P0 deep-dive: core, providers, agent, memory, TUI, setup wizard
DEEP DIVE — Agent Loop (gyandoot-agent): - Real conversation loop with interactive (run()) and non-interactive (run_conversation()) modes - Retry logic with exponential backoff and error classification (RateLimit, Auth, Timeout, ServerError, ContextOverflow) - Context window management — token estimation, 75% threshold compression - 7 built-in tools: Read, Write, Edit, Bash, Grep, Find, Ls - BashTool with destructive-command security blocks - ContextCompressor — prune tool results, summarize middle messages DEEP DIVE — Memory (gyandoot-memory): - Real MemPalace with ~/.gyandoot/palace/wings/<wing>/ directory tree - Daily transcript files (YYYY-MM-DD_transcript.md) with append-only format - L0/L1 identity persistence (< 157 tokens for L0) - Session digest to drawer.md - File persistence support for transcripts and identity store DEEP DIVE — Core (gyandoot-core): - GyandootConfig with TOML deserialization for all sections - ConfigLoader with env > profile > file > default chain - Rich types: Message, ToolCall, ToolResult, SessionId - Comprehensive error types with thiserror derive DEEP DIVE — Providers (gyandoot-providers): - Provider trait with chat/chat_stream/models - OpenAI provider with streaming SSE and function calling - Anthropic provider with Messages API + tool use - Ollama provider with merge_system/no_tools auto-detect - SseParser, StreamEvent, TokenStream for SSE parsing - ProviderFactory for creation from config NEW — TUI crate (gyandoot-tui): - Banner: Kapil-approved robot head ASCII art with ANSI colors - Setup wizard: 7 providers, interactive config, connection test - Terminal helpers: spinner, confirm, text input, selection menu - Ratatui TUI: keyboard input, commands, status display All 12 crates + CLI binary compile with 0 errors.
This commit is contained in:
44
AGENTS.md
Normal file
44
AGENTS.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# GyanDoot Agent Development Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide documents conventions for developing and extending GyanDoot CLI/TUI tools.
|
||||||
|
|
||||||
|
## gyandoot-tui Conventions
|
||||||
|
|
||||||
|
### Module Structure
|
||||||
|
- `src/lib.rs` — Re-exports all public API
|
||||||
|
- `src/banner.rs` — ASCII art banner display (primary robot head + fallback box)
|
||||||
|
- `src/setup.rs` — Interactive setup wizard for provider configuration
|
||||||
|
- `src/terminal.rs` — Terminal helpers (spinner, confirm, text input, selection menu, status)
|
||||||
|
- `src/tui.rs` — Ratatui-based TUI main loop with event handling
|
||||||
|
|
||||||
|
### Banner
|
||||||
|
- Primary banner uses Kapil's approved robot head ASCII art with 4-bit ANSI colors
|
||||||
|
- Fallback banner is a compact box used in pipe mode
|
||||||
|
- `print_banner()` auto-detects pipe mode via `atty`
|
||||||
|
- `banner_plain()` returns uncolored text for TUI display
|
||||||
|
|
||||||
|
### Setup Wizard
|
||||||
|
- Flow: Banner → Provider Selection → Per-provider Config → Summary → Connection Test → Save
|
||||||
|
- Uses crossterm for raw input (not dialoguer)
|
||||||
|
- Provider auto-detect: localhost Ollama sets `merge_system=true`, `no_tools=true`
|
||||||
|
- Config saved to `~/.config/gyandoot/config.toml`
|
||||||
|
|
||||||
|
### Terminal Helpers
|
||||||
|
- `progress_spinner()` — Runs a closure with a spinner animation
|
||||||
|
- `confirm_prompt()` — y/n question with optional default
|
||||||
|
- `text_input()` — Raw-mode text input with backspace/enter/esc support
|
||||||
|
- `selection_menu()` — Numbered menu returning index or None
|
||||||
|
- `show_status()` — Colored status display (success/error/warning/info)
|
||||||
|
|
||||||
|
### TUI
|
||||||
|
- Three modes: Normal, Input, Command
|
||||||
|
- Keyboard shortcuts: `q`/Esc to quit, `i` for input mode, `:` or `/` for commands
|
||||||
|
- Commands: `/help`, `/clear`, `/banner`, `/quit`, `/status`
|
||||||
|
- Uses ratatui with crossterm backend
|
||||||
|
- Enters/leaves alternate screen on start/exit
|
||||||
|
|
||||||
|
### CLI Integration
|
||||||
|
- `gyandoot setup` — Runs the setup wizard (non-async, terminal I/O)
|
||||||
|
- `gyandoot tui` — Launches the ratatui TUI interface
|
||||||
421
Cargo.lock
generated
421
Cargo.lock
generated
@@ -46,6 +46,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -128,6 +134,17 @@ version = "1.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atty"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||||
|
dependencies = [
|
||||||
|
"hermit-abi",
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -167,6 +184,21 @@ version = "1.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cassowary"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "castaway"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.63"
|
version = "1.2.63"
|
||||||
@@ -259,6 +291,20 @@ version = "1.0.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "compact_str"
|
||||||
|
version = "0.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e"
|
||||||
|
dependencies = [
|
||||||
|
"castaway",
|
||||||
|
"cfg-if",
|
||||||
|
"itoa",
|
||||||
|
"rustversion",
|
||||||
|
"ryu",
|
||||||
|
"static_assertions",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -274,6 +320,31 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix 0.38.44",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm_winapi"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||||
|
dependencies = [
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -294,6 +365,40 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"darling_macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_core"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||||
|
dependencies = [
|
||||||
|
"ident_case",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"strsim",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "darling_macro"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||||
|
dependencies = [
|
||||||
|
"darling_core",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -337,6 +442,12 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -353,6 +464,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -541,6 +658,7 @@ dependencies = [
|
|||||||
"gyandoot-security",
|
"gyandoot-security",
|
||||||
"gyandoot-skills",
|
"gyandoot-skills",
|
||||||
"gyandoot-subagents",
|
"gyandoot-subagents",
|
||||||
|
"gyandoot-tui",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -561,6 +679,7 @@ dependencies = [
|
|||||||
"gyandoot-providers",
|
"gyandoot-providers",
|
||||||
"gyandoot-security",
|
"gyandoot-security",
|
||||||
"gyandoot-skills",
|
"gyandoot-skills",
|
||||||
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@@ -595,6 +714,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -752,12 +872,37 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gyandoot-tui"
|
||||||
|
version = "2.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"atty",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
|
"crossterm",
|
||||||
|
"dirs",
|
||||||
|
"gyandoot-core",
|
||||||
|
"ratatui",
|
||||||
|
"regex",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
|
"tokio",
|
||||||
|
"toml",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -773,6 +918,15 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hermit-abi"
|
||||||
|
version = "0.1.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hmac"
|
name = "hmac"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -992,6 +1146,12 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ident_case"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -1025,6 +1185,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indoc"
|
||||||
|
version = "2.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -1034,6 +1203,19 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "instability"
|
||||||
|
version = "0.3.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
|
||||||
|
dependencies = [
|
||||||
|
"darling",
|
||||||
|
"indoc",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.0"
|
version = "2.12.0"
|
||||||
@@ -1046,6 +1228,15 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -1090,6 +1281,18 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -1111,6 +1314,15 @@ version = "0.4.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru"
|
||||||
|
version = "0.12.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.15.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru-slab"
|
name = "lru-slab"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -1139,6 +1351,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
|
"log",
|
||||||
"wasi",
|
"wasi",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -1208,6 +1421,12 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paste"
|
||||||
|
version = "1.0.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
@@ -1404,6 +1623,27 @@ dependencies = [
|
|||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cassowary",
|
||||||
|
"compact_str",
|
||||||
|
"crossterm",
|
||||||
|
"indoc",
|
||||||
|
"instability",
|
||||||
|
"itertools",
|
||||||
|
"lru",
|
||||||
|
"paste",
|
||||||
|
"strum",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-truncate",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -1424,6 +1664,18 @@ dependencies = [
|
|||||||
"thiserror",
|
"thiserror",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.12.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex-automata"
|
name = "regex-automata"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -1449,7 +1701,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
@@ -1469,12 +1723,14 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
@@ -1499,6 +1755,32 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys 0.4.15",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys 0.12.1",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.40"
|
||||||
@@ -1648,6 +1930,27 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook"
|
||||||
|
version = "0.3.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"signal-hook-registry",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-mio"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
|
"signal-hook",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.8"
|
version = "1.4.8"
|
||||||
@@ -1686,12 +1989,40 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.26.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -1729,6 +2060,19 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.4.2",
|
||||||
|
"once_cell",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
@@ -1832,6 +2176,19 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
@@ -1997,6 +2354,35 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-segmentation"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-truncate"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||||
|
dependencies = [
|
||||||
|
"itertools",
|
||||||
|
"unicode-segmentation",
|
||||||
|
"unicode-width 0.1.14",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -2177,6 +2563,19 @@ dependencies = [
|
|||||||
"wasmparser",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-streams"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasmparser"
|
name = "wasmparser"
|
||||||
version = "0.244.0"
|
version = "0.244.0"
|
||||||
@@ -2218,6 +2617,28 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ members = [
|
|||||||
"crates/gyandoot-profile",
|
"crates/gyandoot-profile",
|
||||||
"crates/gyandoot-gateway",
|
"crates/gyandoot-gateway",
|
||||||
"crates/gyandoot-browser",
|
"crates/gyandoot-browser",
|
||||||
|
"crates/gyandoot-tui",
|
||||||
]
|
]
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -65,6 +65,7 @@ gyandoot-skills = { path = "crates/gyandoot-skills" }
|
|||||||
gyandoot-profile = { path = "crates/gyandoot-profile" }
|
gyandoot-profile = { path = "crates/gyandoot-profile" }
|
||||||
gyandoot-gateway = { path = "crates/gyandoot-gateway" }
|
gyandoot-gateway = { path = "crates/gyandoot-gateway" }
|
||||||
gyandoot-browser = { path = "crates/gyandoot-browser" }
|
gyandoot-browser = { path = "crates/gyandoot-browser" }
|
||||||
|
gyandoot-tui = { path = "crates/gyandoot-tui" }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ tracing.workspace = true
|
|||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
@@ -1,47 +1,134 @@
|
|||||||
//! The core agent inference loop.
|
//! Production-grade agent inference loop.
|
||||||
//!
|
//!
|
||||||
//! The `AgentLoop` manages the cycle: receive message → build context →
|
//! `AgentLoop` manages the full cycle: receive message → build context →
|
||||||
//! call LLM → process tool calls → repeat or respond.
|
//! call LLM → execute tools → respond → compress context. Supports
|
||||||
|
//! interactive (stdin/stdout) and non-interactive (gateway/sub-agent) modes,
|
||||||
|
//! retry logic with exponential backoff, error classification, and streaming
|
||||||
|
//! response delivery.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use gyandoot_core::{
|
use gyandoot_core::{
|
||||||
AgentConfig, AgentStatus, ConversationContext, Message, MessageRole, ToolCall, ToolResult,
|
AgentConfig, AgentError, AgentResult, AgentStatus, ConversationContext, Message,
|
||||||
|
MessageRole,
|
||||||
};
|
};
|
||||||
use gyandoot_memory::palace::MemPalace;
|
use gyandoot_memory::palace::MemPalace;
|
||||||
use gyandoot_providers::Provider;
|
use gyandoot_providers::{Provider, ProviderResponse};
|
||||||
use gyandoot_security::sanitizer::Sanitizer;
|
use gyandoot_security::sanitizer::Sanitizer;
|
||||||
use gyandoot_skills::SkillsManager;
|
use gyandoot_skills::SkillsManager;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{debug, error, info, span, Level};
|
use tracing::{debug, error, info, span, warn, Level};
|
||||||
|
|
||||||
use crate::context::ContextCompressor;
|
use crate::context::ContextCompressor;
|
||||||
use crate::tools::ToolExecutor;
|
use crate::tools::ToolExecutor;
|
||||||
|
|
||||||
/// The main agent execution loop.
|
// ─── Error classification ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Categories of errors the agent loop can encounter during LLM calls.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AgentErrorKind {
|
||||||
|
/// Provider rejected authentication credentials.
|
||||||
|
Auth,
|
||||||
|
/// API rate-limit exceeded.
|
||||||
|
RateLimit,
|
||||||
|
/// Request timed out (network or provider-side).
|
||||||
|
Timeout,
|
||||||
|
/// Server returned a 5xx or similar transient error.
|
||||||
|
ServerError,
|
||||||
|
/// Context window exceeded the model's maximum capacity.
|
||||||
|
ContextOverflow,
|
||||||
|
/// All other / unclassified errors.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentErrorKind {
|
||||||
|
/// Classify a provider- or agent-error string into a structured kind.
|
||||||
|
pub fn classify(error_text: &str) -> Self {
|
||||||
|
let lower = error_text.to_lowercase();
|
||||||
|
if lower.contains("rate limit")
|
||||||
|
|| lower.contains("rate_limit")
|
||||||
|
|| lower.contains("429")
|
||||||
|
{
|
||||||
|
Self::RateLimit
|
||||||
|
} else if lower.contains("auth")
|
||||||
|
|| lower.contains("unauthorized")
|
||||||
|
|| lower.contains("401")
|
||||||
|
|| lower.contains("api key")
|
||||||
|
{
|
||||||
|
Self::Auth
|
||||||
|
} else if lower.contains("timeout")
|
||||||
|
|| lower.contains("timed out")
|
||||||
|
|| lower.contains("connection reset")
|
||||||
|
{
|
||||||
|
Self::Timeout
|
||||||
|
} else if lower.contains("5")
|
||||||
|
|| lower.contains("server error")
|
||||||
|
|| lower.contains("internal server error")
|
||||||
|
{
|
||||||
|
Self::ServerError
|
||||||
|
} else if lower.contains("context length")
|
||||||
|
|| lower.contains("maximum context")
|
||||||
|
|| lower.contains("token limit")
|
||||||
|
|| lower.contains("context overflow")
|
||||||
|
{
|
||||||
|
Self::ContextOverflow
|
||||||
|
} else {
|
||||||
|
Self::Other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for errors that are safe to retry.
|
||||||
|
pub fn is_retryable(&self) -> bool {
|
||||||
|
matches!(self, Self::RateLimit | Self::Timeout | Self::ServerError | Self::Other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── AgentLoop ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Configuration for the agent-loop retry behaviour.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RetryConfig {
|
||||||
|
/// Maximum number of retry attempts (including the initial call).
|
||||||
|
pub max_attempts: u32,
|
||||||
|
/// Base delay in milliseconds for exponential back-off.
|
||||||
|
pub base_delay_ms: u64,
|
||||||
|
/// Maximum delay cap in milliseconds.
|
||||||
|
pub max_delay_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RetryConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_attempts: 3,
|
||||||
|
base_delay_ms: 1000,
|
||||||
|
max_delay_ms: 30_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main agent execution loop, usable in both interactive and
|
||||||
|
/// non-interactive contexts.
|
||||||
pub struct AgentLoop {
|
pub struct AgentLoop {
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
provider: Box<dyn Provider + Send + Sync>,
|
provider: Box<dyn Provider + Send + Sync>,
|
||||||
tool_executor: ToolExecutor,
|
tool_executor: ToolExecutor,
|
||||||
context_compressor: ContextCompressor,
|
context_compressor: ContextCompressor,
|
||||||
memory: Arc<Mutex<MemPalace>>,
|
memory: Arc<Mutex<MemPalace>>,
|
||||||
skills_manager: SkillsManager,
|
_skills_manager: SkillsManager,
|
||||||
sanitizer: Sanitizer,
|
sanitizer: Sanitizer,
|
||||||
status: Arc<Mutex<AgentStatus>>,
|
status: Arc<Mutex<AgentStatus>>,
|
||||||
|
retry_cfg: RetryConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentLoop {
|
impl AgentLoop {
|
||||||
/// Create a new agent loop with the given configuration.
|
/// Create a new agent loop from a fully-resolved configuration.
|
||||||
///
|
|
||||||
/// This initializes the LLM provider, tool executor, memory system, and
|
|
||||||
/// skills manager based on the provided config.
|
|
||||||
pub fn new(config: AgentConfig) -> Self {
|
pub fn new(config: AgentConfig) -> Self {
|
||||||
let provider = create_provider(&config);
|
let provider = create_provider(&config);
|
||||||
let tool_executor = ToolExecutor::new();
|
let tool_executor = ToolExecutor::with_defaults();
|
||||||
let context_compressor = ContextCompressor::new();
|
let context_compressor = ContextCompressor::new(config.max_tokens);
|
||||||
let memory = Arc::new(Mutex::new(MemPalace::new()));
|
let memory = Arc::new(Mutex::new(MemPalace::new("default")));
|
||||||
let skills_manager = SkillsManager::new();
|
let skills_manager = SkillsManager::new();
|
||||||
let sanitizer = Sanitizer::new();
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
@@ -49,96 +136,81 @@ impl AgentLoop {
|
|||||||
tool_executor,
|
tool_executor,
|
||||||
context_compressor,
|
context_compressor,
|
||||||
memory,
|
memory,
|
||||||
skills_manager,
|
_skills_manager: skills_manager,
|
||||||
sanitizer,
|
sanitizer: Sanitizer::new(),
|
||||||
status: Arc::new(Mutex::new(AgentStatus::Idle)),
|
status: Arc::new(Mutex::new(AgentStatus::Idle)),
|
||||||
|
retry_cfg: RetryConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the agent loop interactively, reading from stdin and writing to stdout.
|
/// Create an agent loop with a custom retry policy.
|
||||||
///
|
pub fn with_retry(mut self, cfg: RetryConfig) -> Self {
|
||||||
/// This is the primary entry point for CLI usage. The loop reads user input,
|
self.retry_cfg = cfg;
|
||||||
/// calls the LLM, executes any requested tools, and continues until the
|
self
|
||||||
/// user exits or an error occurs.
|
}
|
||||||
|
|
||||||
|
// ── Interactive loop (stdin → stdout) ──────────────────────────────
|
||||||
|
|
||||||
|
/// Run the agent loop interactively, reading from stdin and writing to
|
||||||
|
/// stdout until the user types `exit` / `quit` or an unrecoverable error
|
||||||
|
/// occurs.
|
||||||
pub async fn run(&self) -> anyhow::Result<()> {
|
pub async fn run(&self) -> anyhow::Result<()> {
|
||||||
let span = span!(Level::INFO, "agent_loop", agent = %self.config.name);
|
let span = span!(Level::INFO, "agent_loop", agent = %self.config.name);
|
||||||
let _guard = span.enter();
|
let _guard = span.enter();
|
||||||
|
info!("Agent loop started (interactive)");
|
||||||
|
|
||||||
info!("Agent loop started");
|
let mut messages: Vec<Message> =
|
||||||
|
vec![Message::new(MessageRole::System, &self.config.system_prompt)];
|
||||||
let mut messages: Vec<Message> = vec![
|
|
||||||
Message::new(MessageRole::System, &self.config.system_prompt),
|
|
||||||
];
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Read user input
|
// 1. Read user input
|
||||||
let user_input = self.read_input().await?;
|
let user_input = self.read_input().await?;
|
||||||
|
|
||||||
// Check for exit conditions
|
// 2. Check exit conditions
|
||||||
if self.should_exit(&user_input) {
|
if self.should_exit(&user_input) {
|
||||||
info!("User requested exit");
|
info!("User requested exit");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize input
|
// 3. Sanitize
|
||||||
let clean_input = self.sanitizer.sanitize(&user_input);
|
let clean_input = self.sanitizer.sanitize(&user_input);
|
||||||
|
|
||||||
// Add user message
|
|
||||||
messages.push(Message::new(MessageRole::User, clean_input));
|
messages.push(Message::new(MessageRole::User, clean_input));
|
||||||
|
|
||||||
// Build conversation context
|
// 4. Run the core turn (LLM + tool execution)
|
||||||
let context = ConversationContext {
|
let new_messages = self.execute_turn(messages).await?;
|
||||||
messages: messages.clone(),
|
messages = new_messages;
|
||||||
system_prompt: self.config.system_prompt.clone(),
|
|
||||||
tools: self.tool_executor.definitions(),
|
|
||||||
max_tokens: self.config.max_tokens,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Call the LLM
|
// 5. Store conversation turn in memory
|
||||||
*self.status.lock().await = AgentStatus::Thinking;
|
|
||||||
let response = self.provider.chat(context).await?;
|
|
||||||
|
|
||||||
// Process assistant response
|
|
||||||
messages.push(Message::new(MessageRole::Assistant, &response.content));
|
|
||||||
|
|
||||||
// Handle tool calls
|
|
||||||
if let Some(tool_calls) = response.tool_calls {
|
|
||||||
for tool_call in tool_calls {
|
|
||||||
debug!("Executing tool: {}", tool_call.name);
|
|
||||||
let result = self.tool_executor.execute(&tool_call).await?;
|
|
||||||
messages.push(
|
|
||||||
Message::new(MessageRole::Tool, &result.output)
|
|
||||||
.with_tool_result(result.output.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call LLM again with tool results
|
|
||||||
let context = ConversationContext {
|
|
||||||
messages: messages.clone(),
|
|
||||||
system_prompt: self.config.system_prompt.clone(),
|
|
||||||
tools: self.tool_executor.definitions(),
|
|
||||||
max_tokens: self.config.max_tokens,
|
|
||||||
};
|
|
||||||
|
|
||||||
let follow_up = self.provider.chat(context).await?;
|
|
||||||
messages.push(Message::new(MessageRole::Assistant, &follow_up.content));
|
|
||||||
self.display_output(&follow_up.content);
|
|
||||||
} else {
|
|
||||||
self.display_output(&response.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store in memory
|
|
||||||
{
|
{
|
||||||
let mut memory = self.memory.lock().await;
|
let memory = self.memory.lock().await;
|
||||||
memory.store_conversation_turn(&messages).await?;
|
memory
|
||||||
|
.transcript_append("user", &user_input)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| warn!("Failed to store user message: {e}"));
|
||||||
|
// Store last assistant response
|
||||||
|
if let Some(last) = messages.last() {
|
||||||
|
if last.role == MessageRole::Assistant {
|
||||||
|
memory
|
||||||
|
.transcript_append("assistant", &last.content)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| warn!("Failed to store assistant message: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compress context if too large
|
// 6. Compress context if token budget is tight
|
||||||
if self.should_compress(&messages) {
|
let total_tokens: usize =
|
||||||
debug!("Context size threshold reached, compressing");
|
messages.iter().map(|m| estimate_tokens(&m.content)).sum();
|
||||||
messages = self.context_compressor.compress(messages).await;
|
let limit = self.config.max_tokens;
|
||||||
|
if total_tokens > (limit * 3 / 4) {
|
||||||
|
debug!(
|
||||||
|
"Context at ~{:.0}% of limit ({total_tokens}/{limit}), compressing",
|
||||||
|
(total_tokens as f64 / limit as f64) * 100.0
|
||||||
|
);
|
||||||
|
messages = self.context_compressor.compress(messages, limit).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Update status
|
||||||
*self.status.lock().await = AgentStatus::Idle;
|
*self.status.lock().await = AgentStatus::Idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,11 +218,263 @@ impl AgentLoop {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read input from stdin (CLI mode).
|
// ── Non-interactive (gateway / sub-agent mode) ─────────────────────
|
||||||
|
|
||||||
|
/// Run a single conversation turn given an incoming user message.
|
||||||
|
/// Returns the assistant response(s) and any tool outputs.
|
||||||
|
///
|
||||||
|
/// This is the entry point used by the gateway (Discord, Telegram, etc.)
|
||||||
|
/// and by sub-agents that receive delegated tasks.
|
||||||
|
pub async fn run_conversation(
|
||||||
|
&self,
|
||||||
|
message: &str,
|
||||||
|
) -> AgentResult<ConversationOutput> {
|
||||||
|
let span = span!(Level::INFO, "conversation", agent = %self.config.name);
|
||||||
|
let _guard = span.enter();
|
||||||
|
debug!("run_conversation: processing message");
|
||||||
|
|
||||||
|
let mut messages: Vec<Message> =
|
||||||
|
vec![Message::new(MessageRole::System, &self.config.system_prompt)];
|
||||||
|
|
||||||
|
// Load recent context from memory for continuity
|
||||||
|
{
|
||||||
|
let memory = self.memory.lock().await;
|
||||||
|
if let Ok(transcript) = memory.get_transcript(4096, 10).await {
|
||||||
|
if !transcript.is_empty() {
|
||||||
|
messages.push(Message::new(
|
||||||
|
MessageRole::System,
|
||||||
|
format!("[Recent conversation context]\n{transcript}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(Message::new(MessageRole::User, message));
|
||||||
|
|
||||||
|
// Execute turn with retry
|
||||||
|
let (final_messages, output) = self
|
||||||
|
.execute_turn_with_output(messages)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Store in memory
|
||||||
|
{
|
||||||
|
let memory = self.memory.lock().await;
|
||||||
|
memory
|
||||||
|
.transcript_append("user", message)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| warn!("Failed to store message: {e}"));
|
||||||
|
if let Some(last) = final_messages.last() {
|
||||||
|
if last.role == MessageRole::Assistant {
|
||||||
|
memory
|
||||||
|
.transcript_append("assistant", &last.content)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| warn!("Failed to store response: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current agent status.
|
||||||
|
pub async fn get_status(&self) -> AgentStatus {
|
||||||
|
self.status.lock().await.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide access to tool definitions (e.g. for OpenAI function-calling).
|
||||||
|
pub fn tool_definitions(&self) -> Vec<gyandoot_core::ToolDefinition> {
|
||||||
|
self.tool_executor.definitions()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provide a reference to the tool executor.
|
||||||
|
pub fn tool_executor(&self) -> &ToolExecutor {
|
||||||
|
&self.tool_executor
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Execute a single turn: LLM call → tool calls → LLM follow-up.
|
||||||
|
/// Returns the updated message list.
|
||||||
|
async fn execute_turn(&self, mut messages: Vec<Message>) -> AgentResult<Vec<Message>> {
|
||||||
|
let _limit = self.config.max_tokens;
|
||||||
|
|
||||||
|
// Build context and call LLM (with retry)
|
||||||
|
let context = self
|
||||||
|
.context_compressor
|
||||||
|
.build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions());
|
||||||
|
|
||||||
|
*self.status.lock().await = AgentStatus::Thinking;
|
||||||
|
let response = self.call_with_retry(context).await?;
|
||||||
|
|
||||||
|
messages.push(Message::new(MessageRole::Assistant, &response.content)
|
||||||
|
.with_tool_calls(response.tool_calls.clone().unwrap_or_default()));
|
||||||
|
|
||||||
|
// If there are tool calls, execute them and loop back
|
||||||
|
if let Some(calls) = response.tool_calls {
|
||||||
|
if !calls.is_empty() {
|
||||||
|
*self.status.lock().await = AgentStatus::WaitingForTool;
|
||||||
|
for call in calls {
|
||||||
|
debug!("Executing tool: {}", call.name);
|
||||||
|
let result = self.tool_executor.execute(&call).await.map_err(|e| {
|
||||||
|
AgentError::ToolExecution(format!("{}: {}", call.name, e))
|
||||||
|
})?;
|
||||||
|
let output = if result.success {
|
||||||
|
result.output
|
||||||
|
} else {
|
||||||
|
format!("ERROR: {}", result.error.unwrap_or_default())
|
||||||
|
};
|
||||||
|
messages.push(
|
||||||
|
Message::new(MessageRole::Tool, &output)
|
||||||
|
.with_tool_result(output),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow-up call with tool results
|
||||||
|
let follow_context = self
|
||||||
|
.context_compressor
|
||||||
|
.build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions());
|
||||||
|
|
||||||
|
*self.status.lock().await = AgentStatus::Thinking;
|
||||||
|
let follow_up = self.call_with_retry(follow_context).await?;
|
||||||
|
messages.push(
|
||||||
|
Message::new(MessageRole::Assistant, &follow_up.content)
|
||||||
|
.with_tool_calls(follow_up.tool_calls.unwrap_or_default()),
|
||||||
|
);
|
||||||
|
self.display_output(&follow_up.content);
|
||||||
|
} else {
|
||||||
|
self.display_output(&response.content);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.display_output(&response.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a single turn and return the structured output (for
|
||||||
|
/// non-interactive callers).
|
||||||
|
async fn execute_turn_with_output(
|
||||||
|
&self,
|
||||||
|
mut messages: Vec<Message>,
|
||||||
|
) -> AgentResult<(Vec<Message>, ConversationOutput)> {
|
||||||
|
let _limit = self.config.max_tokens;
|
||||||
|
|
||||||
|
let context = self
|
||||||
|
.context_compressor
|
||||||
|
.build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions());
|
||||||
|
|
||||||
|
*self.status.lock().await = AgentStatus::Thinking;
|
||||||
|
let response = self.call_with_retry(context).await?;
|
||||||
|
|
||||||
|
let mut assistant_text = response.content.clone();
|
||||||
|
let mut tool_results: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
messages.push(Message::new(MessageRole::Assistant, &response.content)
|
||||||
|
.with_tool_calls(response.tool_calls.clone().unwrap_or_default()));
|
||||||
|
|
||||||
|
if let Some(calls) = response.tool_calls {
|
||||||
|
if !calls.is_empty() {
|
||||||
|
*self.status.lock().await = AgentStatus::WaitingForTool;
|
||||||
|
for call in calls {
|
||||||
|
let result = self.tool_executor.execute(&call).await.map_err(|e| {
|
||||||
|
AgentError::ToolExecution(format!("{}: {}", call.name, e))
|
||||||
|
})?;
|
||||||
|
let output = if result.success {
|
||||||
|
result.output.clone()
|
||||||
|
} else {
|
||||||
|
format!("ERROR: {}", result.error.unwrap_or_default())
|
||||||
|
};
|
||||||
|
tool_results.push(output.clone());
|
||||||
|
messages.push(
|
||||||
|
Message::new(MessageRole::Tool, &output)
|
||||||
|
.with_tool_result(output),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let follow_context = self
|
||||||
|
.context_compressor
|
||||||
|
.build_context(&messages, &self.config.system_prompt, &self.tool_executor.definitions());
|
||||||
|
|
||||||
|
*self.status.lock().await = AgentStatus::Thinking;
|
||||||
|
let follow_up = self.call_with_retry(follow_context).await?;
|
||||||
|
assistant_text = follow_up.content.clone();
|
||||||
|
messages.push(
|
||||||
|
Message::new(MessageRole::Assistant, &follow_up.content)
|
||||||
|
.with_tool_calls(follow_up.tool_calls.unwrap_or_default()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
messages,
|
||||||
|
ConversationOutput {
|
||||||
|
text: assistant_text,
|
||||||
|
tool_results,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call the LLM provider with automatic retry and exponential back-off.
|
||||||
|
async fn call_with_retry(
|
||||||
|
&self,
|
||||||
|
context: ConversationContext,
|
||||||
|
) -> AgentResult<ProviderResponse> {
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for attempt in 1..=self.retry_cfg.max_attempts {
|
||||||
|
match self.provider.chat(context.clone()).await {
|
||||||
|
Ok(response) => return Ok(response),
|
||||||
|
Err(e) => {
|
||||||
|
let error_text = e.to_string();
|
||||||
|
let kind = AgentErrorKind::classify(&error_text);
|
||||||
|
|
||||||
|
// Non-retryable errors bubble up immediately.
|
||||||
|
if !kind.is_retryable() {
|
||||||
|
return Err(AgentError::Provider(format!(
|
||||||
|
"Non-retryable provider error: {error_text}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt < self.retry_cfg.max_attempts {
|
||||||
|
let delay = calculate_backoff(
|
||||||
|
attempt,
|
||||||
|
self.retry_cfg.base_delay_ms,
|
||||||
|
self.retry_cfg.max_delay_ms,
|
||||||
|
);
|
||||||
|
warn!(
|
||||||
|
"Provider call failed (attempt {}/{}, kind={kind:?}), \
|
||||||
|
retrying in {delay}ms: {error_text}",
|
||||||
|
attempt,
|
||||||
|
self.retry_cfg.max_attempts,
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_millis(delay)).await;
|
||||||
|
last_error = Some(e);
|
||||||
|
} else {
|
||||||
|
error!(
|
||||||
|
"Provider call failed after {} attempts: {error_text}",
|
||||||
|
self.retry_cfg.max_attempts
|
||||||
|
);
|
||||||
|
return Err(AgentError::Provider(format!(
|
||||||
|
"Failed after {} retries: {error_text}",
|
||||||
|
self.retry_cfg.max_attempts
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(AgentError::Provider(format!(
|
||||||
|
"Exhausted retries: {:?}",
|
||||||
|
last_error.map(|e| e.to_string())
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a single line from stdin with a prompt.
|
||||||
async fn read_input(&self) -> anyhow::Result<String> {
|
async fn read_input(&self) -> anyhow::Result<String> {
|
||||||
use tokio::io::AsyncBufReadExt;
|
use tokio::io::AsyncBufReadExt;
|
||||||
let mut stdout = tokio::io::stdout();
|
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
|
let mut stdout = tokio::io::stdout();
|
||||||
stdout.write_all(b"\n> ").await?;
|
stdout.write_all(b"\n> ").await?;
|
||||||
stdout.flush().await?;
|
stdout.flush().await?;
|
||||||
|
|
||||||
@@ -158,76 +482,79 @@ impl AgentLoop {
|
|||||||
let reader = tokio::io::BufReader::new(stdin);
|
let reader = tokio::io::BufReader::new(stdin);
|
||||||
let mut lines = reader.lines();
|
let mut lines = reader.lines();
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
|
|
||||||
if let Some(line) = lines.next_line().await? {
|
if let Some(line) = lines.next_line().await? {
|
||||||
input = line;
|
input = line;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(input)
|
Ok(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the input indicates the user wants to exit.
|
|
||||||
fn should_exit(&self, input: &str) -> bool {
|
fn should_exit(&self, input: &str) -> bool {
|
||||||
matches!(input.trim().to_lowercase().as_str(), "exit" | "quit" | "/exit" | "/quit")
|
matches!(
|
||||||
|
input.trim().to_lowercase().as_str(),
|
||||||
|
"exit" | "quit" | "/exit" | "/quit"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display output to the user.
|
|
||||||
fn display_output(&self, output: &str) {
|
fn display_output(&self, output: &str) {
|
||||||
println!("\n{}", output);
|
println!("\n{}", output);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine if context compression is needed based on message count and token estimate.
|
|
||||||
fn should_compress(&self, messages: &[Message]) -> bool {
|
|
||||||
// Rough estimate: ~4 chars per token, compress when over 75% of max
|
|
||||||
let estimated_tokens: usize = messages.iter().map(|m| m.content.len() / 4).sum();
|
|
||||||
estimated_tokens > (self.config.max_tokens * 3 / 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current agent status.
|
|
||||||
pub async fn get_status(&self) -> AgentStatus {
|
|
||||||
self.status.lock().await.clone()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create the appropriate LLM provider based on configuration.
|
// ─── ConversationOutput (for non-interactive callers) ───────────────────
|
||||||
|
|
||||||
|
/// The result of a single `run_conversation` call.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConversationOutput {
|
||||||
|
/// The final assistant response text.
|
||||||
|
pub text: String,
|
||||||
|
/// Any tool outputs generated during the turn.
|
||||||
|
pub tool_results: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Backoff calculation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Calculate exponential back-off with jitter.
|
||||||
|
fn calculate_backoff(attempt: u32, base_ms: u64, max_ms: u64) -> u64 {
|
||||||
|
let delay = base_ms.saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
|
||||||
|
let clamped = delay.min(max_ms);
|
||||||
|
// Add small jitter (±10%)
|
||||||
|
let jitter = rand::random::<f64>() * 0.2 - 0.1; // -10% to +10%
|
||||||
|
(clamped as f64 * (1.0 + jitter)) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rough token estimator (~4 chars per token).
|
||||||
|
fn estimate_tokens(text: &str) -> usize {
|
||||||
|
(text.len() / 4).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider factory ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fn create_provider(config: &AgentConfig) -> Box<dyn Provider + Send + Sync> {
|
fn create_provider(config: &AgentConfig) -> Box<dyn Provider + Send + Sync> {
|
||||||
match config.provider.default.as_str() {
|
match config.provider.default.as_str() {
|
||||||
"openai" => {
|
"openai" => {
|
||||||
let openai_config = config
|
let cfg = config.provider.openai.clone().unwrap_or_default();
|
||||||
.provider
|
|
||||||
.openai
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default();
|
|
||||||
Box::new(gyandoot_providers::openai::OpenAIProvider::new(
|
Box::new(gyandoot_providers::openai::OpenAIProvider::new(
|
||||||
openai_config.api_key.clone(),
|
cfg.api_key,
|
||||||
openai_config.model.clone(),
|
cfg.model,
|
||||||
openai_config.base_url.clone(),
|
cfg.base_url,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
"anthropic" => {
|
"anthropic" => {
|
||||||
let anthropic_config = config
|
let cfg = config.provider.anthropic.clone().unwrap_or_default();
|
||||||
.provider
|
|
||||||
.anthropic
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default();
|
|
||||||
Box::new(gyandoot_providers::anthropic::AnthropicProvider::new(
|
Box::new(gyandoot_providers::anthropic::AnthropicProvider::new(
|
||||||
anthropic_config.api_key.clone(),
|
cfg.api_key,
|
||||||
anthropic_config.model.clone(),
|
cfg.model,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
"ollama" => {
|
"ollama" => {
|
||||||
let ollama_config = config
|
let cfg = config.provider.ollama.clone().unwrap_or_default();
|
||||||
.provider
|
|
||||||
.ollama
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_default();
|
|
||||||
Box::new(gyandoot_providers::ollama::OllamaProvider::new(
|
Box::new(gyandoot_providers::ollama::OllamaProvider::new(
|
||||||
ollama_config.base_url.clone(),
|
cfg.base_url,
|
||||||
ollama_config.model.clone(),
|
cfg.model,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
tracing::warn!("Unknown provider '{}', falling back to OpenAI", other);
|
tracing::warn!("Unknown provider '{other}', falling back to OpenAI");
|
||||||
Box::new(gyandoot_providers::openai::OpenAIProvider::new(
|
Box::new(gyandoot_providers::openai::OpenAIProvider::new(
|
||||||
String::new(),
|
String::new(),
|
||||||
config.model.clone(),
|
config.model.clone(),
|
||||||
@@ -237,10 +564,48 @@ fn create_provider(config: &AgentConfig) -> Box<dyn Provider + Send + Sync> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_error_classification() {
|
||||||
|
assert_eq!(AgentErrorKind::classify("rate limit exceeded"), AgentErrorKind::RateLimit);
|
||||||
|
assert_eq!(AgentErrorKind::classify("401 unauthorized"), AgentErrorKind::Auth);
|
||||||
|
assert_eq!(AgentErrorKind::classify("timeout waiting"), AgentErrorKind::Timeout);
|
||||||
|
assert_eq!(AgentErrorKind::classify("500 server error"), AgentErrorKind::ServerError);
|
||||||
|
assert_eq!(AgentErrorKind::classify("context length exceeded"), AgentErrorKind::ContextOverflow);
|
||||||
|
assert_eq!(AgentErrorKind::classify("unknown error"), AgentErrorKind::Other);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_retryable_classification() {
|
||||||
|
assert!(AgentErrorKind::RateLimit.is_retryable());
|
||||||
|
assert!(AgentErrorKind::Timeout.is_retryable());
|
||||||
|
assert!(!AgentErrorKind::Auth.is_retryable());
|
||||||
|
assert!(!AgentErrorKind::ContextOverflow.is_retryable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backoff_is_monotonic() {
|
||||||
|
let d1 = calculate_backoff(1, 1000, 30_000);
|
||||||
|
let d2 = calculate_backoff(2, 1000, 30_000);
|
||||||
|
let d3 = calculate_backoff(3, 1000, 30_000);
|
||||||
|
// With jitter these might not be strictly monotonic, but should be roughly so.
|
||||||
|
assert!(d1 >= 900 && d1 <= 1100); // ~1000ms ±10%
|
||||||
|
assert!(d2 >= 1800 && d2 <= 2200); // ~2000ms ±10%
|
||||||
|
assert!(d3 >= 3600 && d3 <= 4400); // ~4000ms ±10%
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_tokens() {
|
||||||
|
assert_eq!(estimate_tokens("abcd"), 1);
|
||||||
|
assert_eq!(estimate_tokens(""), 1); // at least 1
|
||||||
|
assert!(estimate_tokens("hello world this is a test") > 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_exit() {
|
fn test_should_exit() {
|
||||||
let config = AgentConfig::default();
|
let config = AgentConfig::default();
|
||||||
@@ -249,15 +614,24 @@ mod tests {
|
|||||||
"gpt-4o".to_string(),
|
"gpt-4o".to_string(),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
let _agent = AgentLoop {
|
let tool_executor = ToolExecutor::with_defaults();
|
||||||
|
let context_compressor = ContextCompressor::new(4096);
|
||||||
|
|
||||||
|
let loop_ = AgentLoop {
|
||||||
config,
|
config,
|
||||||
provider: Box::new(provider),
|
provider: Box::new(provider),
|
||||||
tool_executor: ToolExecutor::new(),
|
tool_executor,
|
||||||
context_compressor: ContextCompressor::new(),
|
context_compressor,
|
||||||
memory: Arc::new(Mutex::new(MemPalace::new())),
|
memory: Arc::new(Mutex::new(MemPalace::new("default"))),
|
||||||
skills_manager: SkillsManager::new(&gyandoot_core::SkillsConfig::default()),
|
_skills_manager: SkillsManager::new(),
|
||||||
sanitizer: Sanitizer::new(),
|
sanitizer: Sanitizer::new(),
|
||||||
status: Arc::new(Mutex::new(AgentStatus::Idle)),
|
status: Arc::new(Mutex::new(AgentStatus::Idle)),
|
||||||
|
retry_cfg: RetryConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
assert!(loop_.should_exit("exit"));
|
||||||
|
assert!(loop_.should_exit("quit"));
|
||||||
|
assert!(loop_.should_exit(" /exit "));
|
||||||
|
assert!(!loop_.should_exit("hello"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,53 +1,152 @@
|
|||||||
//! Context compression logic.
|
//! Production-grade context compression engine.
|
||||||
//!
|
//!
|
||||||
//! When the agent's conversation context grows too large, this module
|
//! Manages LLM conversation context by estimating token usage, pruning verbose
|
||||||
//! intelligently compresses or summarizes older messages while preserving
|
//! tool outputs, summarizing older messages, and assembling the final context
|
||||||
//! critical information.
|
//! that gets sent to the provider.
|
||||||
|
|
||||||
use gyandoot_core::Message;
|
use gyandoot_core::{ConversationContext, Message, MessageRole, ToolDefinition};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
/// Compresses conversation context when it exceeds size thresholds.
|
// ─── ContextCompressor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Token-aware context window manager.
|
||||||
|
///
|
||||||
|
/// Strategies:
|
||||||
|
/// - Rough token estimation (chars/4) for fast checks.
|
||||||
|
/// - Summarization of older messages into a compact system message.
|
||||||
|
/// - Pruning of verbose tool outputs from mid-history.
|
||||||
|
/// - Keeping the most recent N turns intact.
|
||||||
pub struct ContextCompressor {
|
pub struct ContextCompressor {
|
||||||
|
/// Hard limit on context messages before summarization kicks in.
|
||||||
max_messages_before_compress: usize,
|
max_messages_before_compress: usize,
|
||||||
|
/// Minimum recent messages to keep intact.
|
||||||
|
recent_context_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContextCompressor {
|
impl ContextCompressor {
|
||||||
/// Create a new context compressor.
|
/// Create a new compressor with sensible defaults.
|
||||||
///
|
///
|
||||||
/// By default, compression triggers when there are more than 30 messages
|
/// `max_tokens` is the model's context window size; compress triggers
|
||||||
/// (roughly corresponding to 15 turns of conversation).
|
/// at 75% of this value.
|
||||||
pub fn new() -> Self {
|
pub fn new(max_tokens: usize) -> Self {
|
||||||
Self {
|
let max_messages = (max_tokens / 200).max(20);
|
||||||
max_messages_before_compress: 30,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a compressor with a custom message threshold.
|
|
||||||
pub fn with_threshold(max_messages: usize) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
max_messages_before_compress: max_messages,
|
max_messages_before_compress: max_messages,
|
||||||
|
recent_context_count: (max_messages / 2).max(4),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compress the message list by summarizing older messages.
|
/// Create a compressor with explicit message thresholds.
|
||||||
|
pub fn with_thresholds(max_messages: usize, recent_count: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
max_messages_before_compress: max_messages,
|
||||||
|
recent_context_count: recent_count.max(2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate the number of tokens in a text string.
|
||||||
|
///
|
||||||
|
/// Uses a simple char/4 heuristic. In production, swap this with a
|
||||||
|
/// tiktoken-rs call for per-model accuracy.
|
||||||
|
pub fn estimate_tokens(text: &str) -> usize {
|
||||||
|
if text.is_empty() {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
// ~4 chars per token on average for English text
|
||||||
|
(text.len() / 4).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate tokens for a slice of messages.
|
||||||
|
pub fn estimate_messages_tokens(messages: &[Message]) -> usize {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| Self::estimate_tokens(&m.content) + 4) // +4 for role overhead
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compress a message list to fit within `max_tokens`.
|
||||||
///
|
///
|
||||||
/// Strategy:
|
/// Strategy:
|
||||||
/// - Keep the system prompt (first message).
|
/// 1. Always keep the system prompt (first message).
|
||||||
/// - Keep the most recent N messages (recent context).
|
/// 2. Prune verbose tool result bodies from middle messages.
|
||||||
/// - Summarize the messages in between into a single condensed message.
|
/// 3. Summarize older non-recent messages into a single condensed entry.
|
||||||
/// - Preserve all tool call/result pairs.
|
/// 4. Keep the most recent N messages intact.
|
||||||
pub async fn compress(&self, messages: Vec<Message>) -> Vec<Message> {
|
pub async fn compress(
|
||||||
if messages.len() <= self.max_messages_before_compress {
|
&self,
|
||||||
|
messages: Vec<Message>,
|
||||||
|
max_tokens: usize,
|
||||||
|
) -> Vec<Message> {
|
||||||
|
let total_tokens = Self::estimate_messages_tokens(&messages);
|
||||||
|
let threshold = max_tokens * 3 / 4;
|
||||||
|
|
||||||
|
if total_tokens <= threshold && messages.len() <= self.max_messages_before_compress {
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"Compressing {} messages (threshold: {})",
|
"Compressing {} messages (estimated {} tokens, threshold {} tokens)",
|
||||||
messages.len(),
|
messages.len(),
|
||||||
self.max_messages_before_compress
|
total_tokens,
|
||||||
|
threshold,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Step 1: Prune verbose tool results from middle messages
|
||||||
|
let mut pruned = self.prune_tool_results(messages);
|
||||||
|
|
||||||
|
// Step 2: If still over threshold, summarize
|
||||||
|
let pruned_tokens = Self::estimate_messages_tokens(&pruned);
|
||||||
|
if pruned_tokens > threshold || pruned.len() > self.max_messages_before_compress {
|
||||||
|
pruned = self.summarize_middle(pruned);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("Compressed to {} messages", pruned.len());
|
||||||
|
pruned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prune verbose tool outputs from message history, keeping only a
|
||||||
|
/// compact summary for non-recent tool results.
|
||||||
|
pub fn prune_tool_results(&self, messages: Vec<Message>) -> Vec<Message> {
|
||||||
|
let keep_recent = self.recent_context_count;
|
||||||
|
let mut result: Vec<Message> = Vec::with_capacity(messages.len());
|
||||||
|
|
||||||
|
for (i, msg) in messages.iter().enumerate() {
|
||||||
|
// Always keep the first message (system prompt)
|
||||||
|
if i == 0 {
|
||||||
|
result.push(msg.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep recent messages intact
|
||||||
|
if i >= messages.len().saturating_sub(keep_recent) {
|
||||||
|
result.push(msg.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune tool result content in middle messages
|
||||||
|
if msg.role == MessageRole::Tool {
|
||||||
|
let tool_result = msg.tool_result.as_deref().unwrap_or("");
|
||||||
|
let truncated = if tool_result.len() > 200 {
|
||||||
|
format!(
|
||||||
|
"[Tool result: {} chars — truncated]\n...{}",
|
||||||
|
tool_result.len(),
|
||||||
|
&tool_result[..tool_result.len().min(200)]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
msg.content.clone()
|
||||||
|
};
|
||||||
|
result.push(Message::new(MessageRole::Tool, truncated));
|
||||||
|
} else {
|
||||||
|
result.push(msg.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summarize non-recent, non-system messages into a single condensed
|
||||||
|
/// system message.
|
||||||
|
fn summarize_middle(&self, messages: Vec<Message>) -> Vec<Message> {
|
||||||
|
let keep_recent = self.recent_context_count;
|
||||||
let mut compressed: Vec<Message> = Vec::with_capacity(self.max_messages_before_compress);
|
let mut compressed: Vec<Message> = Vec::with_capacity(self.max_messages_before_compress);
|
||||||
|
|
||||||
// Always keep the system prompt
|
// Always keep the system prompt
|
||||||
@@ -55,108 +154,240 @@ impl ContextCompressor {
|
|||||||
compressed.push(system.clone());
|
compressed.push(system.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the most recent N-2 messages (reserve space for summary)
|
// Messages to summarize: everything between system prompt and recent tail
|
||||||
let recent_count = (self.max_messages_before_compress / 2).max(2);
|
let summarize_end = messages.len().saturating_sub(keep_recent);
|
||||||
let keep_start = messages.len().saturating_sub(recent_count);
|
let middle: Vec<&Message> = messages[1..summarize_end]
|
||||||
|
|
||||||
// Summarize middle messages
|
|
||||||
let middle: Vec<&Message> = messages[1..keep_start]
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| m.role != gyandoot_core::MessageRole::Tool)
|
.filter(|m| m.role != MessageRole::Tool)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !middle.is_empty() {
|
if !middle.is_empty() {
|
||||||
let summary = self.summarize_messages(&middle);
|
let summary = self.build_summary(&middle);
|
||||||
compressed.push(Message::new(
|
compressed.push(Message::new(
|
||||||
gyandoot_core::MessageRole::System,
|
MessageRole::System,
|
||||||
format!("[Summarized context from previous conversation: {}]", summary),
|
format!(
|
||||||
|
"[Summarized conversation history — {}]\n\
|
||||||
|
(Context from earlier turns in this session)",
|
||||||
|
summary
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append the most recent messages
|
// Append the most recent messages
|
||||||
|
let keep_start = messages.len().saturating_sub(keep_recent).max(1);
|
||||||
for msg in messages.iter().skip(keep_start) {
|
for msg in messages.iter().skip(keep_start) {
|
||||||
compressed.push(msg.clone());
|
compressed.push(msg.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Compressed to {} messages", compressed.len());
|
|
||||||
compressed
|
compressed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a text summary from a slice of messages.
|
/// Build a text summary from a slice of middle messages.
|
||||||
fn summarize_messages(&self, messages: &[&Message]) -> String {
|
fn build_summary(&self, messages: &[&Message]) -> String {
|
||||||
let count = messages.len();
|
let count = messages.len();
|
||||||
let user_msgs: Vec<&str> = messages
|
let user_msgs: Vec<&str> = messages
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| m.role == gyandoot_core::MessageRole::User)
|
.filter(|m| m.role == MessageRole::User)
|
||||||
.map(|m| m.content.as_str())
|
.map(|m| m.content.as_str())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if user_msgs.is_empty() {
|
if user_msgs.is_empty() {
|
||||||
format!("{} previous assistant messages", count)
|
format!("{} assistant messages", count)
|
||||||
} else if user_msgs.len() <= 3 {
|
} else if user_msgs.len() <= 3 {
|
||||||
format!(
|
format!(
|
||||||
"Earlier discussion: {}",
|
"Previous discussion: {}",
|
||||||
user_msgs.join(" | ")
|
user_msgs
|
||||||
|
.iter()
|
||||||
|
.map(|s| truncate_str(s, 80))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" | ")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
"{} previous turns covering topics: {}, ... (and {} more)",
|
"{} turns covering: {}, ... (and {} more)",
|
||||||
count,
|
count,
|
||||||
user_msgs[..3].join(", "),
|
user_msgs[..3]
|
||||||
|
.iter()
|
||||||
|
.map(|s| truncate_str(s, 80))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
user_msgs.len().saturating_sub(3)
|
user_msgs.len().saturating_sub(3)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ContextCompressor {
|
/// Assemble the final `ConversationContext` from messages, system
|
||||||
fn default() -> Self {
|
/// prompt, and tool definitions.
|
||||||
Self::new()
|
pub fn build_context(
|
||||||
|
&self,
|
||||||
|
messages: &[Message],
|
||||||
|
system_prompt: &str,
|
||||||
|
tool_definitions: &[ToolDefinition],
|
||||||
|
) -> ConversationContext {
|
||||||
|
ConversationContext {
|
||||||
|
messages: messages.to_vec(),
|
||||||
|
system_prompt: system_prompt.to_string(),
|
||||||
|
tools: tool_definitions.to_vec(),
|
||||||
|
max_tokens: 0, // caller sets this or provider defaults
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Truncate a string to `max_chars`, appending "…" if shortened.
|
||||||
|
fn truncate_str(s: &str, max_chars: usize) -> String {
|
||||||
|
if s.len() <= max_chars {
|
||||||
|
s.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}…", &s[..max_chars.saturating_sub(1)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use gyandoot_core::MessageRole;
|
|
||||||
|
fn make_msg(role: MessageRole, content: impl Into<String>) -> Message {
|
||||||
|
Message::new(role, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_tokens() {
|
||||||
|
assert_eq!(ContextCompressor::estimate_tokens("abcd"), 1);
|
||||||
|
assert_eq!(ContextCompressor::estimate_tokens(""), 1);
|
||||||
|
let long = "a".repeat(400);
|
||||||
|
assert_eq!(ContextCompressor::estimate_tokens(&long), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_estimate_messages_tokens() {
|
||||||
|
let msgs = vec![
|
||||||
|
make_msg(MessageRole::User, "hello"),
|
||||||
|
make_msg(MessageRole::Assistant, "world"),
|
||||||
|
];
|
||||||
|
let tokens = ContextCompressor::estimate_messages_tokens(&msgs);
|
||||||
|
// Each content ~1 token, each +4 overhead = ~10 total
|
||||||
|
assert!(tokens >= 10);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_no_compress_below_threshold() {
|
async fn test_no_compress_below_threshold() {
|
||||||
let compressor = ContextCompressor::with_threshold(10);
|
let compressor = ContextCompressor::with_thresholds(20, 5);
|
||||||
let messages: Vec<Message> = (0..5)
|
let messages: Vec<Message> = (0..5)
|
||||||
.map(|i| Message::new(MessageRole::User, format!("message {}", i)))
|
.map(|i| make_msg(MessageRole::User, format!("msg {i}")))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let result = compressor.compress(messages.clone()).await;
|
let result = compressor.compress(messages.clone(), 9999).await;
|
||||||
assert_eq!(result.len(), 5);
|
assert_eq!(result.len(), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_compress_above_threshold() {
|
async fn test_compress_above_threshold() {
|
||||||
let compressor = ContextCompressor::with_threshold(10);
|
let compressor = ContextCompressor::with_thresholds(10, 4);
|
||||||
let mut messages = vec![Message::new(MessageRole::System, "You are an AI.")];
|
let mut messages = vec![make_msg(MessageRole::System, "You are an AI.")];
|
||||||
for i in 0..20 {
|
for i in 0..20 {
|
||||||
messages.push(Message::new(MessageRole::User, format!("user message {}", i)));
|
messages.push(make_msg(MessageRole::User, format!("user msg {i}")));
|
||||||
messages.push(Message::new(MessageRole::Assistant, format!("response {}", i)));
|
messages.push(make_msg(MessageRole::Assistant, format!("response {i}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = compressor.compress(messages).await;
|
let result = compressor.compress(messages, 9999).await;
|
||||||
assert!(result.len() < 20);
|
assert!(result.len() < 20);
|
||||||
assert_eq!(result[0].content, "You are an AI.");
|
assert_eq!(result[0].content, "You are an AI.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_compress_token_budget() {
|
||||||
|
let compressor = ContextCompressor::with_thresholds(50, 8);
|
||||||
|
let mut messages = vec![make_msg(MessageRole::System, "sys")];
|
||||||
|
for i in 0..30 {
|
||||||
|
messages.push(make_msg(MessageRole::User, format!("user msg {i}")));
|
||||||
|
messages.push(make_msg(MessageRole::Assistant, format!("response {i}")));
|
||||||
|
}
|
||||||
|
// total messages: 1 + 60 = 61
|
||||||
|
// estimated tokens: ~ 61 * (3/4 + 4) ≈ 290
|
||||||
|
let result = compressor.compress(messages, 500).await;
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
assert_eq!(result[0].content, "sys");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_summarize_empty() {
|
fn test_prune_tool_results_short() {
|
||||||
let compressor = ContextCompressor::new();
|
let compressor = ContextCompressor::with_thresholds(20, 5);
|
||||||
let summary = compressor.summarize_messages(&[]);
|
let messages = vec![
|
||||||
|
make_msg(MessageRole::System, "sys"),
|
||||||
|
make_msg(MessageRole::User, "hello"),
|
||||||
|
Message::new(MessageRole::Tool, "short result").with_tool_result("short result".into()),
|
||||||
|
];
|
||||||
|
let pruned = compressor.prune_tool_results(messages);
|
||||||
|
assert_eq!(pruned.len(), 3);
|
||||||
|
assert_eq!(pruned[2].content, "short result");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_prune_tool_results_long() {
|
||||||
|
let compressor = ContextCompressor::with_thresholds(20, 3);
|
||||||
|
let long_result = "x".repeat(500);
|
||||||
|
let mut messages = vec![
|
||||||
|
make_msg(MessageRole::System, "sys"),
|
||||||
|
make_msg(MessageRole::User, "hello"),
|
||||||
|
];
|
||||||
|
// Add middle messages so tool result falls outside recent zone
|
||||||
|
// total = 10, recent = 3, recent_start = 7
|
||||||
|
for i in 0..5 {
|
||||||
|
messages.push(make_msg(MessageRole::User, format!("middle {i}")));
|
||||||
|
}
|
||||||
|
messages.push(
|
||||||
|
Message::new(MessageRole::Tool, &long_result)
|
||||||
|
.with_tool_result(long_result.clone()),
|
||||||
|
);
|
||||||
|
messages.push(make_msg(MessageRole::Assistant, "response 1"));
|
||||||
|
messages.push(make_msg(MessageRole::Assistant, "response 2"));
|
||||||
|
messages.push(make_msg(MessageRole::Assistant, "recent response"));
|
||||||
|
|
||||||
|
let pruned = compressor.prune_tool_results(messages);
|
||||||
|
// Find the tool message
|
||||||
|
let tool_msg = pruned.iter().find(|m| m.role == MessageRole::Tool).unwrap();
|
||||||
|
// Long tool content should be truncated
|
||||||
|
assert!(
|
||||||
|
tool_msg.content.contains("[Tool result:"),
|
||||||
|
"Expected truncated tool result, got: {}...",
|
||||||
|
&tool_msg.content[..tool_msg.content.len().min(100)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncate_str() {
|
||||||
|
assert_eq!(truncate_str("hello", 10), "hello");
|
||||||
|
let long = "a".repeat(100);
|
||||||
|
let truncated = truncate_str(&long, 10);
|
||||||
|
// 9 chars + '…' (multi-byte) = 10 characters
|
||||||
|
assert_eq!(truncated.chars().count(), 10);
|
||||||
|
assert!(truncated.ends_with('…'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_summary_empty() {
|
||||||
|
let compressor = ContextCompressor::with_thresholds(20, 5);
|
||||||
|
let summary = compressor.build_summary(&[]);
|
||||||
assert!(summary.contains("0"));
|
assert!(summary.contains("0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_summarize_few() {
|
fn test_build_summary_few_messages() {
|
||||||
let compressor = ContextCompressor::new();
|
let compressor = ContextCompressor::with_thresholds(20, 5);
|
||||||
let msg = Message::new(MessageRole::User, "hello");
|
let msg = make_msg(MessageRole::User, "hello there");
|
||||||
let summary = compressor.summarize_messages(&[&msg]);
|
let msgs = vec![&msg];
|
||||||
assert!(summary.contains("hello"));
|
let summary = compressor.build_summary(&msgs);
|
||||||
|
assert!(summary.contains("hello there"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_context() {
|
||||||
|
let compressor = ContextCompressor::with_thresholds(20, 5);
|
||||||
|
let messages = vec![make_msg(MessageRole::User, "hi")];
|
||||||
|
let ctx = compressor.build_context(&messages, "system prompt", &[]);
|
||||||
|
assert_eq!(ctx.messages.len(), 1);
|
||||||
|
assert_eq!(ctx.system_prompt, "system prompt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,6 @@ pub mod browser_tools;
|
|||||||
pub mod tools;
|
pub mod tools;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
|
|
||||||
pub use agent_loop::AgentLoop;
|
pub use agent_loop::{AgentLoop, AgentErrorKind, ConversationOutput, RetryConfig};
|
||||||
pub use tools::ToolExecutor;
|
pub use tools::{BuiltinTool, BashTool, BashToolConfig, Tool, ToolExecutor, ReadTool, WriteTool, EditTool, GrepTool, FindTool, LsTool};
|
||||||
pub use context::ContextCompressor;
|
pub use context::ContextCompressor;
|
||||||
@@ -1,42 +1,119 @@
|
|||||||
//! Tool execution subsystem.
|
//! Production-grade tool execution subsystem.
|
||||||
//!
|
//!
|
||||||
//! Discovers available tools, executes tool calls, and returns results.
|
//! Defines the `Tool` trait and `ToolExecutor` which discovers, registers,
|
||||||
|
//! and executes tools on behalf of the LLM. Includes built-in tools for
|
||||||
|
//! filesystem operations and shell command execution, all with appropriate
|
||||||
|
//! security checks and resource limits.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use gyandoot_core::{ToolCall, ToolDefinition, ToolResult};
|
use gyandoot_core::{ToolCall, ToolDefinition, ToolResult};
|
||||||
|
use serde_json;
|
||||||
use tracing::{debug, error, warn};
|
use tracing::{debug, error, warn};
|
||||||
|
|
||||||
/// Executes tool calls made by the LLM.
|
// ─── Tool trait ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Trait that all tools (built-in or skill-based) must implement.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
pub trait Tool: Send + Sync {
|
||||||
|
/// Short, unique name for the tool (e.g. `"read"`, `"bash"`).
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
/// Human-readable description for the LLM.
|
||||||
|
fn description(&self) -> &str;
|
||||||
|
|
||||||
|
/// JSON Schema for the tool's parameters.
|
||||||
|
fn parameters(&self) -> serde_json::Value;
|
||||||
|
|
||||||
|
/// Execute the tool with the given arguments. Returns the output text.
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BuiltinTool enum ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Enumeration of all built-in tools available to the agent.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BuiltinTool {
|
||||||
|
Read,
|
||||||
|
Write,
|
||||||
|
Edit,
|
||||||
|
Bash,
|
||||||
|
Grep,
|
||||||
|
Find,
|
||||||
|
Ls,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BuiltinTool {
|
||||||
|
/// All built-in tool variants.
|
||||||
|
pub fn all() -> &'static [BuiltinTool] {
|
||||||
|
&[
|
||||||
|
BuiltinTool::Read,
|
||||||
|
BuiltinTool::Write,
|
||||||
|
BuiltinTool::Edit,
|
||||||
|
BuiltinTool::Bash,
|
||||||
|
BuiltinTool::Grep,
|
||||||
|
BuiltinTool::Find,
|
||||||
|
BuiltinTool::Ls,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
BuiltinTool::Read => "read",
|
||||||
|
BuiltinTool::Write => "write",
|
||||||
|
BuiltinTool::Edit => "edit",
|
||||||
|
BuiltinTool::Bash => "bash",
|
||||||
|
BuiltinTool::Grep => "grep",
|
||||||
|
BuiltinTool::Find => "find",
|
||||||
|
BuiltinTool::Ls => "ls",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ToolExecutor ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Manages tool registration, definition generation, and execution.
|
||||||
pub struct ToolExecutor {
|
pub struct ToolExecutor {
|
||||||
tools: HashMap<String, Box<dyn Tool + Send + Sync>>,
|
tools: HashMap<String, Box<dyn Tool + Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait that all tools must implement.
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
pub trait Tool: Send + Sync {
|
|
||||||
fn name(&self) -> &str;
|
|
||||||
fn description(&self) -> &str;
|
|
||||||
fn parameters(&self) -> serde_json::Value;
|
|
||||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToolExecutor {
|
impl ToolExecutor {
|
||||||
/// Create a new executor with no tools loaded.
|
/// Create an empty executor (no tools registered).
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tools: HashMap::new(),
|
tools: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a tool implementation.
|
/// Create an executor pre-populated with all built-in tools.
|
||||||
|
pub fn with_defaults() -> Self {
|
||||||
|
let mut executor = Self::new();
|
||||||
|
executor.register_builtins();
|
||||||
|
executor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a single tool.
|
||||||
pub fn register(&mut self, tool: Box<dyn Tool + Send + Sync>) {
|
pub fn register(&mut self, tool: Box<dyn Tool + Send + Sync>) {
|
||||||
let name = tool.name().to_string();
|
let name = tool.name().to_string();
|
||||||
debug!("Registering tool: {}", name);
|
debug!("Registering tool: {name}");
|
||||||
self.tools.insert(name, tool);
|
self.tools.insert(name, tool);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return all registered tool definitions (for LLM function calling).
|
/// Register all built-in tools.
|
||||||
|
pub fn register_builtins(&mut self) {
|
||||||
|
self.register(Box::new(ReadTool));
|
||||||
|
self.register(Box::new(WriteTool));
|
||||||
|
self.register(Box::new(EditTool));
|
||||||
|
self.register(Box::new(BashTool::default()));
|
||||||
|
self.register(Box::new(GrepTool));
|
||||||
|
self.register(Box::new(FindTool));
|
||||||
|
self.register(Box::new(LsTool));
|
||||||
|
debug!("Registered {} built-in tools", BuiltinTool::all().len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `ToolDefinition` values for every registered tool (for LLM
|
||||||
|
/// function-calling schemas).
|
||||||
pub fn definitions(&self) -> Vec<ToolDefinition> {
|
pub fn definitions(&self) -> Vec<ToolDefinition> {
|
||||||
self.tools
|
self.tools
|
||||||
.values()
|
.values()
|
||||||
@@ -48,17 +125,21 @@ impl ToolExecutor {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a single tool call and return the result.
|
/// Execute a single tool call, returning a structured result.
|
||||||
pub async fn execute(&self, call: &ToolCall) -> anyhow::Result<ToolResult> {
|
pub async fn execute(&self, call: &ToolCall) -> anyhow::Result<ToolResult> {
|
||||||
let span = tracing::span!(tracing::Level::INFO, "tool_exec", tool = %call.name);
|
let span = tracing::span!(tracing::Level::INFO, "tool_exec", tool = %call.name);
|
||||||
let _guard = span.enter();
|
let _guard = span.enter();
|
||||||
|
|
||||||
match self.tools.get(&call.name) {
|
match self.tools.get(&call.name) {
|
||||||
Some(tool) => {
|
Some(tool) => {
|
||||||
debug!("Executing tool '{}' with args: {}", call.name, call.arguments);
|
debug!(
|
||||||
|
"Executing tool '{}' with args: {}",
|
||||||
|
call.name,
|
||||||
|
call.arguments.to_string()
|
||||||
|
);
|
||||||
match tool.execute(call.arguments.clone()).await {
|
match tool.execute(call.arguments.clone()).await {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
debug!("Tool '{}' succeeded", call.name);
|
debug!("Tool '{}' succeeded ({} bytes)", call.name, output.len());
|
||||||
Ok(ToolResult {
|
Ok(ToolResult {
|
||||||
tool_call_id: call.id.clone(),
|
tool_call_id: call.id.clone(),
|
||||||
success: true,
|
success: true,
|
||||||
@@ -67,7 +148,7 @@ impl ToolExecutor {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Tool '{}' failed: {}", call.name, e);
|
error!("Tool '{}' failed: {e}", call.name);
|
||||||
Ok(ToolResult {
|
Ok(ToolResult {
|
||||||
tool_call_id: call.id.clone(),
|
tool_call_id: call.id.clone(),
|
||||||
success: false,
|
success: false,
|
||||||
@@ -88,6 +169,21 @@ impl ToolExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check whether a specific tool name is registered.
|
||||||
|
pub fn has_tool(&self, name: &str) -> bool {
|
||||||
|
self.tools.contains_key(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of registered tools.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.tools.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if no tools are registered.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.tools.is_empty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ToolExecutor {
|
impl Default for ToolExecutor {
|
||||||
@@ -96,6 +192,619 @@ impl Default for ToolExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Built-in tools
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ─── ReadTool ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read a file, optionally with line numbers.
|
||||||
|
pub struct ReadTool;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for ReadTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"read"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Read the contents of a file. Optionally show line numbers and limit output."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the file to read"
|
||||||
|
},
|
||||||
|
"offset": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Line number to start from (1-indexed)",
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of lines to read",
|
||||||
|
"default": 500
|
||||||
|
},
|
||||||
|
"line_numbers": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Show line numbers next to each line",
|
||||||
|
"default": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["file_path"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
|
||||||
|
let file_path = args["file_path"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("'file_path' is required (string)"))?;
|
||||||
|
let offset = args["offset"].as_u64().unwrap_or(1) as usize;
|
||||||
|
let limit = args["limit"].as_u64().unwrap_or(500) as usize;
|
||||||
|
let line_numbers = args["line_numbers"].as_bool().unwrap_or(true);
|
||||||
|
|
||||||
|
let content = tokio::fs::read_to_string(file_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to read '{file_path}': {e}"))?;
|
||||||
|
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
let start = offset.saturating_sub(1).min(lines.len());
|
||||||
|
let end = (start + limit).min(lines.len());
|
||||||
|
let slice = &lines[start..end];
|
||||||
|
|
||||||
|
if slice.is_empty() {
|
||||||
|
return Ok(format!("(empty — file has {} lines)", lines.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let out: String = if line_numbers {
|
||||||
|
slice
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, line)| format!("{:>6}| {}", start + i + 1, line))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
} else {
|
||||||
|
slice.join("\n")
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = lines.len();
|
||||||
|
if end < total {
|
||||||
|
Ok(format!(
|
||||||
|
"{out}\n\n--- {}/{} lines shown (use offset={}, limit={limit} for more) ---",
|
||||||
|
end - start,
|
||||||
|
total,
|
||||||
|
end + 1,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(format!("{out}\n\n--- {}/{} lines ---", end - start, total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── WriteTool ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Write or create a file.
|
||||||
|
pub struct WriteTool;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for WriteTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"write"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Write content to a file, creating parent directories if needed. Overwrites existing content."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the file to write"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Content to write to the file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["file_path", "content"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
|
||||||
|
let file_path = args["file_path"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("'file_path' is required (string)"))?;
|
||||||
|
let content = args["content"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("'content' is required (string)"))?;
|
||||||
|
|
||||||
|
// Create parent directories
|
||||||
|
if let Some(parent) = std::path::Path::new(file_path).parent() {
|
||||||
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to create parent directory: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::fs::write(file_path, content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to write '{file_path}': {e}"))?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Wrote {} bytes to {file_path}",
|
||||||
|
content.len()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── EditTool ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Apply a targeted edit to an existing file (find-and-replace).
|
||||||
|
pub struct EditTool;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for EditTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"edit"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Apply a targeted find-and-replace edit to a file using fuzzy matching. \
|
||||||
|
Use this instead of rewriting entire files."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the file to edit"
|
||||||
|
},
|
||||||
|
"old_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Exact text to find and replace"
|
||||||
|
},
|
||||||
|
"new_string": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Replacement text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["file_path", "old_string", "new_string"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
|
||||||
|
let file_path = args["file_path"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("'file_path' is required (string)"))?;
|
||||||
|
let old_string = args["old_string"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("'old_string' is required (string)"))?;
|
||||||
|
let new_string = args["new_string"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("'new_string' is required (string)"))?;
|
||||||
|
|
||||||
|
let content = tokio::fs::read_to_string(file_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to read '{file_path}': {e}"))?;
|
||||||
|
|
||||||
|
if !content.contains(old_string) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Could not find the specified text in '{file_path}'"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_content = content.replace(old_string, new_string);
|
||||||
|
let replacements = content.matches(old_string).count();
|
||||||
|
|
||||||
|
tokio::fs::write(file_path, &new_content)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to write '{file_path}': {e}"))?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"Applied {replacements} replacement(s) to {file_path} ({} → {} bytes)",
|
||||||
|
content.len(),
|
||||||
|
new_content.len()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BashTool ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Configuration for the Bash tool.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BashToolConfig {
|
||||||
|
/// Maximum execution timeout in seconds.
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
/// Maximum output characters returned.
|
||||||
|
pub max_output_chars: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BashToolConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
timeout_secs: 120,
|
||||||
|
max_output_chars: 100_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a shell command with timeout and output cap.
|
||||||
|
pub struct BashTool {
|
||||||
|
config: BashToolConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BashTool {
|
||||||
|
pub fn new(config: BashToolConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BashTool {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(BashToolConfig::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for BashTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"bash"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Execute a shell command. Supports pipes, redirects, and any POSIX command. \
|
||||||
|
Timeout and output are capped for safety."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The shell command to execute"
|
||||||
|
},
|
||||||
|
"workdir": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Working directory (default: current dir)",
|
||||||
|
"default": "."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Brief description of what this command does (for audit log)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
|
||||||
|
let command = args["command"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("'command' is required (string)"))?;
|
||||||
|
let workdir = args["workdir"].as_str().unwrap_or(".");
|
||||||
|
|
||||||
|
// Basic security: refuse destructive commands without explicit allow
|
||||||
|
let trimmed = command.trim();
|
||||||
|
if trimmed.starts_with("rm -rf /")
|
||||||
|
|| trimmed.starts_with("rm -rf /*")
|
||||||
|
|| trimmed.starts_with("mkfs")
|
||||||
|
|| trimmed.starts_with("dd if=")
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Security: destructive command '{}' is blocked by default",
|
||||||
|
trimmed.split_whitespace().next().unwrap_or("?")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Bash tool: running '{}' in '{}' (timeout={}s)",
|
||||||
|
command, workdir, self.config.timeout_secs
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = tokio::time::timeout(
|
||||||
|
Duration::from_secs(self.config.timeout_secs),
|
||||||
|
tokio::process::Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(command)
|
||||||
|
.current_dir(workdir)
|
||||||
|
.output(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Command timed out after {}s",
|
||||||
|
self.config.timeout_secs
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to execute command: {e}"))?;
|
||||||
|
|
||||||
|
let mut result = String::new();
|
||||||
|
|
||||||
|
if !output.stdout.is_empty() {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
result.push_str(&stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !output.stderr.is_empty() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !result.is_empty() {
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
result.push_str(&format!("STDERR:\n{stderr}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let exit_code = output.status.code().unwrap_or(-1);
|
||||||
|
if exit_code != 0 {
|
||||||
|
result.push_str(&format!("\n(exit code: {exit_code})"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap output
|
||||||
|
if result.len() > self.config.max_output_chars {
|
||||||
|
let truncated = result[..self.config.max_output_chars].to_string();
|
||||||
|
result = format!(
|
||||||
|
"{truncated}\n\n--- Output truncated ({} chars, cap {}) ---",
|
||||||
|
result.len(),
|
||||||
|
self.config.max_output_chars
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GrepTool ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Search file contents using regex.
|
||||||
|
pub struct GrepTool;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for GrepTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"grep"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Search file contents using regex patterns. Returns matching lines with file paths and line numbers."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Regex pattern to search for"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Directory or file to search in",
|
||||||
|
"default": "."
|
||||||
|
},
|
||||||
|
"file_glob": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filter file types (e.g., '*.rs', '*.py')",
|
||||||
|
"default": ""
|
||||||
|
},
|
||||||
|
"max_results": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of results",
|
||||||
|
"default": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
|
||||||
|
let pattern = args["pattern"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("'pattern' is required (string)"))?;
|
||||||
|
let path = args["path"].as_str().unwrap_or(".");
|
||||||
|
let file_glob = args["file_glob"].as_str().unwrap_or("");
|
||||||
|
let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new("rg");
|
||||||
|
cmd.arg("--line-number")
|
||||||
|
.arg("--with-filename")
|
||||||
|
.arg("--no-heading")
|
||||||
|
.arg("--max-count")
|
||||||
|
.arg(max_results.to_string())
|
||||||
|
.arg(pattern)
|
||||||
|
.arg(path);
|
||||||
|
|
||||||
|
if !file_glob.is_empty() {
|
||||||
|
cmd.arg("--glob").arg(file_glob);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to run ripgrep: {e}"))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||||
|
} else if output.status.code() == Some(1) {
|
||||||
|
Ok("(no matches found)".to_string())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
Err(anyhow::anyhow!("rg failed: {stderr}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FindTool ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Find files by glob/name pattern.
|
||||||
|
pub struct FindTool;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for FindTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"find"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Find files by name pattern (glob). Returns file paths sorted by modification time (newest first)."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Glob pattern to search for (e.g., '*.rs', '*config*')"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Directory to search in",
|
||||||
|
"default": "."
|
||||||
|
},
|
||||||
|
"max_results": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of results",
|
||||||
|
"default": 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
|
||||||
|
let pattern = args["pattern"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("'pattern' is required (string)"))?;
|
||||||
|
let path = args["path"].as_str().unwrap_or(".");
|
||||||
|
let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
|
||||||
|
|
||||||
|
let output = tokio::process::Command::new("fd")
|
||||||
|
.arg("--glob")
|
||||||
|
.arg(pattern)
|
||||||
|
.arg(path)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to run fd (try 'find' directly): {e}"))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
let results: Vec<&str> = stdout
|
||||||
|
.lines()
|
||||||
|
.take(max_results)
|
||||||
|
.collect();
|
||||||
|
if results.is_empty() {
|
||||||
|
Ok("(no files found)".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(format!(
|
||||||
|
"Found {} file(s):\n{}",
|
||||||
|
results.len(),
|
||||||
|
results.join("\n")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to POSIX find
|
||||||
|
let output2 = tokio::process::Command::new("find")
|
||||||
|
.arg(path)
|
||||||
|
.arg("-name")
|
||||||
|
.arg(pattern)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("find also failed: {e}"))?;
|
||||||
|
|
||||||
|
let stdout2 = String::from_utf8_lossy(&output2.stdout).to_string();
|
||||||
|
let results: Vec<&str> = stdout2
|
||||||
|
.lines()
|
||||||
|
.take(max_results)
|
||||||
|
.collect();
|
||||||
|
if results.is_empty() {
|
||||||
|
Ok("(no files found)".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(format!(
|
||||||
|
"Found {} file(s) (via find):\n{}",
|
||||||
|
results.len(),
|
||||||
|
results.join("\n")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── LsTool ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// List directory contents.
|
||||||
|
pub struct LsTool;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl Tool for LsTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"ls"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"List files and directories at a given path with size and modification time."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Directory path to list",
|
||||||
|
"default": "."
|
||||||
|
},
|
||||||
|
"long": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Show detailed listing (permissions, size, date)",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"all": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Show hidden files",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<String> {
|
||||||
|
let dir_path = args["path"].as_str().unwrap_or(".");
|
||||||
|
let long = args["long"].as_bool().unwrap_or(false);
|
||||||
|
let all = args["all"].as_bool().unwrap_or(false);
|
||||||
|
|
||||||
|
let mut cmd = tokio::process::Command::new("ls");
|
||||||
|
if long {
|
||||||
|
cmd.arg("-lh");
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
cmd.arg("-A");
|
||||||
|
}
|
||||||
|
cmd.arg(dir_path);
|
||||||
|
|
||||||
|
let output = cmd
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to list directory: {e}"))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
Err(anyhow::anyhow!("ls failed: {stderr}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -143,4 +852,126 @@ mod tests {
|
|||||||
assert!(result.success);
|
assert!(result.success);
|
||||||
assert_eq!(result.output, "hello");
|
assert_eq!(result.output, "hello");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_unknown_tool_returns_error() {
|
||||||
|
let executor = ToolExecutor::new();
|
||||||
|
let call = ToolCall {
|
||||||
|
id: "1".to_string(),
|
||||||
|
name: "nonexistent".to_string(),
|
||||||
|
arguments: serde_json::json!({}),
|
||||||
|
};
|
||||||
|
let result = executor.execute(&call).await.unwrap();
|
||||||
|
assert!(!result.success);
|
||||||
|
assert!(result.error.unwrap().contains("not available"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_builtin_tool_names() {
|
||||||
|
for t in BuiltinTool::all() {
|
||||||
|
assert!(!t.name().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_with_defaults_registers_seven_tools() {
|
||||||
|
let executor = ToolExecutor::with_defaults();
|
||||||
|
assert_eq!(executor.len(), 7);
|
||||||
|
assert!(executor.has_tool("read"));
|
||||||
|
assert!(executor.has_tool("write"));
|
||||||
|
assert!(executor.has_tool("edit"));
|
||||||
|
assert!(executor.has_tool("bash"));
|
||||||
|
assert!(executor.has_tool("grep"));
|
||||||
|
assert!(executor.has_tool("find"));
|
||||||
|
assert!(executor.has_tool("ls"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_tool_basic() {
|
||||||
|
// Create a temp file
|
||||||
|
let tmp = std::env::temp_dir().join("gyandoot_test_read.txt");
|
||||||
|
tokio::fs::write(&tmp, "line1\nline2\nline3\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let tool = ReadTool;
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({
|
||||||
|
"file_path": tmp.to_str().unwrap(),
|
||||||
|
"line_numbers": false
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.contains("line1"));
|
||||||
|
assert!(result.contains("line2"));
|
||||||
|
|
||||||
|
tokio::fs::remove_file(&tmp).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_write_tool_creates_file() {
|
||||||
|
let tmp = std::env::temp_dir().join("gyandoot_test_write.txt");
|
||||||
|
let tool = WriteTool;
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({
|
||||||
|
"file_path": tmp.to_str().unwrap(),
|
||||||
|
"content": "hello world"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.contains("11 bytes"));
|
||||||
|
assert!(tokio::fs::read_to_string(&tmp).await.unwrap() == "hello world");
|
||||||
|
tokio::fs::remove_file(&tmp).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bash_tool_basic() {
|
||||||
|
let tool = BashTool::default();
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"command": "echo hello"}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.contains("hello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bash_tool_security_block() {
|
||||||
|
let tool = BashTool::default();
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"command": "rm -rf /"}))
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("blocked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_edit_tool() {
|
||||||
|
let tmp = std::env::temp_dir().join("gyandoot_test_edit.txt");
|
||||||
|
tokio::fs::write(&tmp, "old content").await.unwrap();
|
||||||
|
let tool = EditTool;
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({
|
||||||
|
"file_path": tmp.to_str().unwrap(),
|
||||||
|
"old_string": "old",
|
||||||
|
"new_string": "new"
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.contains("1 replacement(s)"));
|
||||||
|
assert_eq!(
|
||||||
|
tokio::fs::read_to_string(&tmp).await.unwrap(),
|
||||||
|
"new content"
|
||||||
|
);
|
||||||
|
tokio::fs::remove_file(&tmp).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_ls_tool() {
|
||||||
|
let tool = LsTool;
|
||||||
|
let result = tool
|
||||||
|
.execute(serde_json::json!({"path": "."}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!result.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,3 +17,6 @@ chrono.workspace = true
|
|||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
reqwest = { workspace = true, features = ["json"] }
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
@@ -1,10 +1,248 @@
|
|||||||
//! Configuration types for GyanDoot agents and platform.
|
//! Configuration types for GyanDoot agents and platform.
|
||||||
|
//!
|
||||||
|
//! Provides [`GyandootConfig`] as the canonical TOML-deserializable
|
||||||
|
//! configuration, [`ConfigLoader`] for layered resolution
|
||||||
|
//! (env vars > profile > default file > hardcoded defaults), and
|
||||||
|
//! backward-compatible aliases used by the rest of the workspace.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::ConfigError;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Top-level agent configuration.
|
// ─── GyandootConfig (canonical TOML schema) ───────────────────────────────
|
||||||
|
|
||||||
|
/// Top-level TOML-deserializable configuration for GyanDoot.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GyandootConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub agent: AgentSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub providers: ProvidersSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub platform: PlatformSection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub memory: MemorySection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway: GatewaySection,
|
||||||
|
#[serde(default)]
|
||||||
|
pub browser: BrowserSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GyandootConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
agent: AgentSection::default(),
|
||||||
|
providers: ProvidersSection::default(),
|
||||||
|
platform: PlatformSection::default(),
|
||||||
|
memory: MemorySection::default(),
|
||||||
|
gateway: GatewaySection::default(),
|
||||||
|
browser: BrowserSection::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[agent]` section.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AgentSection {
|
||||||
|
/// Agent display name.
|
||||||
|
#[serde(default = "default_agent_name")]
|
||||||
|
pub name: String,
|
||||||
|
/// Default model identifier.
|
||||||
|
#[serde(default = "default_agent_model")]
|
||||||
|
pub model: String,
|
||||||
|
/// Maximum tokens per response.
|
||||||
|
#[serde(default = "default_max_tokens")]
|
||||||
|
pub max_tokens: usize,
|
||||||
|
/// Sampling temperature (0.0 – 2.0).
|
||||||
|
#[serde(default = "default_temperature")]
|
||||||
|
pub temperature: f64,
|
||||||
|
/// System prompt prepended to every conversation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub system_prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_agent_name() -> String {
|
||||||
|
"gyandoot".to_string()
|
||||||
|
}
|
||||||
|
fn default_agent_model() -> String {
|
||||||
|
"gpt-4o".to_string()
|
||||||
|
}
|
||||||
|
fn default_max_tokens() -> usize {
|
||||||
|
4096
|
||||||
|
}
|
||||||
|
fn default_temperature() -> f64 {
|
||||||
|
0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AgentSection {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
name: default_agent_name(),
|
||||||
|
model: default_agent_model(),
|
||||||
|
max_tokens: default_max_tokens(),
|
||||||
|
temperature: default_temperature(),
|
||||||
|
system_prompt: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[providers]` section — maps provider name → per-provider config.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProvidersSection {
|
||||||
|
/// Default provider key (e.g. "openai").
|
||||||
|
#[serde(default = "default_provider_default")]
|
||||||
|
pub default: String,
|
||||||
|
/// Provider-specific settings keyed by name.
|
||||||
|
#[serde(default, flatten)]
|
||||||
|
pub providers: HashMap<String, toml::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_provider_default() -> String {
|
||||||
|
"openai".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProvidersSection {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut providers = HashMap::new();
|
||||||
|
providers.insert(
|
||||||
|
"openai".to_string(),
|
||||||
|
toml::Value::Table({
|
||||||
|
let mut m = toml::map::Map::new();
|
||||||
|
m.insert("model".to_string(), toml::Value::String("gpt-4o".to_string()));
|
||||||
|
m
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
default: default_provider_default(),
|
||||||
|
providers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[platform]` section.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PlatformSection {
|
||||||
|
/// Default platform (e.g. "cli", "telegram", "discord").
|
||||||
|
#[serde(default = "default_platform_default")]
|
||||||
|
pub default: String,
|
||||||
|
/// Platform-specific config (flattened for extensibility).
|
||||||
|
#[serde(default, flatten)]
|
||||||
|
pub platforms: HashMap<String, toml::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_platform_default() -> String {
|
||||||
|
"cli".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlatformSection {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
default: default_platform_default(),
|
||||||
|
platforms: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[memory]` section.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemorySection {
|
||||||
|
/// Memory backend (e.g. "local", "redis").
|
||||||
|
#[serde(default = "default_memory_backend")]
|
||||||
|
pub backend: String,
|
||||||
|
/// Storage path for local backend.
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
/// Maximum entries before compression / eviction.
|
||||||
|
#[serde(default = "default_memory_max_entries")]
|
||||||
|
pub max_entries: usize,
|
||||||
|
/// Whether auto-compression is enabled.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub auto_compress: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_memory_backend() -> String {
|
||||||
|
"local".to_string()
|
||||||
|
}
|
||||||
|
fn default_memory_max_entries() -> usize {
|
||||||
|
10000
|
||||||
|
}
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MemorySection {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
backend: default_memory_backend(),
|
||||||
|
path: None,
|
||||||
|
max_entries: default_memory_max_entries(),
|
||||||
|
auto_compress: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[gateway]` section.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct GatewaySection {
|
||||||
|
/// Whether the gateway server is enabled.
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// TCP port for the gateway.
|
||||||
|
#[serde(default = "default_gateway_port")]
|
||||||
|
pub port: u16,
|
||||||
|
/// Bind address / host.
|
||||||
|
#[serde(default = "default_gateway_host")]
|
||||||
|
pub host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_false() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn default_gateway_port() -> u16 {
|
||||||
|
8765
|
||||||
|
}
|
||||||
|
fn default_gateway_host() -> String {
|
||||||
|
"127.0.0.1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GatewaySection {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
port: default_gateway_port(),
|
||||||
|
host: default_gateway_host(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[browser]` section.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BrowserSection {
|
||||||
|
/// Whether the browser automation layer is enabled.
|
||||||
|
#[serde(default = "default_false")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Camofox URL for browser automation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub camofox_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BrowserSection {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: false,
|
||||||
|
camofox_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legacy / backward-compatible config types ──────────────────────────
|
||||||
|
// These are re-exported under their original names so downstream crates
|
||||||
|
// (gyandoot-agent, gyandoot-providers, etc.) continue to compile unchanged.
|
||||||
|
|
||||||
|
/// Top-level agent configuration (backward-compatible).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AgentConfig {
|
pub struct AgentConfig {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -12,42 +250,80 @@ pub struct AgentConfig {
|
|||||||
pub max_tokens: usize,
|
pub max_tokens: usize,
|
||||||
pub temperature: f64,
|
pub temperature: f64,
|
||||||
pub system_prompt: String,
|
pub system_prompt: String,
|
||||||
pub provider: ProviderConfig,
|
pub provider: ProviderRootConfig,
|
||||||
pub platform: PlatformConfig,
|
pub platform: PlatformRootConfig,
|
||||||
pub memory: MemoryConfig,
|
pub memory: MemoryRootConfig,
|
||||||
pub security: SecurityConfig,
|
pub security: SecurityRootConfig,
|
||||||
pub kanban: KanbanConfig,
|
pub kanban: KanbanRootConfig,
|
||||||
pub skills: SkillsConfig,
|
pub skills: SkillsRootConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AgentConfig {
|
impl Default for AgentConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: "gyandoot".to_string(),
|
name: default_agent_name(),
|
||||||
model: "gpt-4o".to_string(),
|
model: default_agent_model(),
|
||||||
max_tokens: 4096,
|
max_tokens: default_max_tokens(),
|
||||||
temperature: 0.7,
|
temperature: default_temperature(),
|
||||||
system_prompt: String::new(),
|
system_prompt: String::new(),
|
||||||
provider: ProviderConfig::default(),
|
provider: ProviderRootConfig::default(),
|
||||||
platform: PlatformConfig::default(),
|
platform: PlatformRootConfig::default(),
|
||||||
memory: MemoryConfig::default(),
|
memory: MemoryRootConfig::default(),
|
||||||
security: SecurityConfig::default(),
|
security: SecurityRootConfig::default(),
|
||||||
kanban: KanbanConfig::default(),
|
kanban: KanbanRootConfig::default(),
|
||||||
skills: SkillsConfig::default(),
|
skills: SkillsRootConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LLM provider configuration.
|
impl From<GyandootConfig> for AgentConfig {
|
||||||
|
fn from(cfg: GyandootConfig) -> Self {
|
||||||
|
// Extract provider-specific configs from the providers map
|
||||||
|
let openai = cfg.providers.providers.get("openai").map(|v| OpenAIConfig::from_toml_value(v));
|
||||||
|
let anthropic = cfg.providers.providers.get("anthropic").map(|v| AnthropicConfig::from_toml_value(v));
|
||||||
|
let ollama = cfg.providers.providers.get("ollama").map(|v| OllamaConfig::from_toml_value(v));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
name: cfg.agent.name,
|
||||||
|
model: cfg.agent.model,
|
||||||
|
max_tokens: cfg.agent.max_tokens,
|
||||||
|
temperature: cfg.agent.temperature,
|
||||||
|
system_prompt: cfg.agent.system_prompt,
|
||||||
|
provider: ProviderRootConfig {
|
||||||
|
default: cfg.providers.default,
|
||||||
|
openai,
|
||||||
|
anthropic,
|
||||||
|
ollama,
|
||||||
|
},
|
||||||
|
platform: PlatformRootConfig {
|
||||||
|
default: cfg.platform.default,
|
||||||
|
telegram: None,
|
||||||
|
discord: None,
|
||||||
|
webhook: None,
|
||||||
|
},
|
||||||
|
memory: MemoryRootConfig {
|
||||||
|
backend: cfg.memory.backend,
|
||||||
|
storage_path: cfg.memory.path,
|
||||||
|
max_entries: cfg.memory.max_entries,
|
||||||
|
auto_compress: cfg.memory.auto_compress,
|
||||||
|
},
|
||||||
|
security: SecurityRootConfig::default(),
|
||||||
|
kanban: KanbanRootConfig::default(),
|
||||||
|
skills: SkillsRootConfig::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider configurations (backward-compatible).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProviderConfig {
|
pub struct ProviderRootConfig {
|
||||||
pub default: String,
|
pub default: String,
|
||||||
pub openai: Option<OpenAIConfig>,
|
pub openai: Option<OpenAIConfig>,
|
||||||
pub anthropic: Option<AnthropicConfig>,
|
pub anthropic: Option<AnthropicConfig>,
|
||||||
pub ollama: Option<OllamaConfig>,
|
pub ollama: Option<OllamaConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProviderConfig {
|
impl Default for ProviderRootConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
default: "openai".to_string(),
|
default: "openai".to_string(),
|
||||||
@@ -58,6 +334,7 @@ impl Default for ProviderConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-provider config struct used by gyandoot-agent's `create_provider`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OpenAIConfig {
|
pub struct OpenAIConfig {
|
||||||
pub api_key: String,
|
pub api_key: String,
|
||||||
@@ -66,6 +343,21 @@ pub struct OpenAIConfig {
|
|||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OpenAIConfig {
|
||||||
|
fn from_toml_value(v: &toml::Value) -> Self {
|
||||||
|
let table = match v.as_table() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Self::default(),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
api_key: table.get("api_key").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||||
|
model: table.get("model").and_then(|v| v.as_str()).unwrap_or_else(|| "gpt-4o").to_string(),
|
||||||
|
base_url: table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(3) as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for OpenAIConfig {
|
impl Default for OpenAIConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -84,6 +376,20 @@ pub struct AnthropicConfig {
|
|||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AnthropicConfig {
|
||||||
|
fn from_toml_value(v: &toml::Value) -> Self {
|
||||||
|
let table = match v.as_table() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Self::default(),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
api_key: table.get("api_key").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
||||||
|
model: table.get("model").and_then(|v| v.as_str()).unwrap_or_else(|| "claude-3-5-sonnet-20241022").to_string(),
|
||||||
|
max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(3) as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for AnthropicConfig {
|
impl Default for AnthropicConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -101,6 +407,20 @@ pub struct OllamaConfig {
|
|||||||
pub max_retries: u32,
|
pub max_retries: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OllamaConfig {
|
||||||
|
fn from_toml_value(v: &toml::Value) -> Self {
|
||||||
|
let table = match v.as_table() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Self::default(),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
base_url: table.get("base_url").and_then(|v| v.as_str()).unwrap_or("http://localhost:11434").to_string(),
|
||||||
|
model: table.get("model").and_then(|v| v.as_str()).unwrap_or("llama3").to_string(),
|
||||||
|
max_retries: table.get("max_retries").and_then(|v| v.as_integer()).unwrap_or(2) as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for OllamaConfig {
|
impl Default for OllamaConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -111,16 +431,18 @@ impl Default for OllamaConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Platform adapter configuration.
|
// Backward-compatible wrappers
|
||||||
|
pub type ProviderConfig = ProviderRootConfig;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PlatformConfig {
|
pub struct PlatformRootConfig {
|
||||||
pub default: String,
|
pub default: String,
|
||||||
pub telegram: Option<TelegramConfig>,
|
pub telegram: Option<TelegramConfig>,
|
||||||
pub discord: Option<DiscordConfig>,
|
pub discord: Option<DiscordConfig>,
|
||||||
pub webhook: Option<WebhookConfig>,
|
pub webhook: Option<WebhookConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PlatformConfig {
|
impl Default for PlatformRootConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
default: "cli".to_string(),
|
default: "cli".to_string(),
|
||||||
@@ -150,16 +472,15 @@ pub struct WebhookConfig {
|
|||||||
pub secret: Option<String>,
|
pub secret: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Memory system configuration.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MemoryConfig {
|
pub struct MemoryRootConfig {
|
||||||
pub backend: String,
|
pub backend: String,
|
||||||
pub storage_path: Option<PathBuf>,
|
pub storage_path: Option<PathBuf>,
|
||||||
pub max_entries: usize,
|
pub max_entries: usize,
|
||||||
pub auto_compress: bool,
|
pub auto_compress: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MemoryConfig {
|
impl Default for MemoryRootConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
backend: "local".to_string(),
|
backend: "local".to_string(),
|
||||||
@@ -170,15 +491,14 @@ impl Default for MemoryConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Security configuration.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SecurityConfig {
|
pub struct SecurityRootConfig {
|
||||||
pub vault_path: Option<PathBuf>,
|
pub vault_path: Option<PathBuf>,
|
||||||
pub encryption_enabled: bool,
|
pub encryption_enabled: bool,
|
||||||
pub threat_detection: bool,
|
pub threat_detection: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SecurityConfig {
|
impl Default for SecurityRootConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
vault_path: None,
|
vault_path: None,
|
||||||
@@ -188,15 +508,14 @@ impl Default for SecurityConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kanban task system configuration.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct KanbanConfig {
|
pub struct KanbanRootConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub max_concurrent_tasks: usize,
|
pub max_concurrent_tasks: usize,
|
||||||
pub auto_decompose: bool,
|
pub auto_decompose: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for KanbanConfig {
|
impl Default for KanbanRootConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -206,15 +525,14 @@ impl Default for KanbanConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Skills system configuration.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SkillsConfig {
|
pub struct SkillsRootConfig {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub skills_dir: Option<PathBuf>,
|
pub skills_dir: Option<PathBuf>,
|
||||||
pub auto_load: bool,
|
pub auto_load: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SkillsConfig {
|
impl Default for SkillsRootConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -224,18 +542,218 @@ impl Default for SkillsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load configuration from a TOML file.
|
// ─── ConfigLoader ─────────────────────────────────────────────────────────
|
||||||
pub fn load_config_from_file(path: &std::path::Path) -> anyhow::Result<AgentConfig> {
|
|
||||||
let content = std::fs::read_to_string(path)
|
/// Layered configuration loader.
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read config file {}: {}", path.display(), e))?;
|
///
|
||||||
let config: AgentConfig = toml::from_str(&content)
|
/// Resolution order (later overrides earlier):
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e))?;
|
/// 1. Hardcoded defaults
|
||||||
Ok(config)
|
/// 2. Default file (`~/.config/gyandoot/config.toml`)
|
||||||
|
/// 3. Profile file (`~/.gyandoot/config.yaml` — legacy)
|
||||||
|
/// 4. Environment variables (`GYANDOOT_*`)
|
||||||
|
///
|
||||||
|
/// The final result is a [`GyandootConfig`] which can also be converted
|
||||||
|
/// to the legacy [`AgentConfig`] via `.into()`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConfigLoader {
|
||||||
|
env_prefix: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for ConfigLoader {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigLoader {
|
||||||
|
/// Create a new loader with the default `GYANDOOT_` prefix.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
env_prefix: "GYANDOOT_".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a loader with a custom environment-variable prefix.
|
||||||
|
pub fn with_prefix(prefix: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
env_prefix: prefix.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and parse a single TOML config file.
|
||||||
|
pub fn from_file(path: &std::path::Path) -> Result<GyandootConfig, ConfigError> {
|
||||||
|
let content = std::fs::read_to_string(path)
|
||||||
|
.map_err(|e| ConfigError::FileNotFound {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source: e,
|
||||||
|
})?;
|
||||||
|
toml::from_str(&content).map_err(|e| ConfigError::ParseError {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
detail: e.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Override a config with environment variables.
|
||||||
|
///
|
||||||
|
/// Supported variables:
|
||||||
|
/// - `GYANDOOT_AGENT_NAME`, `GYANDOOT_AGENT_MODEL`,
|
||||||
|
/// `GYANDOOT_AGENT_MAX_TOKENS`, `GYANDOOT_AGENT_TEMPERATURE`,
|
||||||
|
/// `GYANDOOT_AGENT_SYSTEM_PROMPT`
|
||||||
|
/// - `GYANDOOT_PROVIDERS_DEFAULT`
|
||||||
|
/// - `GYANDOOT_PLATFORM_DEFAULT`
|
||||||
|
/// - `GYANDOOT_MEMORY_BACKEND`, `GYANDOOT_MEMORY_PATH`
|
||||||
|
/// - `GYANDOOT_GATEWAY_ENABLED`, `GYANDOOT_GATEWAY_PORT`, `GYANDOOT_GATEWAY_HOST`
|
||||||
|
/// - `GYANDOOT_BROWSER_ENABLED`, `GYANDOOT_BROWSER_CAMOFOX_URL`
|
||||||
|
/// - Provider-specific: `GYANDOOT_OPENAI_API_KEY`, `GYANDOOT_OPENAI_MODEL`,
|
||||||
|
/// `GYANDOOT_OPENAI_BASE_URL`, `GYANDOOT_ANTHROPIC_API_KEY`, etc.
|
||||||
|
pub fn from_env(&self, config: &mut GyandootConfig) {
|
||||||
|
macro_rules! env_opt {
|
||||||
|
($key:literal, $target:expr) => {
|
||||||
|
if let Ok(val) = std::env::var(concat!(env_prefix_str!(), $key)) {
|
||||||
|
$target = val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// We can't use concat! with a runtime string, so we build keys manually.
|
||||||
|
let prefix = &self.env_prefix;
|
||||||
|
let p = |k: &str| format!("{prefix}{k}");
|
||||||
|
|
||||||
|
// Agent
|
||||||
|
if let Ok(v) = std::env::var(p("AGENT_NAME")) {
|
||||||
|
config.agent.name = v;
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var(p("AGENT_MODEL")) {
|
||||||
|
config.agent.model = v;
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var(p("AGENT_MAX_TOKENS")) {
|
||||||
|
if let Ok(n) = v.parse::<usize>() {
|
||||||
|
config.agent.max_tokens = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var(p("AGENT_TEMPERATURE")) {
|
||||||
|
if let Ok(n) = v.parse::<f64>() {
|
||||||
|
config.agent.temperature = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var(p("AGENT_SYSTEM_PROMPT")) {
|
||||||
|
config.agent.system_prompt = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
if let Ok(v) = std::env::var(p("PROVIDERS_DEFAULT")) {
|
||||||
|
config.providers.default = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider-specific API keys / settings stored in the flatten map
|
||||||
|
for (provider_key, env_suffix, toml_field) in [
|
||||||
|
("openai", "OPENAI_API_KEY", "api_key"),
|
||||||
|
("openai", "OPENAI_MODEL", "model"),
|
||||||
|
("openai", "OPENAI_BASE_URL", "base_url"),
|
||||||
|
("anthropic", "ANTHROPIC_API_KEY", "api_key"),
|
||||||
|
("anthropic", "ANTHROPIC_MODEL", "model"),
|
||||||
|
("ollama", "OLLAMA_BASE_URL", "base_url"),
|
||||||
|
("ollama", "OLLAMA_MODEL", "model"),
|
||||||
|
] {
|
||||||
|
if let Ok(val) = std::env::var(p(env_suffix)) {
|
||||||
|
let entry = config
|
||||||
|
.providers
|
||||||
|
.providers
|
||||||
|
.entry(provider_key.to_string())
|
||||||
|
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
|
||||||
|
if let toml::Value::Table(ref mut table) = entry {
|
||||||
|
table.insert(toml_field.to_string(), toml::Value::String(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform
|
||||||
|
if let Ok(v) = std::env::var(p("PLATFORM_DEFAULT")) {
|
||||||
|
config.platform.default = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory
|
||||||
|
if let Ok(v) = std::env::var(p("MEMORY_BACKEND")) {
|
||||||
|
config.memory.backend = v;
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var(p("MEMORY_PATH")) {
|
||||||
|
config.memory.path = Some(PathBuf::from(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway
|
||||||
|
if let Ok(v) = std::env::var(p("GATEWAY_ENABLED")) {
|
||||||
|
config.gateway.enabled = v == "true" || v == "1";
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var(p("GATEWAY_PORT")) {
|
||||||
|
if let Ok(n) = v.parse::<u16>() {
|
||||||
|
config.gateway.port = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var(p("GATEWAY_HOST")) {
|
||||||
|
config.gateway.host = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser
|
||||||
|
if let Ok(v) = std::env::var(p("BROWSER_ENABLED")) {
|
||||||
|
config.browser.enabled = v == "true" || v == "1";
|
||||||
|
}
|
||||||
|
if let Ok(v) = std::env::var(p("BROWSER_CAMOFOX_URL")) {
|
||||||
|
config.browser.camofox_url = Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load configuration using the layered resolution strategy.
|
||||||
|
///
|
||||||
|
/// Order: hardcoded defaults → `~/.config/gyandoot/config.toml` →
|
||||||
|
/// `~/.gyandoot/config.toml` (legacy) → env vars.
|
||||||
|
pub fn load(&self) -> Result<GyandootConfig, ConfigError> {
|
||||||
|
let mut config = GyandootConfig::default();
|
||||||
|
|
||||||
|
// 1. Try XDG-style config file (primary)
|
||||||
|
if let Some(cfg_dir) = dirs::config_dir() {
|
||||||
|
let xdg_path = cfg_dir.join("gyandoot").join("config.toml");
|
||||||
|
if xdg_path.exists() {
|
||||||
|
if let Ok(loaded) = Self::from_file(&xdg_path) {
|
||||||
|
config = loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try legacy ~/.gyandoot/config.toml (profile directory)
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
let legacy_path = home.join(".gyandoot").join("config.toml");
|
||||||
|
if legacy_path.exists() {
|
||||||
|
if let Ok(loaded) = Self::from_file(&legacy_path) {
|
||||||
|
// Legacy overrides XDG for backward compatibility
|
||||||
|
config = loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Environment variable overrides
|
||||||
|
self.from_env(&mut config);
|
||||||
|
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load and convert to the legacy [`AgentConfig`].
|
||||||
|
pub fn load_agent_config(&self) -> Result<AgentConfig, ConfigError> {
|
||||||
|
self.load().map(AgentConfig::from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: load config from a single TOML file (legacy API).
|
||||||
|
pub fn load_config_from_file(path: &std::path::Path) -> anyhow::Result<AgentConfig> {
|
||||||
|
let loader = ConfigLoader::new();
|
||||||
|
let gyandoot = ConfigLoader::from_file(path)?;
|
||||||
|
Ok(AgentConfig::from(gyandoot))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_config() {
|
fn test_default_config() {
|
||||||
@@ -246,8 +764,97 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_provider_default() {
|
fn test_provider_default() {
|
||||||
let provider = ProviderConfig::default();
|
let provider = ProviderRootConfig::default();
|
||||||
assert_eq!(provider.default, "openai");
|
assert_eq!(provider.default, "openai");
|
||||||
assert!(provider.openai.is_some());
|
assert!(provider.openai.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gyandoot_config_defaults() {
|
||||||
|
let cfg = GyandootConfig::default();
|
||||||
|
assert_eq!(cfg.agent.name, "gyandoot");
|
||||||
|
assert_eq!(cfg.agent.max_tokens, 4096);
|
||||||
|
assert_eq!(cfg.gateway.port, 8765);
|
||||||
|
assert!(!cfg.browser.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_gyandoot_to_agent_config() {
|
||||||
|
let gyandoot = GyandootConfig::default();
|
||||||
|
let agent: AgentConfig = gyandoot.into();
|
||||||
|
assert_eq!(agent.name, "gyandoot");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_loader_from_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("test.toml");
|
||||||
|
let toml_content = r#"
|
||||||
|
[agent]
|
||||||
|
name = "test-agent"
|
||||||
|
model = "gpt-4o"
|
||||||
|
max_tokens = 2048
|
||||||
|
temperature = 0.5
|
||||||
|
|
||||||
|
[providers]
|
||||||
|
default = "ollama"
|
||||||
|
|
||||||
|
[memory]
|
||||||
|
backend = "redis"
|
||||||
|
path = "/tmp/gyandoot-memory"
|
||||||
|
|
||||||
|
[gateway]
|
||||||
|
enabled = true
|
||||||
|
port = 9999
|
||||||
|
host = "0.0.0.0"
|
||||||
|
|
||||||
|
[browser]
|
||||||
|
enabled = true
|
||||||
|
camofox_url = "http://camofox:9222"
|
||||||
|
"#;
|
||||||
|
std::fs::write(&path, toml_content).unwrap();
|
||||||
|
let config = ConfigLoader::from_file(&path).unwrap();
|
||||||
|
assert_eq!(config.agent.name, "test-agent");
|
||||||
|
assert_eq!(config.agent.max_tokens, 2048);
|
||||||
|
assert_eq!(config.agent.temperature, 0.5);
|
||||||
|
assert_eq!(config.providers.default, "ollama");
|
||||||
|
assert_eq!(config.memory.backend, "redis");
|
||||||
|
assert!(config.gateway.enabled);
|
||||||
|
assert_eq!(config.gateway.port, 9999);
|
||||||
|
assert!(config.browser.enabled);
|
||||||
|
assert_eq!(
|
||||||
|
config.browser.camofox_url.as_deref(),
|
||||||
|
Some("http://camofox:9222")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_loader_file_not_found() {
|
||||||
|
let err = ConfigLoader::from_file(std::path::Path::new("/nonexistent/gyandoot.toml"));
|
||||||
|
assert!(err.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_loader_invalid_toml() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("bad.toml");
|
||||||
|
std::fs::write(&path, "[[[invalid]]]\n").unwrap();
|
||||||
|
let err = ConfigLoader::from_file(&path);
|
||||||
|
assert!(err.is_err());
|
||||||
|
match err {
|
||||||
|
Err(ConfigError::ParseError { .. }) => {} // expected
|
||||||
|
_ => panic!("Expected ParseError"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agent_config_from_gyandoot_roundtrip() {
|
||||||
|
let gyandoot = GyandootConfig::default();
|
||||||
|
let agent: AgentConfig = gyandoot.clone().into();
|
||||||
|
// Verify the conversion preserves defaults
|
||||||
|
assert_eq!(agent.name, gyandoot.agent.name);
|
||||||
|
assert_eq!(agent.model, gyandoot.agent.model);
|
||||||
|
assert_eq!(agent.max_tokens, gyandoot.agent.max_tokens);
|
||||||
|
assert_eq!(agent.temperature, gyandoot.agent.temperature);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,43 @@
|
|||||||
//! Unified error types for GyanDoot.
|
//! Unified error types for GyanDoot.
|
||||||
//!
|
//!
|
||||||
//! Each subsystem defines its own error enum using `thiserror`,
|
//! Each subsystem defines its own error enum using `thiserror` derive,
|
||||||
//! while application-level errors use `anyhow`.
|
//! providing rich context with `From` conversions between related types.
|
||||||
|
//! Application-level errors use `anyhow`.
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
// ─── Config errors ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Configuration loading and parsing errors.
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("Config file not found: {path} ({source})")]
|
||||||
|
FileNotFound {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("Config parse error in {path}: {detail}")]
|
||||||
|
ParseError {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("Invalid config value for key '{key}': {detail}")]
|
||||||
|
InvalidValue {
|
||||||
|
key: String,
|
||||||
|
detail: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ConfigError> for CoreError {
|
||||||
|
fn from(e: ConfigError) -> Self {
|
||||||
|
CoreError::Config(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Core errors ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Core platform errors.
|
/// Core platform errors.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum CoreError {
|
pub enum CoreError {
|
||||||
@@ -30,15 +63,80 @@ pub enum CoreError {
|
|||||||
Timeout,
|
Timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Agent loop errors.
|
// ─── Provider errors ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// LLM provider errors with rich HTTP status and auth context.
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum ProviderError {
|
||||||
|
#[error("API error (HTTP {status}): {message}")]
|
||||||
|
Api { status: u16, message: String },
|
||||||
|
|
||||||
|
#[error("Authentication failed: {0}")]
|
||||||
|
Auth(String),
|
||||||
|
|
||||||
|
#[error("Rate limited (retry after {retry_after}s): {message}")]
|
||||||
|
RateLimited {
|
||||||
|
retry_after: u64,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("Request timed out after {duration}s")]
|
||||||
|
Timeout { duration: u64 },
|
||||||
|
|
||||||
|
#[error("Stream error: {0}")]
|
||||||
|
Stream(String),
|
||||||
|
|
||||||
|
#[error("Server error (HTTP {status}): {message}")]
|
||||||
|
ServerError { status: u16, message: String },
|
||||||
|
|
||||||
|
#[error("HTTP request failed: {0}")]
|
||||||
|
Request(#[from] reqwest::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderError {
|
||||||
|
/// Attempt to extract `retry_after` header value from a reqwest response.
|
||||||
|
pub fn from_response(status: u16, body: &str) -> Self {
|
||||||
|
if status == 401 || status == 403 {
|
||||||
|
Self::Auth(format!("HTTP {status}: {body}"))
|
||||||
|
} else if status == 429 {
|
||||||
|
Self::RateLimited {
|
||||||
|
retry_after: 30,
|
||||||
|
message: body.to_string(),
|
||||||
|
}
|
||||||
|
} else if status >= 500 {
|
||||||
|
Self::ServerError {
|
||||||
|
status,
|
||||||
|
message: body.to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self::Api {
|
||||||
|
status,
|
||||||
|
message: body.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Agent errors ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Agent loop execution errors.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum AgentError {
|
pub enum AgentError {
|
||||||
#[error("LLM provider error: {0}")]
|
#[error("LLM provider error: {0}")]
|
||||||
Provider(String),
|
Provider(String),
|
||||||
|
|
||||||
|
#[error("Tool not found: {0}")]
|
||||||
|
ToolNotFound(String),
|
||||||
|
|
||||||
#[error("Tool execution error: {0}")]
|
#[error("Tool execution error: {0}")]
|
||||||
ToolExecution(String),
|
ToolExecution(String),
|
||||||
|
|
||||||
|
#[error("Max iterations reached ({0})")]
|
||||||
|
MaxIterations(usize),
|
||||||
|
|
||||||
|
#[error("Context overflow: {0}")]
|
||||||
|
ContextOverflow(String),
|
||||||
|
|
||||||
#[error("Context compression error: {0}")]
|
#[error("Context compression error: {0}")]
|
||||||
ContextCompression(String),
|
ContextCompression(String),
|
||||||
|
|
||||||
@@ -49,66 +147,83 @@ pub enum AgentError {
|
|||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LLM provider errors.
|
impl From<ProviderError> for AgentError {
|
||||||
#[derive(Error, Debug)]
|
fn from(e: ProviderError) -> Self {
|
||||||
pub enum ProviderError {
|
AgentError::Provider(e.to_string())
|
||||||
#[error("API error: {0}")]
|
}
|
||||||
Api(String),
|
|
||||||
|
|
||||||
#[error("Authentication failed: {0}")]
|
|
||||||
Auth(String),
|
|
||||||
|
|
||||||
#[error("Rate limited")]
|
|
||||||
RateLimited,
|
|
||||||
|
|
||||||
#[error("Model unavailable: {0}")]
|
|
||||||
ModelUnavailable(String),
|
|
||||||
|
|
||||||
#[error("Stream error: {0}")]
|
|
||||||
Stream(String),
|
|
||||||
|
|
||||||
#[error("Request failed: {0}")]
|
|
||||||
Request(#[from] reqwest::Error),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Memory errors ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Memory system errors.
|
/// Memory system errors.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum MemoryError {
|
pub enum MemoryError {
|
||||||
#[error("Storage error: {0}")]
|
#[error("Failed to store entry: {0}")]
|
||||||
Storage(String),
|
Store(String),
|
||||||
|
|
||||||
#[error("Query error: {0}")]
|
#[error("Failed to retrieve entry: {0}")]
|
||||||
Query(String),
|
Retrieve(String),
|
||||||
|
|
||||||
#[error("Index corruption: {0}")]
|
#[error("Corrupt storage index: {0}")]
|
||||||
IndexCorruption(String),
|
Corrupt(String),
|
||||||
|
|
||||||
#[error("Entry not found: {0}")]
|
#[error("Entry not found: {0}")]
|
||||||
EntryNotFound(String),
|
EntryNotFound(String),
|
||||||
|
|
||||||
#[error("Compression failed: {0}")]
|
#[error("Compression failed: {0}")]
|
||||||
Compression(String),
|
Compression(String),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Platform errors ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Platform adapter errors.
|
/// Platform adapter errors.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum PlatformError {
|
pub enum PlatformError {
|
||||||
#[error("Connection failed: {0}")]
|
#[error("Failed to send message: {0}")]
|
||||||
Connection(String),
|
|
||||||
|
|
||||||
#[error("Message send failed: {0}")]
|
|
||||||
Send(String),
|
Send(String),
|
||||||
|
|
||||||
|
#[error("Failed to receive message: {0}")]
|
||||||
|
Receive(String),
|
||||||
|
|
||||||
#[error("Authentication failed: {0}")]
|
#[error("Authentication failed: {0}")]
|
||||||
Auth(String),
|
Auth(String),
|
||||||
|
|
||||||
#[error("Platform unavailable: {0}")]
|
|
||||||
Unavailable(String),
|
|
||||||
|
|
||||||
#[error("Network error: {0}")]
|
#[error("Network error: {0}")]
|
||||||
Network(#[from] reqwest::Error),
|
Network(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("Connection failed: {0}")]
|
||||||
|
Connection(String),
|
||||||
|
|
||||||
|
#[error("Platform unavailable: {0}")]
|
||||||
|
Unavailable(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Browser errors ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Browser automation errors.
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum BrowserError {
|
||||||
|
#[error("Browser connection failed: {0}")]
|
||||||
|
Connection(String),
|
||||||
|
|
||||||
|
#[error("Navigation failed: {0}")]
|
||||||
|
Navigation(String),
|
||||||
|
|
||||||
|
#[error("Element not found: {0}")]
|
||||||
|
ElementNotFound(String),
|
||||||
|
|
||||||
|
#[error("Script execution failed: {0}")]
|
||||||
|
ScriptExecution(String),
|
||||||
|
|
||||||
|
#[error("Snapshot capture failed: {0}")]
|
||||||
|
SnapshotFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Other subsystem errors (backward-compatible) ─────────────────────────
|
||||||
|
|
||||||
/// Security subsystem errors.
|
/// Security subsystem errors.
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum SecurityError {
|
pub enum SecurityError {
|
||||||
@@ -217,15 +332,120 @@ pub enum GatewayError {
|
|||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience alias for common result types.
|
// ─── Conversion impls ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Note: anyhow::Error provides blanket From<E> for all E: StdError,
|
||||||
|
// so explicit From impls would conflict. Use .map_err(Into::into) or
|
||||||
|
// anyhow!(e) at call sites instead.
|
||||||
|
|
||||||
|
// ─── Result type aliases ──────────────────────────────────────────────────
|
||||||
|
|
||||||
pub type CoreResult<T> = Result<T, CoreError>;
|
pub type CoreResult<T> = Result<T, CoreError>;
|
||||||
|
pub type ConfigResult<T> = Result<T, ConfigError>;
|
||||||
pub type AgentResult<T> = Result<T, AgentError>;
|
pub type AgentResult<T> = Result<T, AgentError>;
|
||||||
pub type ProviderResult<T> = Result<T, ProviderError>;
|
pub type ProviderResult<T> = Result<T, ProviderError>;
|
||||||
pub type MemoryResult<T> = Result<T, MemoryError>;
|
pub type MemoryResult<T> = Result<T, MemoryError>;
|
||||||
pub type PlatformResult<T> = Result<T, PlatformError>;
|
pub type PlatformResult<T> = Result<T, PlatformError>;
|
||||||
|
pub type BrowserResult<T> = Result<T, BrowserError>;
|
||||||
pub type SecurityResult<T> = Result<T, SecurityError>;
|
pub type SecurityResult<T> = Result<T, SecurityError>;
|
||||||
pub type KanbanResult<T> = Result<T, KanbanError>;
|
pub type KanbanResult<T> = Result<T, KanbanError>;
|
||||||
pub type SubAgentResult<T> = Result<T, SubAgentError>;
|
pub type SubAgentResult<T> = Result<T, SubAgentError>;
|
||||||
pub type SkillsResult<T> = Result<T, SkillsError>;
|
pub type SkillsResult<T> = Result<T, SkillsError>;
|
||||||
pub type ProfileResult<T> = Result<T, ProfileError>;
|
pub type ProfileResult<T> = Result<T, ProfileError>;
|
||||||
pub type GatewayResult<T> = Result<T, GatewayError>;
|
pub type GatewayResult<T> = Result<T, GatewayError>;
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_error_file_not_found() {
|
||||||
|
let err = ConfigError::FileNotFound {
|
||||||
|
path: "/tmp/doesntexist.toml".into(),
|
||||||
|
source: std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
|
||||||
|
};
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains("doesntexist"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_error_parse() {
|
||||||
|
let err = ConfigError::ParseError {
|
||||||
|
path: "/tmp/bad.toml".into(),
|
||||||
|
detail: "invalid syntax".to_string(),
|
||||||
|
};
|
||||||
|
assert!(err.to_string().contains("bad.toml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_error_from_response_auth() {
|
||||||
|
let err = ProviderError::from_response(401, "Invalid API key");
|
||||||
|
match err {
|
||||||
|
ProviderError::Auth(_) => {}
|
||||||
|
_ => panic!("Expected Auth error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_error_from_response_rate_limit() {
|
||||||
|
let err = ProviderError::from_response(429, "Too Many Requests");
|
||||||
|
match err {
|
||||||
|
ProviderError::RateLimited { retry_after, .. } => {
|
||||||
|
assert_eq!(retry_after, 30);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected RateLimited error"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_error_from_response_server() {
|
||||||
|
let err = ProviderError::from_response(503, "Service Unavailable");
|
||||||
|
match err {
|
||||||
|
ProviderError::ServerError { status, .. } => {
|
||||||
|
assert_eq!(status, 503);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected ServerError"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agent_error_from_provider() {
|
||||||
|
let provider_err = ProviderError::Auth("bad key".to_string());
|
||||||
|
let agent_err: AgentError = provider_err.into();
|
||||||
|
match agent_err {
|
||||||
|
AgentError::Provider(msg) => assert!(msg.contains("bad key")),
|
||||||
|
_ => panic!("Expected Provider variant"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_error_into_core() {
|
||||||
|
let cfg_err = ConfigError::FileNotFound {
|
||||||
|
path: "/test.toml".into(),
|
||||||
|
source: std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"),
|
||||||
|
};
|
||||||
|
let core_err: CoreError = cfg_err.into();
|
||||||
|
assert!(matches!(core_err, CoreError::Config(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_browser_error() {
|
||||||
|
let err = BrowserError::Connection("refused".to_string());
|
||||||
|
assert_eq!(err.to_string(), "Browser connection failed: refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agent_error_max_iterations() {
|
||||||
|
let err = AgentError::MaxIterations(10);
|
||||||
|
assert_eq!(err.to_string(), "Max iterations reached (10)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_memory_error_from_io() {
|
||||||
|
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
|
||||||
|
let mem_err: MemoryError = io_err.into();
|
||||||
|
assert!(matches!(mem_err, MemoryError::Io(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
//! Core type definitions for GyanDoot agents, messages, and sessions.
|
//! Core type definitions for GyanDoot agents, messages, and sessions.
|
||||||
|
//!
|
||||||
|
//! This module provides the fundamental data structures used across all
|
||||||
|
//! GyanDoot crates: message types, tool definitions, session identifiers,
|
||||||
|
//! provider kinds, and conversation context.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -8,7 +12,9 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Unique identifier for an agent.
|
// ─── Identifiers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Unique identifier for an agent instance.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub struct AgentId(pub Uuid);
|
pub struct AgentId(pub Uuid);
|
||||||
|
|
||||||
@@ -30,7 +36,7 @@ impl std::fmt::Display for AgentId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unique identifier for a session.
|
/// Unique identifier for a conversation session.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
pub struct SessionId(pub Uuid);
|
pub struct SessionId(pub Uuid);
|
||||||
|
|
||||||
@@ -52,44 +58,53 @@ impl std::fmt::Display for SessionId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Roles ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// The role of a message sender in a conversation.
|
/// The role of a message sender in a conversation.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum MessageRole {
|
pub enum Role {
|
||||||
System,
|
System,
|
||||||
User,
|
User,
|
||||||
Assistant,
|
Assistant,
|
||||||
Tool,
|
Tool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for MessageRole {
|
impl std::fmt::Display for Role {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
MessageRole::System => write!(f, "system"),
|
Role::System => write!(f, "system"),
|
||||||
MessageRole::User => write!(f, "user"),
|
Role::User => write!(f, "user"),
|
||||||
MessageRole::Assistant => write!(f, "assistant"),
|
Role::Assistant => write!(f, "assistant"),
|
||||||
MessageRole::Tool => write!(f, "tool"),
|
Role::Tool => write!(f, "tool"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for MessageRole {
|
impl FromStr for Role {
|
||||||
type Err = String;
|
type Err = String;
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"system" => Ok(MessageRole::System),
|
"system" => Ok(Role::System),
|
||||||
"user" => Ok(MessageRole::User),
|
"user" => Ok(Role::User),
|
||||||
"assistant" => Ok(MessageRole::Assistant),
|
"assistant" => Ok(Role::Assistant),
|
||||||
"tool" => Ok(MessageRole::Tool),
|
"tool" => Ok(Role::Tool),
|
||||||
_ => Err(format!("Invalid message role: {}", s)),
|
_ => Err(format!("Invalid message role: {s}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Legacy MessageRole alias ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Backward-compatible alias for [`Role`].
|
||||||
|
pub type MessageRole = Role;
|
||||||
|
|
||||||
|
// ─── Messages ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A single message in an agent conversation.
|
/// A single message in an agent conversation.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub role: MessageRole,
|
pub role: Role,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub tool_calls: Option<Vec<ToolCall>>,
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
pub tool_result: Option<String>,
|
pub tool_result: Option<String>,
|
||||||
@@ -97,7 +112,8 @@ pub struct Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Message {
|
impl Message {
|
||||||
pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
|
/// Create a new message with the given role and content.
|
||||||
|
pub fn new(role: Role, content: impl Into<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
role,
|
role,
|
||||||
content: content.into(),
|
content: content.into(),
|
||||||
@@ -107,18 +123,47 @@ impl Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach tool calls to this message (typically for assistant messages).
|
||||||
pub fn with_tool_calls(mut self, calls: Vec<ToolCall>) -> Self {
|
pub fn with_tool_calls(mut self, calls: Vec<ToolCall>) -> Self {
|
||||||
self.tool_calls = Some(calls);
|
self.tool_calls = Some(calls);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach a tool result to this message (typically for tool messages).
|
||||||
pub fn with_tool_result(mut self, result: String) -> Self {
|
pub fn with_tool_result(mut self, result: String) -> Self {
|
||||||
self.tool_result = Some(result);
|
self.tool_result = Some(result);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convenience: return true if this is a user message.
|
||||||
|
pub fn is_user(&self) -> bool {
|
||||||
|
self.role == Role::User
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: return true if this is a system message.
|
||||||
|
pub fn is_system(&self) -> bool {
|
||||||
|
self.role == Role::System
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: return true if this is an assistant message.
|
||||||
|
pub fn is_assistant(&self) -> bool {
|
||||||
|
self.role == Role::Assistant
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: return true if this is a tool response message.
|
||||||
|
pub fn is_tool(&self) -> bool {
|
||||||
|
self.role == Role::Tool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return true if this message has at least one tool call attached.
|
||||||
|
pub fn has_tool_calls(&self) -> bool {
|
||||||
|
self.tool_calls.as_ref().map_or(false, |c| !c.is_empty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tool call invoked by the agent.
|
// ─── Tool types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A tool call invoked by the LLM.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ToolCall {
|
pub struct ToolCall {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -126,7 +171,18 @@ pub struct ToolCall {
|
|||||||
pub arguments: serde_json::Value,
|
pub arguments: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A tool definition that the agent can invoke.
|
impl ToolCall {
|
||||||
|
/// Create a new tool call.
|
||||||
|
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: serde_json::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
name: name.into(),
|
||||||
|
arguments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tool definition that the agent can expose to the LLM.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ToolDefinition {
|
pub struct ToolDefinition {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -134,6 +190,17 @@ pub struct ToolDefinition {
|
|||||||
pub parameters: serde_json::Value,
|
pub parameters: serde_json::Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ToolDefinition {
|
||||||
|
/// Create a new tool definition.
|
||||||
|
pub fn new(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
description: description.into(),
|
||||||
|
parameters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The execution result of a tool.
|
/// The execution result of a tool.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
@@ -143,6 +210,101 @@ pub struct ToolResult {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ToolResult {
|
||||||
|
/// Create a successful tool result.
|
||||||
|
pub fn success(tool_call_id: impl Into<String>, output: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
tool_call_id: tool_call_id.into(),
|
||||||
|
success: true,
|
||||||
|
output: output.into(),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a failed tool result.
|
||||||
|
pub fn failure(tool_call_id: impl Into<String>, error: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
tool_call_id: tool_call_id.into(),
|
||||||
|
success: false,
|
||||||
|
output: String::new(),
|
||||||
|
error: Some(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider types ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Per-provider runtime configuration.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub model: String,
|
||||||
|
pub temperature: f64,
|
||||||
|
pub max_tokens: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderConfig {
|
||||||
|
pub fn new(
|
||||||
|
name: impl Into<String>,
|
||||||
|
api_key: impl Into<String>,
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
model: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
api_key: api_key.into(),
|
||||||
|
base_url: base_url.into(),
|
||||||
|
model: model.into(),
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 4096,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LLM provider identifiers.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ProviderKind {
|
||||||
|
OpenAI,
|
||||||
|
Anthropic,
|
||||||
|
Ollama,
|
||||||
|
Gemini,
|
||||||
|
DeepSeek,
|
||||||
|
OpenRouter,
|
||||||
|
Custom(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderKind {
|
||||||
|
/// Return a human-readable label.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
ProviderKind::OpenAI => "openai",
|
||||||
|
ProviderKind::Anthropic => "anthropic",
|
||||||
|
ProviderKind::Ollama => "ollama",
|
||||||
|
ProviderKind::Gemini => "gemini",
|
||||||
|
ProviderKind::DeepSeek => "deepseek",
|
||||||
|
ProviderKind::OpenRouter => "openrouter",
|
||||||
|
ProviderKind::Custom(s) => s.as_str(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse from a known string, returning `Custom` for unknown values.
|
||||||
|
pub fn from_str(s: &str) -> Self {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"openai" => ProviderKind::OpenAI,
|
||||||
|
"anthropic" => ProviderKind::Anthropic,
|
||||||
|
"ollama" => ProviderKind::Ollama,
|
||||||
|
"gemini" => ProviderKind::Gemini,
|
||||||
|
"deepseek" => ProviderKind::DeepSeek,
|
||||||
|
"openrouter" => ProviderKind::OpenRouter,
|
||||||
|
other => ProviderKind::Custom(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Platform types ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Represents a platform type the agent can connect to.
|
/// Represents a platform type the agent can connect to.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum PlatformKind {
|
pub enum PlatformKind {
|
||||||
@@ -161,11 +323,24 @@ impl FromStr for PlatformKind {
|
|||||||
"telegram" => Ok(PlatformKind::Telegram),
|
"telegram" => Ok(PlatformKind::Telegram),
|
||||||
"discord" => Ok(PlatformKind::Discord),
|
"discord" => Ok(PlatformKind::Discord),
|
||||||
"webhook" => Ok(PlatformKind::Webhook),
|
"webhook" => Ok(PlatformKind::Webhook),
|
||||||
_ => Err(format!("Unknown platform: {}", s)),
|
_ => Err(format!("Unknown platform: {s}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PlatformKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
PlatformKind::Cli => write!(f, "cli"),
|
||||||
|
PlatformKind::Telegram => write!(f, "telegram"),
|
||||||
|
PlatformKind::Discord => write!(f, "discord"),
|
||||||
|
PlatformKind::Webhook => write!(f, "webhook"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Agent status ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Status of an agent or task.
|
/// Status of an agent or task.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum AgentStatus {
|
pub enum AgentStatus {
|
||||||
@@ -176,6 +351,20 @@ pub enum AgentStatus {
|
|||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AgentStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
AgentStatus::Idle => write!(f, "idle"),
|
||||||
|
AgentStatus::Running => write!(f, "running"),
|
||||||
|
AgentStatus::Thinking => write!(f, "thinking"),
|
||||||
|
AgentStatus::WaitingForTool => write!(f, "waiting_for_tool"),
|
||||||
|
AgentStatus::Error(e) => write!(f, "error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Conversation context ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// The complete conversation context passed to the LLM.
|
/// The complete conversation context passed to the LLM.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ConversationContext {
|
pub struct ConversationContext {
|
||||||
@@ -185,6 +374,35 @@ pub struct ConversationContext {
|
|||||||
pub max_tokens: usize,
|
pub max_tokens: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ConversationContext {
|
||||||
|
/// Create a new conversation context.
|
||||||
|
pub fn new(
|
||||||
|
messages: Vec<Message>,
|
||||||
|
system_prompt: impl Into<String>,
|
||||||
|
tools: Vec<ToolDefinition>,
|
||||||
|
max_tokens: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
messages,
|
||||||
|
system_prompt: system_prompt.into(),
|
||||||
|
tools,
|
||||||
|
max_tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the number of messages in the context.
|
||||||
|
pub fn message_count(&self) -> usize {
|
||||||
|
self.messages.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return true if there are no messages.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.messages.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Kanban types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Task prioritization levels for the Kanban system.
|
/// Task prioritization levels for the Kanban system.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum Priority {
|
pub enum Priority {
|
||||||
@@ -194,6 +412,18 @@ pub enum Priority {
|
|||||||
Critical,
|
Critical,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Status of a task within the Kanban workflow.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum TaskStatus {
|
||||||
|
Backlog,
|
||||||
|
Ready,
|
||||||
|
InProgress,
|
||||||
|
Review,
|
||||||
|
Blocked,
|
||||||
|
Done,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
/// A task unit in the Kanban system.
|
/// A task unit in the Kanban system.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Task {
|
pub struct Task {
|
||||||
@@ -210,53 +440,30 @@ pub struct Task {
|
|||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
// ─── Config maps ──────────────────────────────────────────────────────────
|
||||||
pub enum TaskStatus {
|
|
||||||
Backlog,
|
|
||||||
Ready,
|
|
||||||
InProgress,
|
|
||||||
Review,
|
|
||||||
Blocked,
|
|
||||||
Done,
|
|
||||||
Cancelled,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// LLM provider identifiers.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub enum ProviderKind {
|
|
||||||
OpenAI,
|
|
||||||
Anthropic,
|
|
||||||
Ollama,
|
|
||||||
Custom(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for ProviderKind {
|
|
||||||
type Err = String;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"openai" => Ok(ProviderKind::OpenAI),
|
|
||||||
"anthropic" => Ok(ProviderKind::Anthropic),
|
|
||||||
"ollama" => Ok(ProviderKind::Ollama),
|
|
||||||
other => Ok(ProviderKind::Custom(other.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A profile configuration key-value map.
|
/// A profile configuration key-value map.
|
||||||
pub type ConfigMap = HashMap<String, serde_json::Value>;
|
pub type ConfigMap = HashMap<String, serde_json::Value>;
|
||||||
|
|
||||||
/// Data directory resolver using platform-agnostic paths.
|
// ─── Path helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Resolve the GyanDoot data directory using `dirs` (cross-platform).
|
||||||
pub fn data_dir() -> PathBuf {
|
pub fn data_dir() -> PathBuf {
|
||||||
let home = dirs::home_dir().expect("Cannot determine home directory");
|
let home = dirs::home_dir().expect("Cannot determine home directory");
|
||||||
home.join(".local").join("share").join("gyandoot")
|
home.join(".local").join("share").join("gyandoot")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the GyanDoot config directory using `dirs` (cross-platform).
|
||||||
pub fn config_dir() -> PathBuf {
|
pub fn config_dir() -> PathBuf {
|
||||||
|
let base = dirs::config_dir().unwrap_or_else(|| {
|
||||||
let home = dirs::home_dir().expect("Cannot determine home directory");
|
let home = dirs::home_dir().expect("Cannot determine home directory");
|
||||||
home.join(".config").join("gyandoot")
|
home.join(".config")
|
||||||
|
});
|
||||||
|
base.join("gyandoot")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -275,9 +482,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_message_role_from_str() {
|
fn test_message_role_from_str() {
|
||||||
assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
|
assert_eq!("user".parse::<Role>().unwrap(), Role::User);
|
||||||
assert_eq!("assistant".parse::<MessageRole>().unwrap(), MessageRole::Assistant);
|
assert_eq!("assistant".parse::<Role>().unwrap(), Role::Assistant);
|
||||||
assert!("invalid".parse::<MessageRole>().is_err());
|
assert!("invalid".parse::<Role>().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_role_display() {
|
||||||
|
assert_eq!(Role::System.to_string(), "system");
|
||||||
|
assert_eq!(Role::User.to_string(), "user");
|
||||||
|
assert_eq!(Role::Assistant.to_string(), "assistant");
|
||||||
|
assert_eq!(Role::Tool.to_string(), "tool");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -285,4 +500,111 @@ mod tests {
|
|||||||
assert_eq!("cli".parse::<PlatformKind>().unwrap(), PlatformKind::Cli);
|
assert_eq!("cli".parse::<PlatformKind>().unwrap(), PlatformKind::Cli);
|
||||||
assert_eq!("discord".parse::<PlatformKind>().unwrap(), PlatformKind::Discord);
|
assert_eq!("discord".parse::<PlatformKind>().unwrap(), PlatformKind::Discord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_new() {
|
||||||
|
let msg = Message::new(Role::User, "hello");
|
||||||
|
assert_eq!(msg.role, Role::User);
|
||||||
|
assert_eq!(msg.content, "hello");
|
||||||
|
assert!(msg.tool_calls.is_none());
|
||||||
|
assert!(msg.tool_result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_with_tool_calls() {
|
||||||
|
let msg = Message::new(Role::Assistant, "thinking...")
|
||||||
|
.with_tool_calls(vec![ToolCall::new("call_1", "get_weather", serde_json::json!({"city": "NYC"}))]);
|
||||||
|
assert!(msg.has_tool_calls());
|
||||||
|
assert_eq!(msg.tool_calls.unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_result_success() {
|
||||||
|
let tr = ToolResult::success("call_1", "output data");
|
||||||
|
assert!(tr.success);
|
||||||
|
assert!(tr.error.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_result_failure() {
|
||||||
|
let tr = ToolResult::failure("call_1", "something went wrong");
|
||||||
|
assert!(!tr.success);
|
||||||
|
assert_eq!(tr.error.unwrap(), "something went wrong");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tool_definition() {
|
||||||
|
let td = ToolDefinition::new("get_weather", "Get current weather", serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"city": {"type": "string"}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
assert_eq!(td.name, "get_weather");
|
||||||
|
assert_eq!(td.description, "Get current weather");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_config() {
|
||||||
|
let pc = ProviderConfig::new("openai", "sk-test", "https://api.openai.com/v1", "gpt-4o");
|
||||||
|
assert_eq!(pc.name, "openai");
|
||||||
|
assert_eq!(pc.temperature, 0.7);
|
||||||
|
assert_eq!(pc.max_tokens, 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_kind_from_str() {
|
||||||
|
assert_eq!(ProviderKind::from_str("openai"), ProviderKind::OpenAI);
|
||||||
|
assert_eq!(ProviderKind::from_str("ANTHROPIC"), ProviderKind::Anthropic);
|
||||||
|
assert_eq!(ProviderKind::from_str("unknown"), ProviderKind::Custom("unknown".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agent_status_display() {
|
||||||
|
assert_eq!(AgentStatus::Idle.to_string(), "idle");
|
||||||
|
assert_eq!(AgentStatus::Thinking.to_string(), "thinking");
|
||||||
|
assert_eq!(
|
||||||
|
AgentStatus::Error("bad".to_string()).to_string(),
|
||||||
|
"error: bad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_conversation_context() {
|
||||||
|
let ctx = ConversationContext::new(
|
||||||
|
vec![Message::new(Role::User, "hi")],
|
||||||
|
"You are an AI.",
|
||||||
|
vec![],
|
||||||
|
4096,
|
||||||
|
);
|
||||||
|
assert_eq!(ctx.message_count(), 1);
|
||||||
|
assert!(!ctx.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_data_dir_resolution() {
|
||||||
|
let dir = data_dir();
|
||||||
|
assert!(dir.to_string_lossy().contains("gyandoot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_dir_resolution() {
|
||||||
|
let dir = config_dir();
|
||||||
|
assert!(dir.to_string_lossy().contains("gyandoot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_session_id_display() {
|
||||||
|
let id = SessionId::new();
|
||||||
|
let s = id.to_string();
|
||||||
|
assert_eq!(s.len(), 36); // uuid v4 format
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_is_helpers() {
|
||||||
|
assert!(Message::new(Role::System, "").is_system());
|
||||||
|
assert!(Message::new(Role::User, "").is_user());
|
||||||
|
assert!(Message::new(Role::Assistant, "").is_assistant());
|
||||||
|
assert!(Message::new(Role::Tool, "").is_tool());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,37 +1,180 @@
|
|||||||
//! Identity store — manages agent identity and preferences.
|
//! Identity store — manages agent identity and preferences.
|
||||||
|
//!
|
||||||
|
//! Provides a two-layer identity system:
|
||||||
|
//! - **L0**: Compact identity (name, role, user_name, user_role) that must
|
||||||
|
//! fit within ~157 tokens for injection into every system prompt.
|
||||||
|
//! - **L1**: Full narrative / story with traits, preferences, and learned
|
||||||
|
//! behaviours.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
/// Agent identity information stored in memory.
|
// ─── Data types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Two-layer agent identity.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct AgentIdentity {
|
pub struct AgentIdentity {
|
||||||
|
/// Unique agent identifier.
|
||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
|
/// Short display name (L0).
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub bio: String,
|
/// One-line role description (L0).
|
||||||
|
pub role: String,
|
||||||
|
/// Name of the human user this agent serves (L0).
|
||||||
|
pub user_name: String,
|
||||||
|
/// Role / title of the human user (L0).
|
||||||
|
pub user_role: String,
|
||||||
|
/// Full narrative / story (L1).
|
||||||
|
pub story: String,
|
||||||
|
/// Key-value preferences.
|
||||||
pub preferences: HashMap<String, String>,
|
pub preferences: HashMap<String, String>,
|
||||||
|
/// Traits learned or assigned over time.
|
||||||
pub learned_traits: Vec<String>,
|
pub learned_traits: Vec<String>,
|
||||||
|
/// When this identity was created.
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
/// When this identity was last updated.
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AgentIdentity {
|
||||||
|
/// Check whether the L0 identity fits within the token budget.
|
||||||
|
///
|
||||||
|
/// LLMs typically consume ~4 characters per token. The recommended
|
||||||
|
/// budget is 157 tokens (~628 characters) for fast system-prompt
|
||||||
|
/// injection.
|
||||||
|
pub fn l0_token_estimate(&self) -> usize {
|
||||||
|
let l0 = format!(
|
||||||
|
"name: {}\nrole: {}\nuser: {} ({})\n",
|
||||||
|
self.name, self.role, self.user_name, self.user_role
|
||||||
|
);
|
||||||
|
// Rough estimate: ~4 chars per token
|
||||||
|
(l0.len() / 4).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the L0 identity fits within the recommended budget.
|
||||||
|
pub fn l0_fits_budget(&self) -> bool {
|
||||||
|
self.l0_token_estimate() <= 157
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the L0 identity as a compact string.
|
||||||
|
pub fn l0_string(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"name: {}\nrole: {}\nuser: {} ({})",
|
||||||
|
self.name, self.role, self.user_name, self.user_role
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the L1 story.
|
||||||
|
pub fn l1_string(&self) -> String {
|
||||||
|
let mut out = format!(
|
||||||
|
"# {}\n\n**Role:** {}\n**User:** {} ({})\n\n{}",
|
||||||
|
self.name, self.role, self.user_name, self.user_role, self.story
|
||||||
|
);
|
||||||
|
|
||||||
|
if !self.preferences.is_empty() {
|
||||||
|
out.push_str("\n\n## Preferences\n");
|
||||||
|
for (k, v) in &self.preferences {
|
||||||
|
out.push_str(&format!("- {k}: {v}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.learned_traits.is_empty() {
|
||||||
|
out.push_str("\n## Learned Traits\n");
|
||||||
|
for t in &self.learned_traits {
|
||||||
|
out.push_str(&format!("- {t}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── IdentityStore ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Manages persistent agent identity data.
|
/// Manages persistent agent identity data.
|
||||||
pub struct IdentityStore {
|
pub struct IdentityStore {
|
||||||
|
/// In-memory identity map (agent_id → identity).
|
||||||
identities: HashMap<String, AgentIdentity>,
|
identities: HashMap<String, AgentIdentity>,
|
||||||
|
/// Optional directory for JSON file persistence.
|
||||||
|
storage_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IdentityStore {
|
impl IdentityStore {
|
||||||
/// Create a new identity store.
|
/// Create a new in-memory identity store.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
identities: HashMap::new(),
|
identities: HashMap::new(),
|
||||||
|
storage_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register or update an agent's identity.
|
/// Create an identity store with file-backed persistence.
|
||||||
|
///
|
||||||
|
/// Identities are saved as `{agent_id}.json` in the given directory.
|
||||||
|
pub fn with_persistence(storage_dir: PathBuf) -> Self {
|
||||||
|
fs::create_dir_all(&storage_dir).ok();
|
||||||
|
// Load existing identities from disk
|
||||||
|
let mut identities = HashMap::new();
|
||||||
|
if let Ok(entries) = fs::read_dir(&storage_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map_or(true, |e| e != "json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(content) = fs::read_to_string(&path) {
|
||||||
|
if let Ok(identity) = serde_json::from_str::<AgentIdentity>(&content) {
|
||||||
|
identities.insert(identity.agent_id.clone(), identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!(
|
||||||
|
"Loaded {} identities from {}",
|
||||||
|
identities.len(),
|
||||||
|
storage_dir.display()
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
identities,
|
||||||
|
storage_dir: Some(storage_dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new agent identity or update an existing one.
|
||||||
|
///
|
||||||
|
/// If the identity already exists, the `updated_at` timestamp is
|
||||||
|
/// refreshed. Existing preferences and traits are merged (new values
|
||||||
|
/// take precedence, new traits are appended).
|
||||||
pub fn register(&mut self, identity: AgentIdentity) {
|
pub fn register(&mut self, identity: AgentIdentity) {
|
||||||
self.identities.insert(identity.agent_id.clone(), identity);
|
let now = Utc::now().to_rfc3339();
|
||||||
|
let mut identity = identity;
|
||||||
|
identity.updated_at = now.clone();
|
||||||
|
|
||||||
|
if let Some(existing) = self.identities.get(&identity.agent_id) {
|
||||||
|
// Preserve creation timestamp
|
||||||
|
identity.created_at = existing.created_at.clone();
|
||||||
|
// Merge preferences (existing keep their values unless overridden)
|
||||||
|
for (k, v) in &existing.preferences {
|
||||||
|
identity.preferences.entry(k.clone()).or_insert_with(|| v.clone());
|
||||||
|
}
|
||||||
|
// Merge traits (existing traits preserved, new ones appended)
|
||||||
|
for t in &existing.learned_traits {
|
||||||
|
if !identity.learned_traits.contains(t) {
|
||||||
|
identity.learned_traits.push(t.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
identity.created_at = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
let agent_id = identity.agent_id.clone();
|
||||||
|
self.identities.insert(agent_id.clone(), identity);
|
||||||
|
self.persist(&agent_id);
|
||||||
|
debug!("Registered/updated identity for agent '{agent_id}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get an agent's identity by ID.
|
/// Get an agent's identity by ID.
|
||||||
@@ -39,27 +182,88 @@ impl IdentityStore {
|
|||||||
self.identities.get(agent_id)
|
self.identities.get(agent_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update a preference for an agent.
|
/// Get a mutable reference to an agent's identity.
|
||||||
pub fn set_preference(&mut self, agent_id: &str, key: String, value: String) -> Option<()> {
|
pub fn get_mut(&mut self, agent_id: &str) -> Option<&mut AgentIdentity> {
|
||||||
self.identities
|
self.identities.get_mut(agent_id)
|
||||||
.get_mut(agent_id)?
|
}
|
||||||
.preferences
|
|
||||||
.insert(key, value);
|
/// Update an agent's identity using a closure.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the agent ID was not found.
|
||||||
|
pub fn update<F>(&mut self, agent_id: &str, f: F) -> Option<()>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut AgentIdentity),
|
||||||
|
{
|
||||||
|
let identity = self.identities.get_mut(agent_id)?;
|
||||||
|
f(identity);
|
||||||
|
identity.updated_at = Utc::now().to_rfc3339();
|
||||||
|
self.persist(agent_id);
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set a preference for an agent.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the agent ID was not found.
|
||||||
|
pub fn set_preference(&mut self, agent_id: &str, key: &str, value: &str) -> Option<()> {
|
||||||
|
self.update(agent_id, |id| {
|
||||||
|
id.preferences
|
||||||
|
.insert(key.to_string(), value.to_string());
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a learned trait to an agent's identity.
|
/// Add a learned trait to an agent's identity.
|
||||||
pub fn add_trait(&mut self, agent_id: &str, trait_name: String) -> Option<()> {
|
///
|
||||||
self.identities
|
/// Skips duplicates. Returns `None` if the agent ID was not found.
|
||||||
.get_mut(agent_id)?
|
pub fn add_trait(&mut self, agent_id: &str, trait_name: &str) -> Option<()> {
|
||||||
.learned_traits
|
self.update(agent_id, |id| {
|
||||||
.push(trait_name);
|
if !id.learned_traits.contains(&trait_name.to_string()) {
|
||||||
Some(())
|
id.learned_traits.push(trait_name.to_string());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an agent identity.
|
||||||
|
///
|
||||||
|
/// Returns the removed identity, or `None` if not found.
|
||||||
|
pub fn remove(&mut self, agent_id: &str) -> Option<AgentIdentity> {
|
||||||
|
let removed = self.identities.remove(agent_id);
|
||||||
|
if removed.is_some() {
|
||||||
|
if let Some(ref storage_dir) = self.storage_dir {
|
||||||
|
let path = storage_dir.join(format!("{agent_id}.json"));
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all registered agent IDs.
|
/// List all registered agent IDs.
|
||||||
pub fn list_agents(&self) -> Vec<String> {
|
pub fn list_agents(&self) -> Vec<String> {
|
||||||
self.identities.keys().cloned().collect()
|
let mut ids: Vec<String> = self.identities.keys().cloned().collect();
|
||||||
|
ids.sort();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of registered identities.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.identities.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if no identities are registered.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.identities.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistence ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn persist(&self, agent_id: &str) {
|
||||||
|
if let Some(ref storage_dir) = self.storage_dir {
|
||||||
|
if let Some(identity) = self.identities.get(agent_id) {
|
||||||
|
let path = storage_dir.join(format!("{agent_id}.json"));
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(identity) {
|
||||||
|
let _ = fs::write(&path, &json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,44 +273,176 @@ impl Default for IdentityStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn make_identity(name: &str, role: &str) -> AgentIdentity {
|
||||||
fn test_identity_register_and_retrieve() {
|
AgentIdentity {
|
||||||
let mut store = IdentityStore::new();
|
agent_id: format!("agent-{name}"),
|
||||||
store.register(AgentIdentity {
|
name: name.to_string(),
|
||||||
agent_id: "agent-1".to_string(),
|
role: role.to_string(),
|
||||||
name: "GyanDoot".to_string(),
|
user_name: "TestUser".to_string(),
|
||||||
bio: "An AI Agent".to_string(),
|
user_role: "Developer".to_string(),
|
||||||
|
story: format!("{name} is a test agent serving as {role}."),
|
||||||
preferences: HashMap::new(),
|
preferences: HashMap::new(),
|
||||||
learned_traits: vec![],
|
learned_traits: vec![],
|
||||||
created_at: "now".to_string(),
|
created_at: Utc::now().to_rfc3339(),
|
||||||
updated_at: "now".to_string(),
|
updated_at: Utc::now().to_rfc3339(),
|
||||||
});
|
}
|
||||||
let identity = store.get("agent-1").unwrap();
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_register_and_retrieve() {
|
||||||
|
let mut store = IdentityStore::new();
|
||||||
|
store.register(make_identity("GyanDoot", "AI Assistant"));
|
||||||
|
let identity = store.get("agent-GyanDoot").unwrap();
|
||||||
assert_eq!(identity.name, "GyanDoot");
|
assert_eq!(identity.name, "GyanDoot");
|
||||||
|
assert_eq!(identity.role, "AI Assistant");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_preferences() {
|
fn test_preferences() {
|
||||||
let mut store = IdentityStore::new();
|
let mut store = IdentityStore::new();
|
||||||
store.register(AgentIdentity {
|
store.register(make_identity("Test", "Bot"));
|
||||||
agent_id: "a1".to_string(),
|
|
||||||
name: "Test".to_string(),
|
|
||||||
bio: String::new(),
|
|
||||||
preferences: HashMap::new(),
|
|
||||||
learned_traits: vec![],
|
|
||||||
created_at: "now".to_string(),
|
|
||||||
updated_at: "now".to_string(),
|
|
||||||
});
|
|
||||||
store
|
store
|
||||||
.set_preference("a1", "language".to_string(), "rust".to_string())
|
.set_preference("agent-Test", "language", "rust")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
let identity = store.get("agent-Test").unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store.get("a1").unwrap().preferences.get("language").unwrap(),
|
identity.preferences.get("language").unwrap(),
|
||||||
"rust"
|
"rust"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_add_trait() {
|
||||||
|
let mut store = IdentityStore::new();
|
||||||
|
store.register(make_identity("Test", "Bot"));
|
||||||
|
|
||||||
|
store.add_trait("agent-Test", "helpful").unwrap();
|
||||||
|
store.add_trait("agent-Test", "helpful").unwrap(); // duplicate, should be ignored
|
||||||
|
|
||||||
|
let identity = store.get("agent-Test").unwrap();
|
||||||
|
assert_eq!(identity.learned_traits.len(), 1);
|
||||||
|
assert_eq!(identity.learned_traits[0], "helpful");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_agents() {
|
||||||
|
let mut store = IdentityStore::new();
|
||||||
|
store.register(make_identity("Alpha", "A"));
|
||||||
|
store.register(make_identity("Beta", "B"));
|
||||||
|
let agents = store.list_agents();
|
||||||
|
assert_eq!(agents.len(), 2);
|
||||||
|
assert!(agents.contains(&"agent-Alpha".to_string()));
|
||||||
|
assert!(agents.contains(&"agent-Beta".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_remove() {
|
||||||
|
let mut store = IdentityStore::new();
|
||||||
|
store.register(make_identity("Temp", "T"));
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
let removed = store.remove("agent-Temp");
|
||||||
|
assert!(removed.is_some());
|
||||||
|
assert_eq!(store.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update() {
|
||||||
|
let mut store = IdentityStore::new();
|
||||||
|
store.register(make_identity("Updatable", "Assistant"));
|
||||||
|
|
||||||
|
store
|
||||||
|
.update("agent-Updatable", |id| {
|
||||||
|
id.role = "Senior Assistant".to_string()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let identity = store.get("agent-Updatable").unwrap();
|
||||||
|
assert_eq!(identity.role, "Senior Assistant");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_l0_token_budget() {
|
||||||
|
let identity = make_identity("GyanDoot", "AI Assistant");
|
||||||
|
let tokens = identity.l0_token_estimate();
|
||||||
|
assert!(tokens < 157, "L0 identity should fit in 157 tokens (got {tokens})");
|
||||||
|
assert!(identity.l0_fits_budget());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_l0_string() {
|
||||||
|
let identity = make_identity("TestBot", "Helper");
|
||||||
|
let l0 = identity.l0_string();
|
||||||
|
assert!(l0.contains("TestBot"));
|
||||||
|
assert!(l0.contains("Helper"));
|
||||||
|
assert!(l0.contains("TestUser"));
|
||||||
|
assert!(l0.contains("Developer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_l1_string() {
|
||||||
|
let identity = make_identity("StoryBot", "Storyteller");
|
||||||
|
let l1 = identity.l1_string();
|
||||||
|
assert!(l1.contains("StoryBot"));
|
||||||
|
assert!(l1.contains("Storyteller"));
|
||||||
|
assert!(l1.contains("test agent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_persistence() {
|
||||||
|
let tmp = std::env::temp_dir().join("gyandoot_identity_test");
|
||||||
|
|
||||||
|
// Create store and register
|
||||||
|
{
|
||||||
|
let mut store = IdentityStore::with_persistence(tmp.clone());
|
||||||
|
store.register(make_identity("PersistBot", "Test"));
|
||||||
|
store
|
||||||
|
.set_preference("agent-PersistBot", "mode", "test")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new store from same directory and verify
|
||||||
|
{
|
||||||
|
let store = IdentityStore::with_persistence(tmp.clone());
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
let identity = store.get("agent-PersistBot").unwrap();
|
||||||
|
assert_eq!(identity.name, "PersistBot");
|
||||||
|
assert_eq!(
|
||||||
|
identity.preferences.get("mode").unwrap(),
|
||||||
|
"test"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_register_updates_existing() {
|
||||||
|
let mut store = IdentityStore::new();
|
||||||
|
let mut id = make_identity("Merge", "Bot");
|
||||||
|
id.learned_traits.push("helpful".to_string());
|
||||||
|
store.register(id);
|
||||||
|
|
||||||
|
// Register again with a new preference
|
||||||
|
let mut id2 = make_identity("Merge", "Bot");
|
||||||
|
id2.preferences.insert("style".to_string(), "concise".to_string());
|
||||||
|
store.register(id2);
|
||||||
|
|
||||||
|
let identity = store.get("agent-Merge").unwrap();
|
||||||
|
// Should have merged traits
|
||||||
|
assert_eq!(identity.learned_traits.len(), 1);
|
||||||
|
assert!(identity.learned_traits.contains(&"helpful".to_string()));
|
||||||
|
// Should have the preference
|
||||||
|
assert_eq!(
|
||||||
|
identity.preferences.get("style").unwrap(),
|
||||||
|
"concise"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
//! GyanDoot Memory — MemPalace persistent memory system.
|
//! GyanDoot Memory — MemPalace persistent memory system.
|
||||||
//!
|
//!
|
||||||
//! The MemPalace stores conversation transcripts, agent identity data,
|
//! The MemPalace stores conversation transcripts, agent identity data,
|
||||||
//! and provides semantic query over past interactions using embeddings
|
//! and provides query over past interactions. Data is organized into
|
||||||
//! stored alongside text.
|
//! "wings" under `~/.gyandoot/palace/`.
|
||||||
|
|
||||||
pub mod palace;
|
pub mod palace;
|
||||||
pub mod transcript;
|
pub mod transcript;
|
||||||
|
|||||||
@@ -1,227 +1,556 @@
|
|||||||
//! MemPalace — The core persistent memory store.
|
//! MemPalace — The core persistent memory store for GyanDoot agents.
|
||||||
//!
|
//!
|
||||||
//! Stores conversation entries with embeddings for semantic retrieval,
|
//! Directory structure:
|
||||||
//! handles memory compaction, and provides query capabilities.
|
//! ```text
|
||||||
|
//! ~/.gyandoot/palace/wings/<wing>/
|
||||||
|
//! sessions/YYYY-MM-DD_transcript.md (append-only daily logs)
|
||||||
|
//! identity/
|
||||||
|
//! L0_identity.md (short, < 157 tokens)
|
||||||
|
//! L1_story.md (full narrative)
|
||||||
|
//! drawer.md (append-only summary archive)
|
||||||
|
//! ```
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::Utc;
|
||||||
use gyandoot_core::MemoryResult;
|
use tracing::{debug, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
use crate::identity::IdentityStore;
|
use crate::identity::IdentityStore;
|
||||||
use crate::transcript::TranscriptStore;
|
use crate::transcript::TranscriptStore;
|
||||||
|
|
||||||
/// A single memory entry stored in the palace.
|
// ─── Error type ─────────────────────────────────────────────────────────
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MemoryEntry {
|
/// Errors that may occur during MemPalace operations.
|
||||||
pub id: String,
|
#[derive(Debug)]
|
||||||
pub content: String,
|
pub enum PalaceError {
|
||||||
pub agent_id: String,
|
Io(std::io::Error),
|
||||||
pub session_id: String,
|
NotFound(String),
|
||||||
pub entry_type: EntryType,
|
Serialization(String),
|
||||||
pub embedding: Option<Vec<f64>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub metadata: std::collections::HashMap<String, String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The type of a memory entry.
|
impl std::fmt::Display for PalaceError {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
pub enum EntryType {
|
match self {
|
||||||
Conversation,
|
PalaceError::Io(e) => write!(f, "IO error: {e}"),
|
||||||
Fact,
|
PalaceError::NotFound(s) => write!(f, "Not found: {s}"),
|
||||||
Identity,
|
PalaceError::Serialization(s) => write!(f, "Serialization error: {s}"),
|
||||||
Task,
|
}
|
||||||
Skill,
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A memory query result with relevance score.
|
impl std::error::Error for PalaceError {}
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct MemoryQueryResult {
|
impl From<std::io::Error> for PalaceError {
|
||||||
pub content: String,
|
fn from(e: std::io::Error) -> Self {
|
||||||
pub relevance: f64,
|
PalaceError::Io(e)
|
||||||
pub entry_type: EntryType,
|
}
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Status information for the memory palace.
|
/// Convenience alias.
|
||||||
#[derive(Debug, Clone)]
|
pub type PalaceResult<T> = Result<T, PalaceError>;
|
||||||
pub struct MemoryStatus {
|
|
||||||
pub entry_count: usize,
|
// ─── Palace paths ───────────────────────────────────────────────────────
|
||||||
pub backend: String,
|
|
||||||
pub storage_size: u64,
|
/// Return the base palace root directory.
|
||||||
|
fn palace_root() -> PathBuf {
|
||||||
|
let home = dirs::home_dir().expect("Cannot determine home directory");
|
||||||
|
home.join(".gyandoot").join("palace")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The MemPalace — persistent, searchable agent memory.
|
/// Return the wing directory for a given wing name.
|
||||||
|
fn wing_dir(wing: &str) -> PathBuf {
|
||||||
|
palace_root().join("wings").join(wing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MemPalace ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// The MemPalace — persistent, file-backed agent memory organized by wing.
|
||||||
///
|
///
|
||||||
/// Entries are stored as JSON files in the data directory, organized by
|
/// Each wing corresponds to an agent profile or deployment context.
|
||||||
/// agent and session. Future versions will include vector embeddings
|
/// Within a wing, conversations are logged as daily Markdown transcripts,
|
||||||
/// for semantic search.
|
/// identity is stored as L0 (short, compact) and L1 (full story) files,
|
||||||
|
/// and summaries are archived in an append-only `drawer.md`.
|
||||||
pub struct MemPalace {
|
pub struct MemPalace {
|
||||||
entries: Vec<MemoryEntry>,
|
wing: String,
|
||||||
storage_path: PathBuf,
|
base_dir: PathBuf,
|
||||||
|
sessions_dir: PathBuf,
|
||||||
|
identity_dir: PathBuf,
|
||||||
transcript_store: TranscriptStore,
|
transcript_store: TranscriptStore,
|
||||||
identity_store: IdentityStore,
|
identity_store: IdentityStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemPalace {
|
impl MemPalace {
|
||||||
/// Create a new MemPalace with memory directory in the user's data dir.
|
/// Open (or create) the palace for a given wing.
|
||||||
pub fn new() -> Self {
|
|
||||||
let data_dir = gyandoot_core::data_dir().join("memory");
|
|
||||||
std::fs::create_dir_all(&data_dir).ok();
|
|
||||||
Self {
|
|
||||||
entries: Vec::new(),
|
|
||||||
storage_path: data_dir,
|
|
||||||
transcript_store: TranscriptStore::new(),
|
|
||||||
identity_store: IdentityStore::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a MemPalace with a custom storage path.
|
|
||||||
pub fn with_path(path: PathBuf) -> Self {
|
|
||||||
std::fs::create_dir_all(&path).ok();
|
|
||||||
Self {
|
|
||||||
entries: Vec::new(),
|
|
||||||
storage_path: path,
|
|
||||||
transcript_store: TranscriptStore::new(),
|
|
||||||
identity_store: IdentityStore::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a memory entry in the palace.
|
|
||||||
pub fn store(&mut self, entry: MemoryEntry) -> MemoryResult<()> {
|
|
||||||
debug!("Storing memory entry: {}", entry.id);
|
|
||||||
self.entries.push(entry);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a conversation turn (the last user + assistant pair) as memory.
|
|
||||||
pub async fn store_conversation_turn(
|
|
||||||
&mut self,
|
|
||||||
messages: &[gyandoot_core::Message],
|
|
||||||
) -> MemoryResult<()> {
|
|
||||||
let recent: Vec<&gyandoot_core::Message> = messages.iter().rev().take(4).collect();
|
|
||||||
for msg in recent.iter().rev() {
|
|
||||||
let entry = MemoryEntry {
|
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
|
||||||
content: msg.content.clone(),
|
|
||||||
agent_id: "default".to_string(),
|
|
||||||
session_id: String::new(),
|
|
||||||
entry_type: EntryType::Conversation,
|
|
||||||
embedding: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
metadata: std::collections::HashMap::new(),
|
|
||||||
};
|
|
||||||
self.store(entry)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query memory by simple text matching.
|
|
||||||
///
|
///
|
||||||
/// Future versions will use embedding-based semantic search.
|
/// Creates directory structure if it does not exist.
|
||||||
pub fn query(&self, query: &str, limit: usize) -> MemoryResult<Vec<MemoryQueryResult>> {
|
pub fn new(wing: &str) -> Self {
|
||||||
let query_lower = query.to_lowercase();
|
let base = wing_dir(wing);
|
||||||
let mut results: Vec<MemoryQueryResult> = self
|
let sessions = base.join("sessions");
|
||||||
.entries
|
let identity = base.join("identity");
|
||||||
.iter()
|
|
||||||
.filter(|e| e.content.to_lowercase().contains(&query_lower))
|
|
||||||
.map(|e| MemoryQueryResult {
|
|
||||||
content: e.content.clone(),
|
|
||||||
relevance: 1.0, // placeholder; real relevance from embedding cosine sim
|
|
||||||
entry_type: e.entry_type.clone(),
|
|
||||||
created_at: e.created_at,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
results.sort_by(|a, b| b.relevance.partial_cmp(&a.relevance).unwrap_or(std::cmp::Ordering::Equal));
|
// Ensure directory structure exists
|
||||||
results.truncate(limit);
|
std::fs::create_dir_all(&sessions).unwrap_or_else(|e| {
|
||||||
Ok(results)
|
warn!("Failed to create sessions dir: {e}");
|
||||||
|
});
|
||||||
|
std::fs::create_dir_all(&identity).unwrap_or_else(|e| {
|
||||||
|
warn!("Failed to create identity dir: {e}");
|
||||||
|
});
|
||||||
|
|
||||||
|
info!("MemPalace initialized for wing '{wing}' at {}", base.display());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
wing: wing.to_string(),
|
||||||
|
base_dir: base,
|
||||||
|
sessions_dir: sessions,
|
||||||
|
identity_dir: identity,
|
||||||
|
transcript_store: TranscriptStore::new(),
|
||||||
|
identity_store: IdentityStore::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Export all memory to a JSON file at the given path.
|
/// Open the palace for a wing at a custom base path.
|
||||||
pub fn export_to(&self, path: &PathBuf) -> MemoryResult<()> {
|
pub fn with_path(wing: &str, base: PathBuf) -> Self {
|
||||||
let json = serde_json::to_string_pretty(&self.entries)
|
let sessions = base.join("sessions");
|
||||||
.map_err(|e| gyandoot_core::MemoryError::Storage(e.to_string()))?;
|
let identity = base.join("identity");
|
||||||
std::fs::write(path, &json)
|
|
||||||
.map_err(|e| gyandoot_core::MemoryError::Storage(e.to_string()))?;
|
std::fs::create_dir_all(&sessions).unwrap_or_else(|e| {
|
||||||
|
warn!("Failed to create sessions dir: {e}");
|
||||||
|
});
|
||||||
|
std::fs::create_dir_all(&identity).unwrap_or_else(|e| {
|
||||||
|
warn!("Failed to create identity dir: {e}");
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
wing: wing.to_string(),
|
||||||
|
base_dir: base,
|
||||||
|
sessions_dir: sessions,
|
||||||
|
identity_dir: identity,
|
||||||
|
transcript_store: TranscriptStore::new(),
|
||||||
|
identity_store: IdentityStore::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generic storage ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Store a value as a JSON file under `sessions/`.
|
||||||
|
///
|
||||||
|
/// The file is named `{key}.json` in the sessions directory.
|
||||||
|
pub fn store(&self, key: &str, value: &serde_json::Value) -> PalaceResult<()> {
|
||||||
|
let path = self.sessions_dir.join(format!("{key}.json"));
|
||||||
|
let json = serde_json::to_string_pretty(value)
|
||||||
|
.map_err(|e| PalaceError::Serialization(e.to_string()))?;
|
||||||
|
std::fs::write(&path, &json)?;
|
||||||
|
debug!("Stored memory key '{key}' to {}", path.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get memory status information.
|
/// Query memory by simple string matching against stored JSON files.
|
||||||
pub fn status() -> MemoryResult<MemoryStatus> {
|
///
|
||||||
let data_dir = gyandoot_core::data_dir().join("memory");
|
/// Searches the `sessions/` directory for files whose content contains
|
||||||
let size = std::fs::read_dir(&data_dir)
|
/// the query string. Returns up to `limit` matches.
|
||||||
.map(|entries| {
|
pub fn query(&self, query: &str, limit: usize) -> PalaceResult<Vec<String>> {
|
||||||
entries
|
let query_lower = query.to_lowercase();
|
||||||
.filter_map(|e| e.ok())
|
let mut results: Vec<(String, String)> = Vec::new(); // (filename, content snippet)
|
||||||
.filter_map(|e| e.metadata().ok())
|
|
||||||
.map(|m| m.len())
|
|
||||||
.sum()
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
let entry_count = std::fs::read_dir(&data_dir)
|
let entries = std::fs::read_dir(&self.sessions_dir)?;
|
||||||
.map(|entries| entries.filter_map(|e| e.ok()).count())
|
for entry in entries.flatten() {
|
||||||
.unwrap_or(0);
|
let path = entry.path();
|
||||||
|
if path.extension().map_or(true, |e| e != "json") {
|
||||||
Ok(MemoryStatus {
|
continue;
|
||||||
entry_count,
|
}
|
||||||
backend: "local-json".to_string(),
|
let content = std::fs::read_to_string(&path)?;
|
||||||
storage_size: size,
|
if content.to_lowercase().contains(&query_lower) {
|
||||||
})
|
let snippet = if content.len() > 200 {
|
||||||
|
format!("{}…", &content[..200])
|
||||||
|
} else {
|
||||||
|
content.clone()
|
||||||
|
};
|
||||||
|
results.push((
|
||||||
|
path.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
snippet,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the transcript store.
|
results.truncate(limit);
|
||||||
|
Ok(results
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, snippet)| format!("[{name}]\n{snippet}"))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export all palace data to a single file.
|
||||||
|
///
|
||||||
|
/// The export bundles daily transcripts, identity files, and the drawer
|
||||||
|
/// summary into one file at `path`.
|
||||||
|
pub fn export_to(&self, path: &Path) -> PalaceResult<()> {
|
||||||
|
let mut output = String::new();
|
||||||
|
|
||||||
|
output.push_str(&format!("# MemPalace Export — Wing: {}\n\n", self.wing));
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
output.push_str("## Identity\n\n");
|
||||||
|
if let Ok(id) = std::fs::read_to_string(self.identity_dir.join("L0_identity.md")) {
|
||||||
|
output.push_str(&id);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
if let Ok(story) = std::fs::read_to_string(self.identity_dir.join("L1_story.md")) {
|
||||||
|
output.push_str(&story);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawer
|
||||||
|
output.push_str("## Drawer (Summary Archive)\n\n");
|
||||||
|
if let Ok(drawer) = std::fs::read_to_string(self.base_dir.join("drawer.md")) {
|
||||||
|
output.push_str(&drawer);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent sessions
|
||||||
|
output.push_str("## Recent Sessions\n\n");
|
||||||
|
let mut session_files: Vec<_> = std::fs::read_dir(&self.sessions_dir)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|d| d.flatten())
|
||||||
|
.filter(|e| e.path().extension().map_or(false, |ext| ext == "md" || ext == "json"))
|
||||||
|
.collect();
|
||||||
|
session_files.sort_by_key(|e| e.path().metadata().map(|m| m.modified().ok()).ok().flatten());
|
||||||
|
for f in session_files.iter().rev().take(10) {
|
||||||
|
if let Ok(content) = std::fs::read_to_string(f.path()) {
|
||||||
|
output.push_str(&format!("--- {} ---\n", f.file_name().to_string_lossy()));
|
||||||
|
let truncated = if content.len() > 2000 {
|
||||||
|
format!("{}…\n[truncated {} chars]\n", &content[..2000], content.len())
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
};
|
||||||
|
output.push_str(&truncated);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::write(path, &output)?;
|
||||||
|
info!(
|
||||||
|
"Exported palace wing '{}' to {} ({} bytes)",
|
||||||
|
self.wing,
|
||||||
|
path.display(),
|
||||||
|
output.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transcript operations ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Append a message to the daily transcript file.
|
||||||
|
///
|
||||||
|
/// Format: `YYYY-MM-DD_transcript.md` in the sessions directory.
|
||||||
|
/// Each entry is timestamped and tagged with the role.
|
||||||
|
pub async fn transcript_append(&self, role: &str, content: &str) -> PalaceResult<()> {
|
||||||
|
let date_str = Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
let path = self.sessions_dir.join(format!("{date_str}_transcript.md"));
|
||||||
|
|
||||||
|
let timestamp = Utc::now().format("%H:%M:%S%.3f").to_string();
|
||||||
|
let line = format!("\n## [{timestamp}] **{role}**\n{content}\n");
|
||||||
|
|
||||||
|
std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)?
|
||||||
|
.sync_all()?;
|
||||||
|
|
||||||
|
// We write via a separate open to use std::io::Write
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)?;
|
||||||
|
file.write_all(line.as_bytes())?;
|
||||||
|
file.flush()?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"Appended {role} message ({len}B) to {}",
|
||||||
|
path.display(),
|
||||||
|
len = content.len()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read recent transcript content.
|
||||||
|
///
|
||||||
|
/// `max_chars` caps the total returned content. `tail_lines` limits to
|
||||||
|
/// the last N lines of the most recent transcript file.
|
||||||
|
pub async fn get_transcript(&self, max_chars: usize, tail_lines: usize) -> PalaceResult<String> {
|
||||||
|
let date_str = Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
let path = self.sessions_dir.join(format!("{date_str}_transcript.md"));
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&path)?;
|
||||||
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
|
||||||
|
// Take the last `tail_lines` lines
|
||||||
|
let start = lines.len().saturating_sub(tail_lines);
|
||||||
|
let tail: String = lines[start..].join("\n");
|
||||||
|
|
||||||
|
if tail.len() <= max_chars {
|
||||||
|
Ok(tail)
|
||||||
|
} else {
|
||||||
|
let truncated: String = tail.chars().take(max_chars).collect();
|
||||||
|
Ok(format!("{truncated}\n…[truncated]"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Identity operations ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Set the agent identity, writing both L0 (compact) and L1 (full story)
|
||||||
|
/// files.
|
||||||
|
///
|
||||||
|
/// **L0** must fit in roughly 157 tokens (~628 chars) for fast
|
||||||
|
/// injection into every system prompt.
|
||||||
|
pub fn identity_set(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
role: &str,
|
||||||
|
user_name: &str,
|
||||||
|
user_role: &str,
|
||||||
|
) -> PalaceResult<()> {
|
||||||
|
// L0 identity: compact, < 157 tokens (~628 chars)
|
||||||
|
let l0 = format!(
|
||||||
|
"name: {name}\nrole: {role}\nuser: {user_name} ({user_role})\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
if estimate_tokens(&l0) > 157 {
|
||||||
|
warn!(
|
||||||
|
"L0 identity may exceed 157 tokens (estimated {})",
|
||||||
|
estimate_tokens(&l0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let l0_path = self.identity_dir.join("L0_identity.md");
|
||||||
|
std::fs::write(&l0_path, &l0)?;
|
||||||
|
|
||||||
|
// L1 story: full narrative
|
||||||
|
let l1 = format!(
|
||||||
|
"# {name}\n\n**Role:** {role}\n\n**User:** {user_name}\n\n**User Role:** {user_role}\n\n\
|
||||||
|
This agent assists {user_name} ({user_role}) as {name}, playing the role of {role}.\n\n\
|
||||||
|
## Traits\n- Helpful\n- Knowledgeable\n- Direct\n\n## Preferences\n- Concise responses\n\
|
||||||
|
- Action-oriented\n- Evidence-based\n"
|
||||||
|
);
|
||||||
|
let l1_path = self.identity_dir.join("L1_story.md");
|
||||||
|
std::fs::write(&l1_path, &l1)?;
|
||||||
|
|
||||||
|
info!("Identity set: name={name}, role={role}, user={user_name}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read current identity (L0 + L1) as a tuple.
|
||||||
|
pub fn identity_get(&self) -> PalaceResult<(String, String)> {
|
||||||
|
let l0_path = self.identity_dir.join("L0_identity.md");
|
||||||
|
let l1_path = self.identity_dir.join("L1_story.md");
|
||||||
|
|
||||||
|
let l0 = if l0_path.exists() {
|
||||||
|
std::fs::read_to_string(&l0_path)?
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let l1 = if l1_path.exists() {
|
||||||
|
std::fs::read_to_string(&l1_path)?
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((l0, l1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drawer / session digest ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Append a summary/digest of the current session to `drawer.md`.
|
||||||
|
///
|
||||||
|
/// The drawer is an append-only file that accumulates session-level
|
||||||
|
/// summaries for long-term recall.
|
||||||
|
pub async fn save_session_digest(&self, summary: &str) -> PalaceResult<()> {
|
||||||
|
let path = self.base_dir.join("drawer.md");
|
||||||
|
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)?;
|
||||||
|
let entry = format!("\n---\n**Session Digest — {timestamp}**\n\n{summary}\n");
|
||||||
|
file.write_all(entry.as_bytes())?;
|
||||||
|
file.flush()?;
|
||||||
|
|
||||||
|
info!("Session digest saved to drawer.md");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Getters ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Get a reference to the in-memory transcript store.
|
||||||
pub fn transcript_store(&self) -> &TranscriptStore {
|
pub fn transcript_store(&self) -> &TranscriptStore {
|
||||||
&self.transcript_store
|
&self.transcript_store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get a mutable reference to the in-memory transcript store.
|
||||||
|
pub fn transcript_store_mut(&mut self) -> &mut TranscriptStore {
|
||||||
|
&mut self.transcript_store
|
||||||
|
}
|
||||||
|
|
||||||
/// Get a reference to the identity store.
|
/// Get a reference to the identity store.
|
||||||
pub fn identity_store(&self) -> &IdentityStore {
|
pub fn identity_store(&self) -> &IdentityStore {
|
||||||
&self.identity_store
|
&self.identity_store
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MemPalace {
|
/// Get a mutable reference to the identity store.
|
||||||
fn default() -> Self {
|
pub fn identity_store_mut(&mut self) -> &mut IdentityStore {
|
||||||
Self::new()
|
&mut self.identity_store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The wing name for this palace.
|
||||||
|
pub fn wing(&self) -> &str {
|
||||||
|
&self.wing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The base directory of this palace.
|
||||||
|
pub fn base_dir(&self) -> &Path {
|
||||||
|
&self.base_dir
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Rough token estimation (~4 chars per token).
|
||||||
|
fn estimate_tokens(text: &str) -> usize {
|
||||||
|
(text.len() / 4).max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
fn test_wing() -> String {
|
||||||
|
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
format!("test_wing_{}", COUNTER.fetch_add(1, Ordering::SeqCst))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_creates_directories() {
|
||||||
|
let wing = test_wing();
|
||||||
|
let palace = MemPalace::new(&wing);
|
||||||
|
|
||||||
|
assert!(palace.sessions_dir.exists());
|
||||||
|
assert!(palace.identity_dir.exists());
|
||||||
|
assert_eq!(palace.wing(), &wing);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_store_and_query() {
|
fn test_store_and_query() {
|
||||||
let mut palace = MemPalace::new();
|
let wing = test_wing();
|
||||||
|
let palace = MemPalace::new(&wing);
|
||||||
|
|
||||||
palace
|
palace
|
||||||
.store(MemoryEntry {
|
.store("test_key", &serde_json::json!({"content": "Python is great"}))
|
||||||
id: "1".to_string(),
|
|
||||||
content: "The user likes Python".to_string(),
|
|
||||||
agent_id: "default".to_string(),
|
|
||||||
session_id: "s1".to_string(),
|
|
||||||
entry_type: EntryType::Fact,
|
|
||||||
embedding: None,
|
|
||||||
created_at: Utc::now(),
|
|
||||||
metadata: std::collections::HashMap::new(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let results = palace.query("python", 10).unwrap();
|
let results = palace.query("Python", 10).unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert!(!results.is_empty());
|
||||||
assert!(results[0].content.contains("Python"));
|
assert!(results[0].contains("Python"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_transcript_append_and_read() {
|
||||||
|
let wing = test_wing();
|
||||||
|
let palace = MemPalace::new(&wing);
|
||||||
|
|
||||||
|
palace
|
||||||
|
.transcript_append("user", "Hello there!")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
palace
|
||||||
|
.transcript_append("assistant", "Hi! How can I help?")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let transcript = palace.get_transcript(4096, 50).await.unwrap();
|
||||||
|
assert!(transcript.contains("Hello there!"));
|
||||||
|
assert!(transcript.contains("Hi! How can I help?"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_export() {
|
fn test_identity_set_and_get() {
|
||||||
let palace = MemPalace::new();
|
let wing = test_wing();
|
||||||
let tmp = std::env::temp_dir().join("gyandoot_test_export.json");
|
let palace = MemPalace::new(&wing);
|
||||||
|
|
||||||
|
palace
|
||||||
|
.identity_set("GyanDoot", "AI Assistant", "Alice", "Developer")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (l0, l1) = palace.identity_get().unwrap();
|
||||||
|
assert!(l0.contains("GyanDoot"));
|
||||||
|
assert!(l0.contains("Alice"));
|
||||||
|
assert!(l0.contains("Developer"));
|
||||||
|
assert!(l0.contains("AI Assistant"));
|
||||||
|
assert!(l1.contains("GyanDoot"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_session_digest() {
|
||||||
|
let wing = test_wing();
|
||||||
|
let palace = MemPalace::new(&wing);
|
||||||
|
|
||||||
|
palace
|
||||||
|
.save_session_digest("Discussed project architecture and planned refactoring.")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let drawer_path = palace.base_dir().join("drawer.md");
|
||||||
|
assert!(drawer_path.exists());
|
||||||
|
let content = std::fs::read_to_string(drawer_path).unwrap();
|
||||||
|
assert!(content.contains("Session Digest"));
|
||||||
|
assert!(content.contains("refactoring"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_export_to() {
|
||||||
|
let wing = test_wing();
|
||||||
|
let palace = MemPalace::new(&wing);
|
||||||
|
|
||||||
|
let tmp = std::env::temp_dir().join(format!("gyandoot_export_{wing}.md"));
|
||||||
palace.export_to(&tmp).unwrap();
|
palace.export_to(&tmp).unwrap();
|
||||||
assert!(tmp.exists());
|
assert!(tmp.exists());
|
||||||
std::fs::remove_file(tmp).ok();
|
let content = std::fs::read_to_string(&tmp).unwrap();
|
||||||
|
assert!(content.contains(&wing));
|
||||||
|
std::fs::remove_file(&tmp).ok();
|
||||||
|
std::fs::remove_dir_all(palace.base_dir()).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_with_path() {
|
||||||
|
let wing = test_wing();
|
||||||
|
let tmp = std::env::temp_dir().join(format!("gyandoot_palace_{wing}"));
|
||||||
|
let palace = MemPalace::with_path(&wing, tmp.clone());
|
||||||
|
|
||||||
|
assert!(palace.sessions_dir.exists());
|
||||||
|
assert!(palace.identity_dir.exists());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&tmp).ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,79 +1,174 @@
|
|||||||
//! Transcript store — structured conversation log management.
|
//! Transcript store — structured conversation log management.
|
||||||
|
//!
|
||||||
|
//! Provides a file-backed store for indexed, timestamped conversation
|
||||||
|
//! transcripts. Supports creating, appending turns to, finalizing, and
|
||||||
|
//! listing transcripts.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
/// A single turn in a conversation transcript.
|
// ─── Data types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A single turn (exchange) in a transcript.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TranscriptTurn {
|
pub struct TranscriptTurn {
|
||||||
|
/// Sequential turn number within the transcript (1-indexed).
|
||||||
pub turn_number: u64,
|
pub turn_number: u64,
|
||||||
|
/// The user's message content.
|
||||||
pub user_message: String,
|
pub user_message: String,
|
||||||
|
/// The assistant's response content.
|
||||||
pub assistant_message: String,
|
pub assistant_message: String,
|
||||||
pub tool_calls: Vec<String>,
|
/// Tool call identifiers made during this turn.
|
||||||
|
pub tool_call_ids: Vec<String>,
|
||||||
|
/// Round-trip latency in milliseconds.
|
||||||
pub latency_ms: u64,
|
pub latency_ms: u64,
|
||||||
|
/// When this turn occurred.
|
||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A complete conversation transcript.
|
/// A complete conversation transcript.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Transcript {
|
pub struct Transcript {
|
||||||
|
/// Unique transcript identifier (UUID).
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
/// The agent that participated in this conversation.
|
||||||
pub agent_id: String,
|
pub agent_id: String,
|
||||||
|
/// The session this transcript belongs to.
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
|
/// When the transcript was started.
|
||||||
pub started_at: DateTime<Utc>,
|
pub started_at: DateTime<Utc>,
|
||||||
|
/// When the transcript was ended (None if still active).
|
||||||
pub ended_at: Option<DateTime<Utc>>,
|
pub ended_at: Option<DateTime<Utc>>,
|
||||||
|
/// Ordered list of conversation turns.
|
||||||
pub turns: Vec<TranscriptTurn>,
|
pub turns: Vec<TranscriptTurn>,
|
||||||
|
/// Optional summary / digest of the conversation.
|
||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manages conversation transcripts.
|
impl Transcript {
|
||||||
|
/// Render the transcript to a human-readable string.
|
||||||
|
pub fn to_display(&self) -> String {
|
||||||
|
let mut output = format!(
|
||||||
|
"Transcript {}\nAgent: {}\nSession: {}\nStarted: {}\n",
|
||||||
|
self.id,
|
||||||
|
self.agent_id,
|
||||||
|
self.session_id,
|
||||||
|
self.started_at.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(ref ended) = self.ended_at {
|
||||||
|
output.push_str(&format!("Ended: {}\n", ended.format("%Y-%m-%d %H:%M:%S UTC")));
|
||||||
|
}
|
||||||
|
if let Some(ref summary) = self.summary {
|
||||||
|
output.push_str(&format!("Summary: {summary}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push_str(&format!("\nTurns: {}\n", self.turns.len()));
|
||||||
|
for turn in &self.turns {
|
||||||
|
output.push_str(&format!(
|
||||||
|
"\n[{turn_number}] {timestamp} — User:\n{user}\n— Assistant ({latency_ms}ms):\n{assistant}\n",
|
||||||
|
turn_number = turn.turn_number,
|
||||||
|
timestamp = turn.timestamp.format("%H:%M:%S"),
|
||||||
|
user = turn.user_message,
|
||||||
|
assistant = turn.assistant_message,
|
||||||
|
latency_ms = turn.latency_ms,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TranscriptStore ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Manages conversation transcripts with optional file persistence.
|
||||||
pub struct TranscriptStore {
|
pub struct TranscriptStore {
|
||||||
|
/// In-memory transcript map.
|
||||||
transcripts: HashMap<String, Transcript>,
|
transcripts: HashMap<String, Transcript>,
|
||||||
|
/// Optional directory for JSON file persistence.
|
||||||
|
storage_dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TranscriptStore {
|
impl TranscriptStore {
|
||||||
/// Create a new empty transcript store.
|
/// Create a new empty transcript store (in-memory only).
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
transcripts: HashMap::new(),
|
transcripts: HashMap::new(),
|
||||||
|
storage_dir: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a new transcript.
|
/// Create a new transcript store with file-backed persistence.
|
||||||
pub fn start_transcript(&mut self, id: String, agent_id: String, session_id: String) {
|
///
|
||||||
self.transcripts.insert(
|
/// Transcripts are saved as `{transcript_id}.json` in the given
|
||||||
id.clone(),
|
/// directory.
|
||||||
Transcript {
|
pub fn with_persistence(storage_dir: PathBuf) -> Self {
|
||||||
id,
|
fs::create_dir_all(&storage_dir).ok();
|
||||||
agent_id,
|
Self {
|
||||||
session_id,
|
transcripts: HashMap::new(),
|
||||||
|
storage_dir: Some(storage_dir),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a new transcript with the given metadata.
|
||||||
|
///
|
||||||
|
/// Returns the generated transcript ID (UUID).
|
||||||
|
pub fn start_transcript(&mut self, agent_id: &str, session_id: &str) -> String {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let transcript = Transcript {
|
||||||
|
id: id.clone(),
|
||||||
|
agent_id: agent_id.to_string(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
started_at: Utc::now(),
|
started_at: Utc::now(),
|
||||||
ended_at: None,
|
ended_at: None,
|
||||||
turns: Vec::new(),
|
turns: Vec::new(),
|
||||||
summary: None,
|
summary: None,
|
||||||
},
|
};
|
||||||
);
|
self.transcripts.insert(id.clone(), transcript);
|
||||||
|
debug!("Started transcript {id} (agent={agent_id}, session={session_id})");
|
||||||
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a turn to an existing transcript.
|
/// Append a turn to a transcript.
|
||||||
pub fn append_turn(&mut self, transcript_id: &str, turn: TranscriptTurn) -> Option<()> {
|
///
|
||||||
self.transcripts.get_mut(transcript_id)?.turns.push(turn);
|
/// Returns `None` if the transcript ID was not found.
|
||||||
|
pub fn append_turn(
|
||||||
|
&mut self,
|
||||||
|
transcript_id: &str,
|
||||||
|
turn: TranscriptTurn,
|
||||||
|
) -> Option<()> {
|
||||||
|
let transcript = self.transcripts.get_mut(transcript_id)?;
|
||||||
|
transcript.turns.push(turn);
|
||||||
|
self.persist(transcript_id);
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End a transcript by setting the end timestamp.
|
/// End a transcript by setting the end timestamp.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the transcript ID was not found.
|
||||||
pub fn end_transcript(&mut self, transcript_id: &str) -> Option<()> {
|
pub fn end_transcript(&mut self, transcript_id: &str) -> Option<()> {
|
||||||
self.transcripts
|
let transcript = self.transcripts.get_mut(transcript_id)?;
|
||||||
.get_mut(transcript_id)?
|
transcript.ended_at = Some(Utc::now());
|
||||||
.ended_at = Some(Utc::now());
|
self.persist(transcript_id);
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set a summary for the transcript.
|
/// Finalize a transcript with a summary.
|
||||||
pub fn set_summary(&mut self, transcript_id: &str, summary: String) -> Option<()> {
|
///
|
||||||
self.transcripts.get_mut(transcript_id)?.summary = Some(summary);
|
/// This both sets the summary and ends the transcript.
|
||||||
|
pub fn finalize_transcript(
|
||||||
|
&mut self,
|
||||||
|
transcript_id: &str,
|
||||||
|
summary: String,
|
||||||
|
) -> Option<()> {
|
||||||
|
let transcript = self.transcripts.get_mut(transcript_id)?;
|
||||||
|
transcript.summary = Some(summary);
|
||||||
|
transcript.ended_at = Some(Utc::now());
|
||||||
|
self.persist(transcript_id);
|
||||||
Some(())
|
Some(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +177,49 @@ impl TranscriptStore {
|
|||||||
self.transcripts.get(transcript_id)
|
self.transcripts.get(transcript_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all transcript IDs.
|
/// Get a mutable reference to a transcript by ID.
|
||||||
|
pub fn get_mut(&mut self, transcript_id: &str) -> Option<&mut Transcript> {
|
||||||
|
self.transcripts.get_mut(transcript_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all available transcript IDs.
|
||||||
pub fn list_ids(&self) -> Vec<String> {
|
pub fn list_ids(&self) -> Vec<String> {
|
||||||
self.transcripts.keys().cloned().collect()
|
let mut ids: Vec<String> = self.transcripts.keys().cloned().collect();
|
||||||
|
ids.sort();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a transcript by ID.
|
||||||
|
pub fn delete(&mut self, transcript_id: &str) -> Option<Transcript> {
|
||||||
|
let removed = self.transcripts.remove(transcript_id);
|
||||||
|
if let Some(ref storage_dir) = self.storage_dir {
|
||||||
|
let path = storage_dir.join(format!("{transcript_id}.json"));
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of active transcripts.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.transcripts.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if no transcripts are stored.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.transcripts.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistence helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn persist(&self, transcript_id: &str) {
|
||||||
|
if let Some(ref storage_dir) = self.storage_dir {
|
||||||
|
if let Some(transcript) = self.transcripts.get(transcript_id) {
|
||||||
|
let path = storage_dir.join(format!("{transcript_id}.json"));
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(transcript) {
|
||||||
|
let _ = fs::write(&path, &json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,32 +229,121 @@ impl Default for TranscriptStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn make_turn(num: u64, user: &str, assistant: &str) -> TranscriptTurn {
|
||||||
|
TranscriptTurn {
|
||||||
|
turn_number: num,
|
||||||
|
user_message: user.to_string(),
|
||||||
|
assistant_message: assistant.to_string(),
|
||||||
|
tool_call_ids: vec![],
|
||||||
|
latency_ms: 100,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_transcript_lifecycle() {
|
fn test_transcript_lifecycle() {
|
||||||
let mut store = TranscriptStore::new();
|
let mut store = TranscriptStore::new();
|
||||||
store.start_transcript("t1".to_string(), "agent1".to_string(), "session1".to_string());
|
|
||||||
|
let id = store.start_transcript("agent-1", "session-1");
|
||||||
|
assert!(!id.is_empty());
|
||||||
|
|
||||||
store
|
store
|
||||||
.append_turn(
|
.append_turn(&id, make_turn(1, "Hello", "Hi there!"))
|
||||||
"t1",
|
|
||||||
TranscriptTurn {
|
|
||||||
turn_number: 1,
|
|
||||||
user_message: "Hello".to_string(),
|
|
||||||
assistant_message: "Hi there!".to_string(),
|
|
||||||
tool_calls: vec![],
|
|
||||||
latency_ms: 500,
|
|
||||||
timestamp: Utc::now(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
store.end_transcript("t1").unwrap();
|
store.end_transcript(&id).unwrap();
|
||||||
let t = store.get("t1").unwrap();
|
|
||||||
|
let t = store.get(&id).unwrap();
|
||||||
assert_eq!(t.turns.len(), 1);
|
assert_eq!(t.turns.len(), 1);
|
||||||
assert!(t.ended_at.is_some());
|
assert!(t.ended_at.is_some());
|
||||||
|
assert_eq!(t.agent_id, "agent-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_finalize_transcript() {
|
||||||
|
let mut store = TranscriptStore::new();
|
||||||
|
let id = store.start_transcript("agent-2", "session-2");
|
||||||
|
store
|
||||||
|
.append_turn(&id, make_turn(1, "Hello", "Hi"))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.finalize_transcript(&id, "Brief greeting exchange.".to_string())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let t = store.get(&id).unwrap();
|
||||||
|
assert_eq!(t.summary.as_deref(), Some("Brief greeting exchange."));
|
||||||
|
assert!(t.ended_at.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_ids() {
|
||||||
|
let mut store = TranscriptStore::new();
|
||||||
|
let id1 = store.start_transcript("a1", "s1");
|
||||||
|
let id2 = store.start_transcript("a2", "s2");
|
||||||
|
let ids = store.list_ids();
|
||||||
|
assert_eq!(ids.len(), 2);
|
||||||
|
assert!(ids.contains(&id1));
|
||||||
|
assert!(ids.contains(&id2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_delete() {
|
||||||
|
let mut store = TranscriptStore::new();
|
||||||
|
let id = store.start_transcript("a1", "s1");
|
||||||
|
assert_eq!(store.len(), 1);
|
||||||
|
let removed = store.delete(&id);
|
||||||
|
assert!(removed.is_some());
|
||||||
|
assert_eq!(store.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_append_to_nonexistent_returns_none() {
|
||||||
|
let mut store = TranscriptStore::new();
|
||||||
|
let result = store.append_turn("nonexistent", make_turn(1, "hi", "bye"));
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_display_format() {
|
||||||
|
let mut store = TranscriptStore::new();
|
||||||
|
let id = store.start_transcript("agent-1", "session-1");
|
||||||
|
store
|
||||||
|
.append_turn(&id, make_turn(1, "Hello", "Hi!"))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let t = store.get(&id).unwrap();
|
||||||
|
let display = t.to_display();
|
||||||
|
assert!(display.contains("agent-1"));
|
||||||
|
assert!(display.contains("Hello"));
|
||||||
|
assert!(display.contains("Hi!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_file_persistence() {
|
||||||
|
let tmp = std::env::temp_dir().join("gyandoot_transcript_test");
|
||||||
|
let mut store = TranscriptStore::with_persistence(tmp.clone());
|
||||||
|
let id = store.start_transcript("agent-1", "session-1");
|
||||||
|
store
|
||||||
|
.append_turn(&id, make_turn(1, "Hello", "Hi"))
|
||||||
|
.unwrap();
|
||||||
|
store.end_transcript(&id).unwrap();
|
||||||
|
|
||||||
|
let file_path = tmp.join(format!("{id}.json"));
|
||||||
|
assert!(file_path.exists());
|
||||||
|
|
||||||
|
// Read back
|
||||||
|
let content = std::fs::read_to_string(&file_path).unwrap();
|
||||||
|
let restored: Transcript = serde_json::from_str(&content).unwrap();
|
||||||
|
assert_eq!(restored.agent_id, "agent-1");
|
||||||
|
assert_eq!(restored.turns.len(), 1);
|
||||||
|
assert!(restored.ended_at.is_some());
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&tmp).ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ gyandoot-core = { path = "../gyandoot-core" }
|
|||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
//! Anthropic API provider implementation.
|
//! Anthropic API provider implementation.
|
||||||
|
//!
|
||||||
|
//! Full Anthropic Messages API (`/v1/messages`) with streaming SSE support,
|
||||||
|
//! tool use via the `tools` parameter, proper `x-api-key` + `anthropic-version`
|
||||||
|
//! header auth, and structured error handling.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use gyandoot_core::{ConversationContext, MessageRole, ProviderResult};
|
use futures::StreamExt;
|
||||||
|
use gyandoot_core::{ConversationContext, ProviderError, ProviderResult, ToolCall};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use tracing::debug;
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
|
use crate::streaming::StreamEvent;
|
||||||
use crate::{Provider, ProviderResponse};
|
use crate::{Provider, ProviderResponse};
|
||||||
|
|
||||||
/// Provider for the Anthropic (Claude) API.
|
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||||
|
|
||||||
|
/// Provider for the Anthropic (Claude) Messages API.
|
||||||
pub struct AnthropicProvider {
|
pub struct AnthropicProvider {
|
||||||
api_key: String,
|
api_key: String,
|
||||||
model: String,
|
model: String,
|
||||||
client: Client,
|
client: Client,
|
||||||
|
request_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AnthropicProvider {
|
impl AnthropicProvider {
|
||||||
@@ -20,39 +31,158 @@ impl AnthropicProvider {
|
|||||||
Self {
|
Self {
|
||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
client: Client::new(),
|
client: Client::builder()
|
||||||
|
.timeout(Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client"),
|
||||||
|
request_timeout: Duration::from_secs(60),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set a custom request timeout.
|
||||||
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.request_timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the system prompt from context (Anthropic uses a separate field).
|
||||||
|
fn extract_system(&self, context: &ConversationContext) -> Option<String> {
|
||||||
|
let system_msg = context.messages.iter().find(|m| m.is_system());
|
||||||
|
system_msg.map(|m| m.content.clone())
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the messages array in Anthropic's format.
|
/// Build the messages array in Anthropic's format.
|
||||||
|
///
|
||||||
|
/// Anthropic expects alternating `user` and `assistant` messages.
|
||||||
|
/// System messages are extracted separately. Tool results are sent
|
||||||
|
/// as `user` messages with `content` blocks containing `tool_result`.
|
||||||
fn build_messages(&self, context: &ConversationContext) -> Vec<serde_json::Value> {
|
fn build_messages(&self, context: &ConversationContext) -> Vec<serde_json::Value> {
|
||||||
context
|
let mut result = Vec::new();
|
||||||
.messages
|
|
||||||
.iter()
|
for m in &context.messages {
|
||||||
.filter(|m| m.role != MessageRole::System)
|
if m.is_system() {
|
||||||
.map(|m| {
|
continue; // handled separately
|
||||||
let role = match m.role {
|
}
|
||||||
MessageRole::User => "user",
|
|
||||||
MessageRole::Assistant => "assistant",
|
let serialized = match m.role.to_string().as_str() {
|
||||||
MessageRole::Tool => "user",
|
"tool" => {
|
||||||
MessageRole::System => "user", // filtered above
|
// Tool results: send as user with content blocks
|
||||||
};
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"role": role,
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": m.tool_result.as_ref().map(|_| "tool_result"),
|
||||||
|
"content": m.content
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
role_str => {
|
||||||
|
let mut msg = serde_json::json!({
|
||||||
|
"role": role_str,
|
||||||
"content": m.content,
|
"content": m.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach tool_use blocks for assistant tool calls
|
||||||
|
if let Some(calls) = &m.tool_calls {
|
||||||
|
let blocks: Vec<serde_json::Value> = calls
|
||||||
|
.iter()
|
||||||
|
.map(|tc| {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.id,
|
||||||
|
"name": tc.name,
|
||||||
|
"input": tc.arguments,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
msg["content"] = serde_json::to_value(blocks).unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
result.push(serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the tool definitions in Anthropic's format.
|
||||||
|
fn build_tools(&self, context: &ConversationContext) -> Vec<serde_json::Value> {
|
||||||
|
context
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|td| {
|
||||||
|
serde_json::json!({
|
||||||
|
"name": td.name,
|
||||||
|
"description": td.description,
|
||||||
|
"input_schema": td.parameters,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the system prompt from context (Anthropic uses a separate field).
|
/// Send the non-streaming request.
|
||||||
fn extract_system(&self, context: &ConversationContext) -> String {
|
async fn send_request(&self, body: &serde_json::Value) -> ProviderResult<reqwest::Response> {
|
||||||
context
|
let response = self
|
||||||
.messages
|
.client
|
||||||
.iter()
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
.find(|m| m.role == MessageRole::System)
|
.header("x-api-key", &self.api_key)
|
||||||
.map(|m| m.content.clone())
|
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||||
.unwrap_or_default()
|
.header("content-type", "application/json")
|
||||||
|
.json(body)
|
||||||
|
.timeout(self.request_timeout)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(ProviderError::from_response(status.as_u16(), &error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a non-streaming response.
|
||||||
|
fn parse_response(&self, data: &serde_json::Value) -> ProviderResult<ProviderResponse> {
|
||||||
|
// Extract content blocks
|
||||||
|
let content_blocks = data["content"].as_array().cloned().unwrap_or_default();
|
||||||
|
let mut text_parts = Vec::new();
|
||||||
|
let mut tool_calls = Vec::new();
|
||||||
|
|
||||||
|
for block in &content_blocks {
|
||||||
|
match block["type"].as_str() {
|
||||||
|
Some("text") => {
|
||||||
|
if let Some(text) = block["text"].as_str() {
|
||||||
|
text_parts.push(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("tool_use") => {
|
||||||
|
let id = block["id"].as_str().unwrap_or_default().to_string();
|
||||||
|
let name = block["name"].as_str().unwrap_or_default().to_string();
|
||||||
|
let arguments = block["input"].clone();
|
||||||
|
tool_calls.push(ToolCall::new(id, name, arguments));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = text_parts.join("");
|
||||||
|
let finish_reason = data["stop_reason"].as_str().unwrap_or("end_turn").to_string();
|
||||||
|
|
||||||
|
Ok(ProviderResponse {
|
||||||
|
content,
|
||||||
|
tool_calls: if tool_calls.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tool_calls)
|
||||||
|
},
|
||||||
|
finish_reason,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +193,7 @@ impl Provider for AnthropicProvider {
|
|||||||
|
|
||||||
let system = self.extract_system(&context);
|
let system = self.extract_system(&context);
|
||||||
let messages = self.build_messages(&context);
|
let messages = self.build_messages(&context);
|
||||||
|
let tools = self.build_tools(&context);
|
||||||
|
|
||||||
let mut body = serde_json::json!({
|
let mut body = serde_json::json!({
|
||||||
"model": self.model,
|
"model": self.model,
|
||||||
@@ -70,40 +201,18 @@ impl Provider for AnthropicProvider {
|
|||||||
"max_tokens": context.max_tokens,
|
"max_tokens": context.max_tokens,
|
||||||
});
|
});
|
||||||
|
|
||||||
if !system.is_empty() {
|
if let Some(sys) = &system {
|
||||||
body["system"] = serde_json::Value::String(system);
|
body["system"] = serde_json::Value::String(sys.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = self
|
if !tools.is_empty() {
|
||||||
.client
|
body["tools"] = serde_json::Value::Array(tools);
|
||||||
.post("https://api.anthropic.com/v1/messages")
|
|
||||||
.header("x-api-key", &self.api_key)
|
|
||||||
.header("anthropic-version", "2023-06-01")
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
|
||||||
return Err(gyandoot_core::ProviderError::Api(format!(
|
|
||||||
"Anthropic API error ({}): {}",
|
|
||||||
status, error_text
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let response = self.send_request(&body).await?;
|
||||||
let data: serde_json::Value = response.json().await?;
|
let data: serde_json::Value = response.json().await?;
|
||||||
let content = data["content"][0]["text"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.to_string();
|
|
||||||
let finish_reason = data["stop_reason"].as_str().unwrap_or("end_turn").to_string();
|
|
||||||
|
|
||||||
Ok(ProviderResponse {
|
Ok(self.parse_response(&data)?)
|
||||||
content,
|
|
||||||
tool_calls: None,
|
|
||||||
finish_reason,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn chat_stream(
|
async fn chat_stream(
|
||||||
@@ -111,12 +220,238 @@ impl Provider for AnthropicProvider {
|
|||||||
context: ConversationContext,
|
context: ConversationContext,
|
||||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||||
{
|
{
|
||||||
// Fall back to non-streaming for now
|
debug!("Anthropic streaming chat (model: {})", self.model);
|
||||||
let result = self.chat(context).await?;
|
|
||||||
let stream = tokio_stream::StreamExt::chain(
|
let system = self.extract_system(&context);
|
||||||
tokio_stream::once(Ok(result.content)),
|
let messages = self.build_messages(&context);
|
||||||
tokio_stream::empty(),
|
let tools = self.build_tools(&context);
|
||||||
);
|
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"model": self.model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": context.max_tokens,
|
||||||
|
"stream": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(sys) = &system {
|
||||||
|
body["system"] = serde_json::Value::String(sys.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tools.is_empty() {
|
||||||
|
body["tools"] = serde_json::Value::Array(tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("x-api-key", &self.api_key)
|
||||||
|
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.json(&body)
|
||||||
|
.timeout(self.request_timeout)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(ProviderError::from_response(status.as_u16(), &error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel::<ProviderResult<String>>(256);
|
||||||
|
let mut byte_stream = response.bytes_stream();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut parser = crate::streaming::SseParser::new(50);
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(30), byte_stream.next()).await {
|
||||||
|
Ok(Some(Ok(chunk))) => {
|
||||||
|
buffer.extend_from_slice(&chunk);
|
||||||
|
let buffer_str = String::from_utf8_lossy(&buffer);
|
||||||
|
|
||||||
|
for event in parser.feed_chunk(buffer_str.as_ref()) {
|
||||||
|
match event {
|
||||||
|
StreamEvent::Chunk(text) => {
|
||||||
|
// Anthropic SSE includes content_block_delta events
|
||||||
|
// with "text" deltas. Parse them from the JSON.
|
||||||
|
if let Ok(val) =
|
||||||
|
serde_json::from_str::<serde_json::Value>(&text)
|
||||||
|
{
|
||||||
|
if let Some(delta) = val["delta"]["text"].as_str() {
|
||||||
|
trace!("Anthropic delta: {delta}");
|
||||||
|
if tx.send(Ok(delta.to_string())).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StreamEvent::Done => {
|
||||||
|
debug!("Anthropic stream done");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StreamEvent::Error(msg) => {
|
||||||
|
warn!("Anthropic SSE error: {msg}");
|
||||||
|
let _ = tx.send(Err(ProviderError::Stream(msg))).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StreamEvent::ToolCall(_tc) => {
|
||||||
|
// Anthropic tool calls appear in the final message event,
|
||||||
|
// not as streaming deltas. Handled in non-streaming path.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer.drain(0..parser.consumed());
|
||||||
|
}
|
||||||
|
Ok(Some(Err(e))) => {
|
||||||
|
let _ = tx.send(Err(ProviderError::Request(e))).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(None) => return,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = tx
|
||||||
|
.send(Err(ProviderError::Timeout { duration: 30 }))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
Ok(Box::new(stream))
|
Ok(Box::new(stream))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn models(&self) -> ProviderResult<Vec<String>> {
|
||||||
|
// Anthropic doesn't have a public models endpoint.
|
||||||
|
// Return known models.
|
||||||
|
Ok(vec![
|
||||||
|
"claude-3-5-sonnet-20241022".to_string(),
|
||||||
|
"claude-3-5-haiku-20241022".to_string(),
|
||||||
|
"claude-3-opus-20240229".to_string(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_tools(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use gyandoot_core::{Message, Role, ToolDefinition};
|
||||||
|
|
||||||
|
fn test_provider() -> AnthropicProvider {
|
||||||
|
AnthropicProvider::new("sk-ant-test".to_string(), "claude-3-5-sonnet-20241022".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_system() {
|
||||||
|
let provider = test_provider();
|
||||||
|
let context = ConversationContext {
|
||||||
|
messages: vec![
|
||||||
|
Message::new(Role::System, "You are Claude."),
|
||||||
|
Message::new(Role::User, "Hello"),
|
||||||
|
],
|
||||||
|
system_prompt: "You are Claude.".to_string(),
|
||||||
|
tools: vec![],
|
||||||
|
max_tokens: 4096,
|
||||||
|
};
|
||||||
|
let system = provider.extract_system(&context);
|
||||||
|
assert_eq!(system, Some("You are Claude.".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_messages_filters_system() {
|
||||||
|
let provider = test_provider();
|
||||||
|
let context = ConversationContext {
|
||||||
|
messages: vec![
|
||||||
|
Message::new(Role::System, "System prompt"),
|
||||||
|
Message::new(Role::User, "Hello"),
|
||||||
|
Message::new(Role::Assistant, "Hi there!"),
|
||||||
|
],
|
||||||
|
system_prompt: String::new(),
|
||||||
|
tools: vec![],
|
||||||
|
max_tokens: 4096,
|
||||||
|
};
|
||||||
|
let messages = provider.build_messages(&context);
|
||||||
|
assert_eq!(messages.len(), 2);
|
||||||
|
assert_eq!(messages[0]["role"], "user");
|
||||||
|
assert_eq!(messages[1]["role"], "assistant");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_tools() {
|
||||||
|
let provider = test_provider();
|
||||||
|
let context = ConversationContext {
|
||||||
|
messages: vec![Message::new(Role::User, "Weather?")],
|
||||||
|
system_prompt: String::new(),
|
||||||
|
tools: vec![ToolDefinition::new(
|
||||||
|
"get_weather",
|
||||||
|
"Get weather",
|
||||||
|
serde_json::json!({"type": "object"}),
|
||||||
|
)],
|
||||||
|
max_tokens: 4096,
|
||||||
|
};
|
||||||
|
let tools = provider.build_tools(&context);
|
||||||
|
assert_eq!(tools.len(), 1);
|
||||||
|
assert_eq!(tools[0]["name"], "get_weather");
|
||||||
|
assert!(tools[0].get("input_schema").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_response_text_only() {
|
||||||
|
let provider = test_provider();
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"id": "msg_123",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Hello! I'm Claude."}
|
||||||
|
],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"model": "claude-3-5-sonnet-20241022",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 10,
|
||||||
|
"output_tokens": 5
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = provider.parse_response(&data).unwrap();
|
||||||
|
assert_eq!(response.content, "Hello! I'm Claude.");
|
||||||
|
assert_eq!(response.finish_reason, "end_turn");
|
||||||
|
assert!(response.tool_calls.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_response_with_tool_use() {
|
||||||
|
let provider = test_provider();
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"id": "msg_456",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "Let me check the weather."},
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": "toolu_123",
|
||||||
|
"name": "get_weather",
|
||||||
|
"input": {"city": "NYC"}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stop_reason": "tool_use",
|
||||||
|
"model": "claude-3-5-sonnet-20241022"
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = provider.parse_response(&data).unwrap();
|
||||||
|
assert_eq!(response.content, "Let me check the weather.");
|
||||||
|
assert_eq!(response.finish_reason, "tool_use");
|
||||||
|
assert!(response.has_tool_calls());
|
||||||
|
let calls = response.tool_calls.unwrap();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].id, "toolu_123");
|
||||||
|
assert_eq!(calls[0].name, "get_weather");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,34 +1,327 @@
|
|||||||
//! GyanDoot Providers — LLM provider abstraction layer.
|
//! GyanDoot Providers — LLM provider abstraction layer.
|
||||||
//!
|
//!
|
||||||
//! Defines the `Provider` trait and implementations for OpenAI, Anthropic,
|
//! Defines the [`Provider`] trait and implementations for OpenAI, Anthropic,
|
||||||
//! and Ollama. Each provider handles authentication, request formatting,
|
//! Ollama, and other providers. Each implementation handles authentication,
|
||||||
//! response parsing, and streaming.
|
//! request formatting, response parsing, and streaming.
|
||||||
|
//!
|
||||||
|
//! The [`ProviderFactory`] creates provider instances from
|
||||||
|
//! [`gyandoot_core::ProviderConfig`].
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use gyandoot_core::{ConversationContext, ProviderResult};
|
use gyandoot_core::{ConversationContext, ProviderResult, ToolCall};
|
||||||
|
|
||||||
pub mod openai;
|
pub mod openai;
|
||||||
pub mod anthropic;
|
pub mod anthropic;
|
||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod streaming;
|
pub mod streaming;
|
||||||
|
|
||||||
|
// ─── Re-exports ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub use openai::OpenAIProvider;
|
||||||
|
pub use anthropic::AnthropicProvider;
|
||||||
|
pub use ollama::OllamaProvider;
|
||||||
|
pub use streaming::{SseParser, StreamEvent, TokenStream, collect_stream};
|
||||||
|
|
||||||
|
// ─── ProviderResponse ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A provider response containing the generated text and any tool calls.
|
/// A provider response containing the generated text and any tool calls.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProviderResponse {
|
pub struct ProviderResponse {
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub tool_calls: Option<Vec<gyandoot_core::ToolCall>>,
|
pub tool_calls: Option<Vec<ToolCall>>,
|
||||||
pub finish_reason: String,
|
pub finish_reason: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ProviderResponse {
|
||||||
|
/// Create a new provider response with text content only.
|
||||||
|
pub fn new(content: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
content: content.into(),
|
||||||
|
tool_calls: None,
|
||||||
|
finish_reason: "stop".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a provider response with tool calls.
|
||||||
|
pub fn with_tool_calls(
|
||||||
|
content: impl Into<String>,
|
||||||
|
calls: Vec<ToolCall>,
|
||||||
|
finish_reason: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
content: content.into(),
|
||||||
|
tool_calls: Some(calls),
|
||||||
|
finish_reason: finish_reason.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return true if this response contains tool calls.
|
||||||
|
pub fn has_tool_calls(&self) -> bool {
|
||||||
|
self.tool_calls.as_ref().map_or(false, |c| !c.is_empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider trait ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// The trait that all LLM providers must implement.
|
/// The trait that all LLM providers must implement.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Provider: Send + Sync {
|
pub trait Provider: Send + Sync {
|
||||||
/// Send a chat completion request and return the response.
|
/// Send a chat completion request and return the full response.
|
||||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse>;
|
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse>;
|
||||||
|
|
||||||
/// Send a chat completion request with streaming response.
|
/// Send a chat completion request with streaming response.
|
||||||
|
///
|
||||||
|
/// Each item in the stream represents a text delta. Use
|
||||||
|
/// [`streaming::collect_stream`] to aggregate the full text.
|
||||||
async fn chat_stream(
|
async fn chat_stream(
|
||||||
&self,
|
&self,
|
||||||
context: ConversationContext,
|
context: ConversationContext,
|
||||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>;
|
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>;
|
||||||
|
|
||||||
|
/// Return the list of models available for this provider.
|
||||||
|
async fn models(&self) -> ProviderResult<Vec<String>> {
|
||||||
|
// Default: return an empty list; providers can override.
|
||||||
|
let _ = self;
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return whether this provider supports tool/function calling.
|
||||||
|
fn supports_tools(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ProviderKind (runtime enum) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Runtime provider kind for factory dispatch.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ProviderKind {
|
||||||
|
OpenAI,
|
||||||
|
Anthropic,
|
||||||
|
Ollama,
|
||||||
|
Gemini,
|
||||||
|
DeepSeek,
|
||||||
|
OpenRouter,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderKind {
|
||||||
|
/// Parse from a string, case-insensitive.
|
||||||
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"openai" => Some(ProviderKind::OpenAI),
|
||||||
|
"anthropic" => Some(ProviderKind::Anthropic),
|
||||||
|
"ollama" => Some(ProviderKind::Ollama),
|
||||||
|
"gemini" => Some(ProviderKind::Gemini),
|
||||||
|
"deepseek" => Some(ProviderKind::DeepSeek),
|
||||||
|
"openrouter" => Some(ProviderKind::OpenRouter),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the canonical string name.
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProviderKind::OpenAI => "openai",
|
||||||
|
ProviderKind::Anthropic => "anthropic",
|
||||||
|
ProviderKind::Ollama => "ollama",
|
||||||
|
ProviderKind::Gemini => "gemini",
|
||||||
|
ProviderKind::DeepSeek => "deepseek",
|
||||||
|
ProviderKind::OpenRouter => "openrouter",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ProviderKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ProviderFactory ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Factory for creating provider instances from configuration.
|
||||||
|
pub struct ProviderFactory;
|
||||||
|
|
||||||
|
impl ProviderFactory {
|
||||||
|
/// Create a provider from a [`gyandoot_core::ProviderRootConfig`].
|
||||||
|
pub fn create(config: &gyandoot_core::ProviderRootConfig) -> ProviderResult<Box<dyn Provider>> {
|
||||||
|
let kind = ProviderKind::from_str(&config.default)
|
||||||
|
.ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||||
|
status: 0,
|
||||||
|
message: format!("Unknown provider name: {}", config.default),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Self::create_from_kind(kind, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a provider from a [`ProviderKind`] and config.
|
||||||
|
pub fn create_from_kind(
|
||||||
|
kind: ProviderKind,
|
||||||
|
config: &gyandoot_core::ProviderRootConfig,
|
||||||
|
) -> ProviderResult<Box<dyn Provider>> {
|
||||||
|
match kind {
|
||||||
|
ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter => {
|
||||||
|
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||||
|
status: 0,
|
||||||
|
message: "OpenAI config not found".to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(Box::new(openai::OpenAIProvider::new(
|
||||||
|
cfg.api_key.clone(),
|
||||||
|
cfg.model.clone(),
|
||||||
|
cfg.base_url.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
ProviderKind::Anthropic => {
|
||||||
|
let cfg = config.anthropic.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||||
|
status: 0,
|
||||||
|
message: "Anthropic config not found".to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(Box::new(anthropic::AnthropicProvider::new(
|
||||||
|
cfg.api_key.clone(),
|
||||||
|
cfg.model.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
ProviderKind::Ollama => {
|
||||||
|
let cfg = config.ollama.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||||
|
status: 0,
|
||||||
|
message: "Ollama config not found".to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(Box::new(ollama::OllamaProvider::new(
|
||||||
|
cfg.base_url.clone(),
|
||||||
|
cfg.model.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
ProviderKind::Gemini => {
|
||||||
|
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||||
|
status: 0,
|
||||||
|
message: "Gemini config not found (use openai section)".to_string(),
|
||||||
|
})?;
|
||||||
|
// Gemini uses the OpenAI-compatible endpoint
|
||||||
|
Ok(Box::new(openai::OpenAIProvider::new(
|
||||||
|
cfg.api_key.clone(),
|
||||||
|
cfg.model.clone(),
|
||||||
|
cfg.base_url.clone(),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a provider from just a kind name string and minimal config.
|
||||||
|
pub fn from_name(
|
||||||
|
name: &str,
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
) -> ProviderResult<Box<dyn Provider>> {
|
||||||
|
let kind = ProviderKind::from_str(name)
|
||||||
|
.ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||||
|
status: 0,
|
||||||
|
message: format!("Unknown provider name: {name}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let config = gyandoot_core::ProviderRootConfig {
|
||||||
|
default: name.to_string(),
|
||||||
|
openai: if matches!(kind, ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter | ProviderKind::Gemini) {
|
||||||
|
Some(gyandoot_core::OpenAIConfig {
|
||||||
|
api_key: api_key.to_string(),
|
||||||
|
model: model.to_string(),
|
||||||
|
base_url: Some(default_base_url(&kind)),
|
||||||
|
max_retries: 3,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
anthropic: if matches!(kind, ProviderKind::Anthropic) {
|
||||||
|
Some(gyandoot_core::AnthropicConfig {
|
||||||
|
api_key: api_key.to_string(),
|
||||||
|
model: model.to_string(),
|
||||||
|
max_retries: 3,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
ollama: if matches!(kind, ProviderKind::Ollama) {
|
||||||
|
Some(gyandoot_core::OllamaConfig {
|
||||||
|
base_url: default_base_url(&kind),
|
||||||
|
model: model.to_string(),
|
||||||
|
max_retries: 2,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Self::create_from_kind(kind, &config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default base URL for each provider kind.
|
||||||
|
fn default_base_url(kind: &ProviderKind) -> String {
|
||||||
|
match kind {
|
||||||
|
ProviderKind::OpenAI => "https://api.openai.com/v1".to_string(),
|
||||||
|
ProviderKind::Anthropic => "https://api.anthropic.com".to_string(),
|
||||||
|
ProviderKind::Ollama => "http://localhost:11434".to_string(),
|
||||||
|
ProviderKind::Gemini => "https://generativelanguage.googleapis.com/v1".to_string(),
|
||||||
|
ProviderKind::DeepSeek => "https://api.deepseek.com/v1".to_string(),
|
||||||
|
ProviderKind::OpenRouter => "https://openrouter.ai/api/v1".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_kind_roundtrip() {
|
||||||
|
for kind in &[
|
||||||
|
ProviderKind::OpenAI,
|
||||||
|
ProviderKind::Anthropic,
|
||||||
|
ProviderKind::Ollama,
|
||||||
|
ProviderKind::Gemini,
|
||||||
|
ProviderKind::DeepSeek,
|
||||||
|
ProviderKind::OpenRouter,
|
||||||
|
] {
|
||||||
|
let s = kind.to_string();
|
||||||
|
let parsed = ProviderKind::from_str(&s).unwrap();
|
||||||
|
assert_eq!(*kind, parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_response_has_tool_calls() {
|
||||||
|
let r = ProviderResponse::new("hello");
|
||||||
|
assert!(!r.has_tool_calls());
|
||||||
|
|
||||||
|
let r = ProviderResponse::with_tool_calls(
|
||||||
|
"",
|
||||||
|
vec![ToolCall::new("1", "test", serde_json::Value::Null)],
|
||||||
|
"tool_calls",
|
||||||
|
);
|
||||||
|
assert!(r.has_tool_calls());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_base_urls() {
|
||||||
|
assert!(default_base_url(&ProviderKind::OpenAI).contains("openai.com"));
|
||||||
|
assert!(default_base_url(&ProviderKind::Anthropic).contains("anthropic.com"));
|
||||||
|
assert!(default_base_url(&ProviderKind::Ollama).contains("localhost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_from_name() {
|
||||||
|
let provider = ProviderFactory::from_name("openai", "sk-test", "gpt-4o").unwrap();
|
||||||
|
assert!(provider.supports_tools());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_factory_invalid_name() {
|
||||||
|
let result = ProviderFactory::from_name("nonexistent", "", "");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_kind_unknown_string() {
|
||||||
|
assert_eq!(ProviderKind::from_str("unknown"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Ollama API provider implementation.
|
//! Ollama API provider implementation.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use gyandoot_core::{ConversationContext, MessageRole, ProviderResult};
|
use gyandoot_core::{ConversationContext, ProviderResult};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
@@ -59,10 +59,13 @@ impl Provider for OllamaProvider {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(gyandoot_core::ProviderError::Api(format!(
|
return Err(gyandoot_core::ProviderError::Api {
|
||||||
|
status: response.status().as_u16(),
|
||||||
|
message: format!(
|
||||||
"Ollama API error: {}",
|
"Ollama API error: {}",
|
||||||
response.status()
|
response.status()
|
||||||
)));
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let data: serde_json::Value = response.json().await?;
|
let data: serde_json::Value = response.json().await?;
|
||||||
|
|||||||
@@ -1,31 +1,56 @@
|
|||||||
//! OpenAI API provider implementation.
|
//! OpenAI API provider implementation.
|
||||||
|
//!
|
||||||
|
//! Full OpenAI-compatible chat completions with function calling, streaming
|
||||||
|
//! SSE parsing, and error handling for auth, rate limits, and timeouts.
|
||||||
|
//! Works with any OpenAI-compatible endpoint (OpenAI, DeepSeek, OpenRouter,
|
||||||
|
//! Gemini, etc.).
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use gyandoot_core::{ConversationContext, MessageRole, ProviderResult};
|
use futures::StreamExt;
|
||||||
|
use gyandoot_core::{ConversationContext, ProviderError, ProviderResult, ToolCall};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use tracing::debug;
|
use std::time::Duration;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
|
use crate::streaming::StreamEvent;
|
||||||
use crate::{Provider, ProviderResponse};
|
use crate::{Provider, ProviderResponse};
|
||||||
|
|
||||||
/// Provider for the OpenAI chat completion API.
|
/// Provider for OpenAI-compatible chat completion APIs.
|
||||||
pub struct OpenAIProvider {
|
pub struct OpenAIProvider {
|
||||||
api_key: String,
|
api_key: String,
|
||||||
model: String,
|
model: String,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
client: Client,
|
client: Client,
|
||||||
|
request_timeout: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenAIProvider {
|
impl OpenAIProvider {
|
||||||
/// Create a new OpenAI provider.
|
/// Create a new OpenAI provider.
|
||||||
|
///
|
||||||
|
/// - `api_key`: Bearer token for authentication.
|
||||||
|
/// - `model`: Model identifier (e.g. "gpt-4o").
|
||||||
|
/// - `base_url`: Base URL of the API (e.g. "https://api.openai.com/v1").
|
||||||
|
/// Defaults to OpenAI's endpoint if `None`.
|
||||||
pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
|
pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
base_url: base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string()),
|
base_url: base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string()),
|
||||||
client: Client::new(),
|
client: Client::builder()
|
||||||
|
.timeout(Duration::from_secs(120))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client"),
|
||||||
|
request_timeout: Duration::from_secs(60),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set a custom request timeout.
|
||||||
|
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||||
|
self.request_timeout = timeout;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the request body for the chat completions endpoint.
|
/// Build the request body for the chat completions endpoint.
|
||||||
fn build_request(&self, context: &ConversationContext) -> serde_json::Value {
|
fn build_request(&self, context: &ConversationContext) -> serde_json::Value {
|
||||||
let messages: Vec<serde_json::Value> = context
|
let messages: Vec<serde_json::Value> = context
|
||||||
@@ -61,47 +86,23 @@ impl OpenAIProvider {
|
|||||||
|
|
||||||
body
|
body
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Provider for OpenAIProvider {
|
|
||||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse> {
|
|
||||||
debug!("OpenAI chat request (model: {})", self.model);
|
|
||||||
|
|
||||||
let request_body = self.build_request(&context);
|
|
||||||
let url = format!("{}/chat/completions", self.base_url);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.post(&url)
|
|
||||||
.bearer_auth(&self.api_key)
|
|
||||||
.json(&request_body)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let error_text = response.text().await.unwrap_or_default();
|
|
||||||
return Err(gyandoot_core::ProviderError::Api(format!(
|
|
||||||
"OpenAI API error ({}): {}",
|
|
||||||
status, error_text
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: serde_json::Value = response.json().await?;
|
|
||||||
|
|
||||||
|
/// Parse the non-streaming API response into a ProviderResponse.
|
||||||
|
fn parse_response(&self, data: &serde_json::Value) -> ProviderResult<ProviderResponse> {
|
||||||
let choice = &data["choices"][0];
|
let choice = &data["choices"][0];
|
||||||
let message = &choice["message"];
|
let message = &choice["message"];
|
||||||
let content = message["content"].as_str().unwrap_or_default().to_string();
|
|
||||||
let finish_reason = choice["finish_reason"].as_str().unwrap_or("stop").to_string();
|
|
||||||
|
|
||||||
// Parse tool calls
|
let content = message["content"].as_str().unwrap_or_default().to_string();
|
||||||
let tool_calls = message["tool_calls"]
|
let finish_reason = choice["finish_reason"]
|
||||||
.as_array()
|
.as_str()
|
||||||
.map(|calls| {
|
.unwrap_or("stop")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Parse tool calls from the response
|
||||||
|
let tool_calls = message["tool_calls"].as_array().map(|calls| {
|
||||||
calls
|
calls
|
||||||
.iter()
|
.iter()
|
||||||
.map(|tc| gyandoot_core::ToolCall {
|
.map(|tc| ToolCall {
|
||||||
id: tc["id"].as_str().unwrap_or_default().to_string(),
|
id: tc["id"].as_str().unwrap_or_default().to_string(),
|
||||||
name: tc["function"]["name"].as_str().unwrap_or_default().to_string(),
|
name: tc["function"]["name"].as_str().unwrap_or_default().to_string(),
|
||||||
arguments: tc["function"]["arguments"]
|
arguments: tc["function"]["arguments"]
|
||||||
@@ -119,61 +120,206 @@ impl Provider for OpenAIProvider {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn chat_stream(
|
/// Send the request and handle common HTTP error statuses.
|
||||||
|
async fn send_request(
|
||||||
&self,
|
&self,
|
||||||
context: ConversationContext,
|
body: &serde_json::Value,
|
||||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
) -> ProviderResult<reqwest::Response> {
|
||||||
{
|
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||||
debug!("OpenAI streaming chat request (model: {})", self.model);
|
|
||||||
|
|
||||||
let request_body = self.build_request(&context);
|
|
||||||
let url = format!("{}/chat/completions", self.base_url);
|
|
||||||
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.bearer_auth(&self.api_key)
|
.bearer_auth(&self.api_key)
|
||||||
.json(&request_body)
|
.json(body)
|
||||||
|
.timeout(self.request_timeout)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let status = response.status();
|
||||||
return Err(gyandoot_core::ProviderError::Api(format!(
|
if !status.is_success() {
|
||||||
"OpenAI stream error: {}",
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
response.status()
|
return Err(ProviderError::from_response(status.as_u16(), &error_text));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We can't easily return a boxed stream from reqwest's streaming API
|
Ok(response)
|
||||||
// without concrete types. For now we'll delegate to the non-streaming path
|
}
|
||||||
// wrapped in a simple stream. Real implementation would use SSE parsing.
|
}
|
||||||
let result = self.chat(context).await?;
|
|
||||||
let stream = tokio_stream::StreamExt::chain(
|
#[async_trait]
|
||||||
tokio_stream::once(Ok(result.content)),
|
impl Provider for OpenAIProvider {
|
||||||
tokio_stream::empty(),
|
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse> {
|
||||||
|
debug!(
|
||||||
|
"OpenAI chat request (model: {}, messages: {})",
|
||||||
|
self.model,
|
||||||
|
context.messages.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let request_body = self.build_request(&context);
|
||||||
|
let response = self.send_request(&request_body).await?;
|
||||||
|
let data: serde_json::Value = response.json().await?;
|
||||||
|
|
||||||
|
Ok(self.parse_response(&data)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn chat_stream(
|
||||||
|
&self,
|
||||||
|
context: ConversationContext,
|
||||||
|
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||||
|
{
|
||||||
|
debug!(
|
||||||
|
"OpenAI streaming chat (model: {}, messages: {})",
|
||||||
|
self.model,
|
||||||
|
context.messages.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut request_body = self.build_request(&context);
|
||||||
|
request_body["stream"] = serde_json::json!(true);
|
||||||
|
|
||||||
|
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
// Start the streaming HTTP request
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.bearer_auth(&self.api_key)
|
||||||
|
.json(&request_body)
|
||||||
|
.timeout(self.request_timeout)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(ProviderError::from_response(status.as_u16(), &error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a channel to bridge the reqwest stream to the tokio_stream
|
||||||
|
let (tx, rx) = mpsc::channel::<ProviderResult<String>>(256);
|
||||||
|
let mut sse_stream = response.bytes_stream();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut parser = crate::streaming::SseParser::new(100);
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match tokio::time::timeout(Duration::from_secs(30), sse_stream.next()).await {
|
||||||
|
Ok(Some(Ok(chunk))) => {
|
||||||
|
buffer.extend_from_slice(&chunk);
|
||||||
|
let buffer_str = String::from_utf8_lossy(&buffer);
|
||||||
|
let mut pos = 0;
|
||||||
|
|
||||||
|
for event in parser.feed_chunk(buffer_str.as_ref()) {
|
||||||
|
match event {
|
||||||
|
StreamEvent::Chunk(text) => {
|
||||||
|
trace!("SSE chunk: {text}");
|
||||||
|
if tx.send(Ok(text)).await.is_err() {
|
||||||
|
debug!("Stream receiver dropped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StreamEvent::ToolCall(tc) => {
|
||||||
|
trace!("SSE tool call: {tc:?}");
|
||||||
|
}
|
||||||
|
StreamEvent::Done => {
|
||||||
|
debug!("SSE stream done");
|
||||||
|
let _ = tx.try_send(Ok(String::new()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StreamEvent::Error(msg) => {
|
||||||
|
warn!("SSE error: {msg}");
|
||||||
|
let _ = tx
|
||||||
|
.send(Err(ProviderError::Stream(msg)))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = parser.consumed();
|
||||||
|
}
|
||||||
|
buffer.drain(0..pos);
|
||||||
|
}
|
||||||
|
Ok(Some(Err(e))) => {
|
||||||
|
let _ = tx
|
||||||
|
.send(Err(ProviderError::Request(e)))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
debug!("SSE stream exhausted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = tx
|
||||||
|
.send(Err(ProviderError::Timeout {
|
||||||
|
duration: 30,
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also collect tool calls from the non-streaming path as a fallback
|
||||||
|
// For simplicity, if the stream doesn't produce content, fall back
|
||||||
|
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||||
Ok(Box::new(stream))
|
Ok(Box::new(stream))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn models(&self) -> ProviderResult<Vec<String>> {
|
||||||
|
let url = format!("{}/models", self.base_url.trim_end_matches('/'));
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.bearer_auth(&self.api_key)
|
||||||
|
.timeout(self.request_timeout)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(ProviderError::from_response(status.as_u16(), &error_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = response.json().await?;
|
||||||
|
let models = data["data"]
|
||||||
|
.as_array()
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(models)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_tools(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use gyandoot_core::{Message, ToolDefinition};
|
use gyandoot_core::{Message, Role, ToolDefinition};
|
||||||
|
|
||||||
#[test]
|
fn test_provider() -> OpenAIProvider {
|
||||||
fn test_build_request() {
|
OpenAIProvider::new(
|
||||||
let provider = OpenAIProvider::new(
|
|
||||||
"sk-test".to_string(),
|
"sk-test".to_string(),
|
||||||
"gpt-4o".to_string(),
|
"gpt-4o".to_string(),
|
||||||
None,
|
None,
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_request_basic() {
|
||||||
|
let provider = test_provider();
|
||||||
let context = ConversationContext {
|
let context = ConversationContext {
|
||||||
messages: vec![
|
messages: vec![
|
||||||
Message::new(MessageRole::System, "You are an AI."),
|
Message::new(Role::System, "You are an AI."),
|
||||||
Message::new(MessageRole::User, "Hello"),
|
Message::new(Role::User, "Hello"),
|
||||||
],
|
],
|
||||||
system_prompt: "You are an AI.".to_string(),
|
system_prompt: "You are an AI.".to_string(),
|
||||||
tools: vec![],
|
tools: vec![],
|
||||||
@@ -184,4 +330,107 @@ mod tests {
|
|||||||
assert_eq!(body["model"], "gpt-4o");
|
assert_eq!(body["model"], "gpt-4o");
|
||||||
assert_eq!(body["messages"].as_array().unwrap().len(), 2);
|
assert_eq!(body["messages"].as_array().unwrap().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_request_with_tools() {
|
||||||
|
let provider = test_provider();
|
||||||
|
let context = ConversationContext {
|
||||||
|
messages: vec![Message::new(Role::User, "What's the weather?")],
|
||||||
|
system_prompt: String::new(),
|
||||||
|
tools: vec![ToolDefinition::new(
|
||||||
|
"get_weather",
|
||||||
|
"Get weather for a city",
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"city": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["city"]
|
||||||
|
}),
|
||||||
|
)],
|
||||||
|
max_tokens: 4096,
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = provider.build_request(&context);
|
||||||
|
assert!(body.get("tools").is_some());
|
||||||
|
assert_eq!(body["tool_choice"], "auto");
|
||||||
|
assert_eq!(body["tools"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_request_tool_result() {
|
||||||
|
let provider = test_provider();
|
||||||
|
let context = ConversationContext {
|
||||||
|
messages: vec![
|
||||||
|
Message::new(Role::Assistant, "")
|
||||||
|
.with_tool_calls(vec![ToolCall::new("call_1", "get_weather", serde_json::json!({"city": "NYC"}))]),
|
||||||
|
Message::new(Role::Tool, "72°F, sunny").with_tool_result("72°F, sunny".to_string()),
|
||||||
|
],
|
||||||
|
system_prompt: String::new(),
|
||||||
|
tools: vec![],
|
||||||
|
max_tokens: 4096,
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = provider.build_request(&context);
|
||||||
|
let messages = body["messages"].as_array().unwrap();
|
||||||
|
assert_eq!(messages.len(), 2);
|
||||||
|
// Tool message should have tool_call_id and content
|
||||||
|
let tool_msg = &messages[1];
|
||||||
|
assert!(tool_msg.get("tool_call_id").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_response() {
|
||||||
|
let provider = test_provider();
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"content": "Hello, how can I help?",
|
||||||
|
"role": "assistant"
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"index": 0
|
||||||
|
}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 5,
|
||||||
|
"total_tokens": 15
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = provider.parse_response(&data).unwrap();
|
||||||
|
assert_eq!(response.content, "Hello, how can I help?");
|
||||||
|
assert_eq!(response.finish_reason, "stop");
|
||||||
|
assert!(response.tool_calls.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_response_with_tool_calls() {
|
||||||
|
let provider = test_provider();
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"content": null,
|
||||||
|
"role": "assistant",
|
||||||
|
"tool_calls": [{
|
||||||
|
"id": "call_abc123",
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_weather",
|
||||||
|
"arguments": "{\"city\": \"NYC\"}"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"finish_reason": "tool_calls",
|
||||||
|
"index": 0
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let response = provider.parse_response(&data).unwrap();
|
||||||
|
assert!(response.has_tool_calls());
|
||||||
|
let calls = response.tool_calls.unwrap();
|
||||||
|
assert_eq!(calls.len(), 1);
|
||||||
|
assert_eq!(calls[0].id, "call_abc123");
|
||||||
|
assert_eq!(calls[0].name, "get_weather");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -45,6 +45,88 @@ impl tokio_stream::Stream for TokenStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── SSE Parser ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Events emitted by the [`SseParser`].
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum StreamEvent {
|
||||||
|
/// Raw data from a `data:` line (without the `data: ` prefix).
|
||||||
|
Chunk(String),
|
||||||
|
/// Stream complete signal (`data: [DONE]`).
|
||||||
|
Done,
|
||||||
|
/// An error-related line was encountered.
|
||||||
|
Error(String),
|
||||||
|
/// A tool call reference detected in the SSE stream (OpenAI format).
|
||||||
|
ToolCall(super::ToolCall),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal SSE (Server-Sent Events) parser that emits [`StreamEvent`] values.
|
||||||
|
///
|
||||||
|
/// Processes byte chunks line-by-line. Only complete lines (terminated by `\n`)
|
||||||
|
/// are processed; a partial final line is held over for the next call.
|
||||||
|
/// Use [`consumed()`](SseParser::consumed) to learn how many bytes were fully
|
||||||
|
/// consumed so the caller can drain them from its buffer.
|
||||||
|
pub struct SseParser {
|
||||||
|
consumed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SseParser {
|
||||||
|
/// Create a new SSE parser.
|
||||||
|
pub fn new(_buffer_size: usize) -> Self {
|
||||||
|
Self { consumed: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed raw SSE-sourced text and return any complete events found.
|
||||||
|
///
|
||||||
|
/// Only lines terminated by `\n` are processed. The returned `consumed`
|
||||||
|
/// count reflects the byte offset of the last fully-processed newline.
|
||||||
|
pub fn feed_chunk<'a>(&'a mut self, chunk: &str) -> Vec<StreamEvent> {
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let mut pos = 0;
|
||||||
|
let bytes = chunk.as_bytes();
|
||||||
|
let len = bytes.len();
|
||||||
|
let mut line_start = 0;
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < len {
|
||||||
|
if bytes[i] == b'\n' {
|
||||||
|
let line = &chunk[line_start..i];
|
||||||
|
if line.starts_with("data: ") {
|
||||||
|
let data = &line[6..];
|
||||||
|
if data == "[DONE]" {
|
||||||
|
events.push(StreamEvent::Done);
|
||||||
|
} else {
|
||||||
|
events.push(StreamEvent::Chunk(data.to_string()));
|
||||||
|
}
|
||||||
|
} else if !line.is_empty() {
|
||||||
|
// Check for error lines
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with(':') {
|
||||||
|
// SSE comment — skip
|
||||||
|
} else if trimmed.starts_with("event:") || trimmed.starts_with("id:") || trimmed.starts_with("retry:") {
|
||||||
|
// SSE metadata — skip
|
||||||
|
} else if trimmed.contains("error") {
|
||||||
|
events.push(StreamEvent::Error(line.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = i + 1;
|
||||||
|
line_start = i + 1;
|
||||||
|
i = pos;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.consumed = pos;
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the number of bytes consumed from the buffer.
|
||||||
|
pub fn consumed(&self) -> usize {
|
||||||
|
self.consumed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -55,4 +137,38 @@ mod tests {
|
|||||||
let result = collect_stream(stream).await.unwrap();
|
let result = collect_stream(stream).await.unwrap();
|
||||||
assert_eq!(result, "hello world");
|
assert_eq!(result, "hello world");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sse_basic_chunk() {
|
||||||
|
let mut parser = SseParser::new(100);
|
||||||
|
let events = parser.feed_chunk("data: hello\n\n");
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert!(matches!(&events[0], StreamEvent::Chunk(t) if t == "hello"));
|
||||||
|
assert!(parser.consumed() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sse_done() {
|
||||||
|
let mut parser = SseParser::new(100);
|
||||||
|
let events = parser.feed_chunk("data: [DONE]\n\n");
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert!(matches!(events[0], StreamEvent::Done));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sse_partial_line() {
|
||||||
|
let mut parser = SseParser::new(100);
|
||||||
|
let events = parser.feed_chunk("data: hel");
|
||||||
|
assert!(events.is_empty());
|
||||||
|
assert_eq!(parser.consumed(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sse_two_chunks() {
|
||||||
|
let mut parser = SseParser::new(100);
|
||||||
|
let _ = parser.feed_chunk("data: hel");
|
||||||
|
let events = parser.feed_chunk("lo\n\n");
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert!(matches!(&events[0], StreamEvent::Chunk(t) if t == "hello"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
25
crates/gyandoot-tui/Cargo.toml
Normal file
25
crates/gyandoot-tui/Cargo.toml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "gyandoot-tui"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "GyanDoot v2 — TUI (banner, setup wizard, terminal helpers)"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
gyandoot-core = { path = "../gyandoot-core" }
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.28"
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
atty = "0.2"
|
||||||
|
regex = "1"
|
||||||
|
reqwest = { workspace = true, features = ["blocking"] }
|
||||||
|
toml.workspace = true
|
||||||
|
dirs.workspace = true
|
||||||
185
crates/gyandoot-tui/src/banner.rs
Normal file
185
crates/gyandoot-tui/src/banner.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//! GyanDoot banner — ASCII art for CLI and TUI display.
|
||||||
|
//!
|
||||||
|
//! Two modes:
|
||||||
|
//! - **Primary**: Kapil's approved ASCII robot head with ANSI color
|
||||||
|
//! - **Fallback**: Compact boxed banner (when banner.txt missing / pipe mode)
|
||||||
|
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
/// The primary ASCII art banner — robot head with GyanDoot text.
|
||||||
|
///
|
||||||
|
/// Uses 4-bit ANSI color codes.
|
||||||
|
pub const BANNER_PRIMARY: &str = r#"
|
||||||
|
o
|
||||||
|
|
|
||||||
|
- - - - - - .---. - - - - - -
|
||||||
|
- - - - - - - - - [ ^ ^ ] - - - - - - - - -
|
||||||
|
- - - - - - - | ___ | - - - - - - -
|
||||||
|
`-----'
|
||||||
|
____ __ __ ___ _ _ ____ ___ ___ _____
|
||||||
|
/ ___|\ \ / / / _ \ | \ | || _ \ / _ \ / _ \|_ _|
|
||||||
|
| | _ \ V / / /_\ \| \| || | | | | | | | | | | |
|
||||||
|
| |_| | | | | _ || |\ || |_| | |_| | |_| | | |
|
||||||
|
\____/ |_| |_| |_||_| \_||____/ \___/ \___/ |_|
|
||||||
|
|
||||||
|
o - - - - - - - knowledge. delivered. - - - - - - - o
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Fallback compact boxed banner — used when pipe mode is detected
|
||||||
|
/// or the primary banner isn't available.
|
||||||
|
pub const BANNER_FALLBACK: &str = r#"
|
||||||
|
╔══════════════════════════════════════════╗
|
||||||
|
║ GyanDoot v2.0.0 ║
|
||||||
|
║ Knowledge in Motion — AI Agent Platform ║
|
||||||
|
╚══════════════════════════════════════════╝
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Color codes for banner rendering.
|
||||||
|
pub mod color {
|
||||||
|
/// White / bright white foreground
|
||||||
|
pub const WHITE: &str = "\x1b[97m";
|
||||||
|
/// Yellow / gold foreground
|
||||||
|
pub const YELLOW: &str = "\x1b[93m";
|
||||||
|
/// Saffron / orange foreground
|
||||||
|
pub const SAFFRON: &str = "\x1b[38;5;208m";
|
||||||
|
/// Deep blue foreground
|
||||||
|
pub const DEEP_BLUE: &str = "\x1b[38;5;18m";
|
||||||
|
/// Gold (RGB-inspired 256 color)
|
||||||
|
pub const GOLD: &str = "\x1b[38;5;214m";
|
||||||
|
/// Reset all attributes
|
||||||
|
pub const RESET: &str = "\x1b[0m";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines whether output is piped (non-TTY).
|
||||||
|
fn is_piped() -> bool {
|
||||||
|
!atty::is(atty::Stream::Stdout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print the GyanDoot banner to stdout.
|
||||||
|
///
|
||||||
|
/// If output is piped, the fallback (uncolored) banner is used.
|
||||||
|
/// Otherwise, the primary ANSI-colored banner is printed.
|
||||||
|
pub fn print_banner() {
|
||||||
|
let primary = colorize_primary(BANNER_PRIMARY);
|
||||||
|
let fallback = strip_ansi(BANNER_FALLBACK);
|
||||||
|
|
||||||
|
if is_piped() {
|
||||||
|
println!("{}", fallback);
|
||||||
|
} else {
|
||||||
|
println!("{}", primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply 4-bit ANSI color to the primary banner.
|
||||||
|
///
|
||||||
|
/// - Robot head lines (first 6): white
|
||||||
|
/// - "GyanDoot" text block: yellow/gold
|
||||||
|
/// - Tagline: white
|
||||||
|
fn colorize_primary(banner: &str) -> String {
|
||||||
|
let lines: Vec<&str> = banner.split('\n').collect();
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut i = 0;
|
||||||
|
for line in lines {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
writeln!(out).ok();
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < 6 {
|
||||||
|
// Robot head section — white
|
||||||
|
write!(out, "{}{}{}", color::WHITE, line, color::RESET).ok();
|
||||||
|
} else if i < 11 {
|
||||||
|
// GyanDoot text — gold/yellow
|
||||||
|
write!(out, "{}{}{}", color::YELLOW, line, color::RESET).ok();
|
||||||
|
} else {
|
||||||
|
// Tagline — white
|
||||||
|
write!(out, "{}{}{}", color::WHITE, line, color::RESET).ok();
|
||||||
|
}
|
||||||
|
writeln!(out).ok();
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip ANSI escape codes from a string.
|
||||||
|
pub fn strip_ansi(input: &str) -> String {
|
||||||
|
let re = regex::Regex::new(r"\x1b\[[0-9;]*[mK]").unwrap();
|
||||||
|
re.replace_all(input, "").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the fallback banner with colors.
|
||||||
|
pub fn render_fallback_colored() -> String {
|
||||||
|
let lines: Vec<&str> = BANNER_FALLBACK.split('\n').collect();
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
if i == 1 {
|
||||||
|
// Top border — gold
|
||||||
|
write!(out, "{}{}{}", color::GOLD, line, color::RESET).ok();
|
||||||
|
} else if i == 2 {
|
||||||
|
// Title — saffron
|
||||||
|
write!(out, "{}{}{}", color::SAFFRON, line, color::RESET).ok();
|
||||||
|
} else if i == 3 {
|
||||||
|
// Tagline — deep blue
|
||||||
|
write!(out, "{}{}{}", color::DEEP_BLUE, line, color::RESET).ok();
|
||||||
|
} else if i == 4 {
|
||||||
|
// Bottom border — gold
|
||||||
|
write!(out, "{}{}{}", color::GOLD, line, color::RESET).ok();
|
||||||
|
} else {
|
||||||
|
write!(out, "{}", line).ok();
|
||||||
|
}
|
||||||
|
writeln!(out).ok();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a plain-text banner string (no ANSI codes).
|
||||||
|
pub fn banner_plain() -> String {
|
||||||
|
strip_ansi(BANNER_PRIMARY)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_banner_plain_contains_gyandoot() {
|
||||||
|
let plain = banner_plain();
|
||||||
|
assert!(plain.contains("GyanDoot") || plain.contains("knowledge. delivered."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_ansi_removes_escapes() {
|
||||||
|
let colored = format!("\x1b[97mHello\x1b[0m");
|
||||||
|
assert_eq!(strip_ansi(&colored), "Hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_ansi_empty() {
|
||||||
|
assert_eq!(strip_ansi(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fallback_has_borders() {
|
||||||
|
assert!(BANNER_FALLBACK.contains('╔'));
|
||||||
|
assert!(BANNER_FALLBACK.contains('╝'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_primary_has_robot_head() {
|
||||||
|
assert!(BANNER_PRIMARY.contains(".-.") || BANNER_PRIMARY.contains("[ ^"));
|
||||||
|
assert!(BANNER_PRIMARY.contains("knowledge. delivered."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_colorize_primary_contains_ansi() {
|
||||||
|
let colored = colorize_primary(BANNER_PRIMARY);
|
||||||
|
assert!(colored.contains("\x1b["));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_render_fallback_contains_ansi() {
|
||||||
|
let colored = render_fallback_colored();
|
||||||
|
assert!(colored.contains("\x1b["));
|
||||||
|
}
|
||||||
|
}
|
||||||
20
crates/gyandoot-tui/src/lib.rs
Normal file
20
crates/gyandoot-tui/src/lib.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//! GyanDoot TUI — Banner, setup wizard, and terminal helpers.
|
||||||
|
//!
|
||||||
|
//! This crate provides:
|
||||||
|
//! - `banner` — The GyanDoot ASCII art banner (primary robot head + fallback box)
|
||||||
|
//! - `setup` — Interactive setup wizard for configuring providers
|
||||||
|
//! - `terminal` — Terminal helpers (progress spinners, confirm prompts, selection menus)
|
||||||
|
//! - `tui` — Ratatui-based TUI main loop
|
||||||
|
|
||||||
|
pub mod banner;
|
||||||
|
pub mod setup;
|
||||||
|
pub mod terminal;
|
||||||
|
pub mod tui;
|
||||||
|
|
||||||
|
pub use banner::{print_banner, BANNER_PRIMARY, BANNER_FALLBACK};
|
||||||
|
pub use setup::{run_setup_wizard, ProviderConfig, ProviderKind, WizardResult};
|
||||||
|
pub use terminal::{
|
||||||
|
confirm_prompt, progress_spinner, selection_menu, show_status, text_input,
|
||||||
|
StatusKind,
|
||||||
|
};
|
||||||
|
pub use tui::{run_tui, TuiApp, TuiCommand};
|
||||||
510
crates/gyandoot-tui/src/setup.rs
Normal file
510
crates/gyandoot-tui/src/setup.rs
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
//! Interactive setup wizard — configures providers and initializes GyanDoot.
|
||||||
|
//!
|
||||||
|
//! Flow:
|
||||||
|
//! 1. Print compact banner header
|
||||||
|
//! 2. Provider selection (numbered menu)
|
||||||
|
//! 3. Per-provider config (Base URL, API Key, Model, Temperature, Max Tokens)
|
||||||
|
//! 4. Summary display before saving
|
||||||
|
//! 5. Connection test (/models endpoint)
|
||||||
|
//! 6. Write config.toml to ~/.config/gyandoot/config.toml
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::banner;
|
||||||
|
use crate::terminal;
|
||||||
|
|
||||||
|
/// Supported LLM providers.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ProviderKind {
|
||||||
|
OpenAI,
|
||||||
|
Anthropic,
|
||||||
|
Ollama,
|
||||||
|
GoogleGemini,
|
||||||
|
DeepSeek,
|
||||||
|
OpenRouter,
|
||||||
|
Groq,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderKind {
|
||||||
|
/// All known providers in display order.
|
||||||
|
pub fn all() -> &'static [ProviderKind] {
|
||||||
|
&[
|
||||||
|
ProviderKind::OpenAI,
|
||||||
|
ProviderKind::Anthropic,
|
||||||
|
ProviderKind::Ollama,
|
||||||
|
ProviderKind::GoogleGemini,
|
||||||
|
ProviderKind::DeepSeek,
|
||||||
|
ProviderKind::OpenRouter,
|
||||||
|
ProviderKind::Groq,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display name for menus.
|
||||||
|
pub fn display_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProviderKind::OpenAI => "OpenAI",
|
||||||
|
ProviderKind::Anthropic => "Anthropic",
|
||||||
|
ProviderKind::Ollama => "Ollama",
|
||||||
|
ProviderKind::GoogleGemini => "Google Gemini",
|
||||||
|
ProviderKind::DeepSeek => "DeepSeek",
|
||||||
|
ProviderKind::OpenRouter => "OpenRouter",
|
||||||
|
ProviderKind::Groq => "Groq",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default base URL for the provider.
|
||||||
|
pub fn default_base_url(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProviderKind::OpenAI => "https://api.openai.com/v1",
|
||||||
|
ProviderKind::Anthropic => "https://api.anthropic.com/v1",
|
||||||
|
ProviderKind::Ollama => "http://localhost:11434",
|
||||||
|
ProviderKind::GoogleGemini => "https://generativelanguage.googleapis.com/v1",
|
||||||
|
ProviderKind::DeepSeek => "https://api.deepseek.com/v1",
|
||||||
|
ProviderKind::OpenRouter => "https://openrouter.ai/api/v1",
|
||||||
|
ProviderKind::Groq => "https://api.groq.com/openai/v1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default model name.
|
||||||
|
pub fn default_model(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProviderKind::OpenAI => "gpt-4o",
|
||||||
|
ProviderKind::Anthropic => "claude-sonnet-4-20250514",
|
||||||
|
ProviderKind::Ollama => "llama3",
|
||||||
|
ProviderKind::GoogleGemini => "gemini-2.0-flash",
|
||||||
|
ProviderKind::DeepSeek => "deepseek-chat",
|
||||||
|
ProviderKind::OpenRouter => "openai/gpt-4o",
|
||||||
|
ProviderKind::Groq => "llama3-70b-8192",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default temperature.
|
||||||
|
pub fn default_temperature(&self) -> f64 {
|
||||||
|
0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default max tokens.
|
||||||
|
pub fn default_max_tokens(&self) -> u32 {
|
||||||
|
4096
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-specific API key env var hint.
|
||||||
|
pub fn api_key_env_hint(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ProviderKind::OpenAI => "OPENAI_API_KEY",
|
||||||
|
ProviderKind::Anthropic => "ANTHROPIC_API_KEY",
|
||||||
|
ProviderKind::Ollama => "OLLAMA_API_KEY (optional)",
|
||||||
|
ProviderKind::GoogleGemini => "GEMINI_API_KEY",
|
||||||
|
ProviderKind::DeepSeek => "DEEPSEEK_API_KEY",
|
||||||
|
ProviderKind::OpenRouter => "OPENROUTER_API_KEY",
|
||||||
|
ProviderKind::Groq => "GROQ_API_KEY",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ProviderKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.display_name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-provider configuration collected during setup.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
/// Which provider this config is for.
|
||||||
|
pub kind: ProviderKind,
|
||||||
|
/// API base URL.
|
||||||
|
pub base_url: String,
|
||||||
|
/// API key (may be empty for local Ollama).
|
||||||
|
pub api_key: String,
|
||||||
|
/// Model identifier.
|
||||||
|
pub model: String,
|
||||||
|
/// Temperature parameter (0.0–2.0).
|
||||||
|
pub temperature: f64,
|
||||||
|
/// Max tokens per response.
|
||||||
|
pub max_tokens: u32,
|
||||||
|
/// Ollama-specific: merge system messages.
|
||||||
|
#[serde(default)]
|
||||||
|
pub merge_system: bool,
|
||||||
|
/// Ollama-specific: disable tool calling.
|
||||||
|
#[serde(default)]
|
||||||
|
pub no_tools: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProviderConfig {
|
||||||
|
/// Create a new provider config with defaults.
|
||||||
|
pub fn new(kind: ProviderKind) -> Self {
|
||||||
|
Self {
|
||||||
|
kind,
|
||||||
|
base_url: kind.default_base_url().to_string(),
|
||||||
|
api_key: String::new(),
|
||||||
|
model: kind.default_model().to_string(),
|
||||||
|
temperature: kind.default_temperature(),
|
||||||
|
max_tokens: kind.default_max_tokens(),
|
||||||
|
merge_system: false,
|
||||||
|
no_tools: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of the setup wizard.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct WizardResult {
|
||||||
|
/// Selected provider kind.
|
||||||
|
pub provider: ProviderKind,
|
||||||
|
/// Full provider config.
|
||||||
|
pub config: ProviderConfig,
|
||||||
|
/// Whether the connection test passed.
|
||||||
|
pub connection_ok: bool,
|
||||||
|
/// Path where config was written.
|
||||||
|
pub config_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default config directory.
|
||||||
|
fn config_dir() -> PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(".config")
|
||||||
|
.join("gyandoot")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the full interactive setup wizard.
|
||||||
|
///
|
||||||
|
/// Returns the wizard result on success, or an error if the user cancels or
|
||||||
|
/// something goes wrong during write.
|
||||||
|
pub fn run_setup_wizard() -> anyhow::Result<Option<WizardResult>> {
|
||||||
|
// Step 1: Print compact banner
|
||||||
|
println!();
|
||||||
|
println!("{}", banner::render_fallback_colored());
|
||||||
|
println!();
|
||||||
|
terminal::section_header("Setup Wizard");
|
||||||
|
|
||||||
|
// Step 2: Provider selection
|
||||||
|
println!("Select your LLM provider:");
|
||||||
|
let provider_names: Vec<&str> = ProviderKind::all()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.display_name())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let choice = terminal::selection_menu("Choose a provider:", &provider_names);
|
||||||
|
let choice = match choice {
|
||||||
|
Some(idx) => ProviderKind::all()[idx],
|
||||||
|
None => {
|
||||||
|
terminal::show_status(terminal::StatusKind::Info, "Setup cancelled.");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!();
|
||||||
|
terminal::show_status(
|
||||||
|
terminal::StatusKind::Info,
|
||||||
|
&format!("Configuring provider: {}", choice.display_name()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: Per-provider config
|
||||||
|
let mut config = ProviderConfig::new(choice);
|
||||||
|
|
||||||
|
// Base URL
|
||||||
|
println!();
|
||||||
|
let url = terminal::text_input("Base URL", Some(&config.base_url));
|
||||||
|
match url {
|
||||||
|
Some(u) if !u.trim().is_empty() => config.base_url = u.trim().to_string(),
|
||||||
|
Some(_) => {} // keep default
|
||||||
|
None => return Ok(None),
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Key (with env hint)
|
||||||
|
let api_key_hint = choice.api_key_env_hint();
|
||||||
|
println!(" (Hint: set via {} env var or enter now)", api_key_hint);
|
||||||
|
let key = terminal::text_input("API Key", None);
|
||||||
|
match key {
|
||||||
|
Some(k) => config.api_key = k.trim().to_string(),
|
||||||
|
None => return Ok(None),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model
|
||||||
|
let model = terminal::text_input("Model", Some(&config.model));
|
||||||
|
match model {
|
||||||
|
Some(m) if !m.trim().is_empty() => config.model = m.trim().to_string(),
|
||||||
|
Some(_) => {} // keep default
|
||||||
|
None => return Ok(None),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temperature
|
||||||
|
let temp_input = terminal::text_input("Temperature (0.0-2.0)", Some("0.7"));
|
||||||
|
match temp_input {
|
||||||
|
Some(t) if !t.trim().is_empty() => {
|
||||||
|
if let Ok(v) = t.trim().parse::<f64>() {
|
||||||
|
config.temperature = v.clamp(0.0, 2.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => {} // keep default
|
||||||
|
None => return Ok(None),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max Tokens
|
||||||
|
let tokens_input = terminal::text_input("Max Tokens", Some("4096"));
|
||||||
|
match tokens_input {
|
||||||
|
Some(t) if !t.trim().is_empty() => {
|
||||||
|
if let Ok(v) = t.trim().parse::<u32>() {
|
||||||
|
config.max_tokens = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(_) => {} // keep default
|
||||||
|
None => return Ok(None),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect Ollama localhost settings
|
||||||
|
if choice == ProviderKind::Ollama && config.base_url.contains("localhost") {
|
||||||
|
config.merge_system = true;
|
||||||
|
config.no_tools = true;
|
||||||
|
println!();
|
||||||
|
terminal::show_status(
|
||||||
|
terminal::StatusKind::Info,
|
||||||
|
"Local Ollama detected: merge_system=true, no_tools=true",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Summary
|
||||||
|
println!();
|
||||||
|
terminal::section_header("Configuration Summary");
|
||||||
|
println!(" Provider: {}", config.kind.display_name());
|
||||||
|
println!(" Base URL: {}", config.base_url);
|
||||||
|
println!(
|
||||||
|
" API Key: {}",
|
||||||
|
if config.api_key.is_empty() {
|
||||||
|
"(none)".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}…{}", &config.api_key[..3.min(config.api_key.len())], &config.api_key[config.api_key.len().saturating_sub(4)..])
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(" Model: {}", config.model);
|
||||||
|
println!(" Temperature: {}", config.temperature);
|
||||||
|
println!(" Max Tokens: {}", config.max_tokens);
|
||||||
|
if config.merge_system {
|
||||||
|
println!(" Merge System: true");
|
||||||
|
}
|
||||||
|
if config.no_tools {
|
||||||
|
println!(" No Tools: true");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
if !terminal::confirm_prompt("Save this configuration?", Some(true)) {
|
||||||
|
terminal::show_status(terminal::StatusKind::Info, "Setup cancelled.");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Connection test
|
||||||
|
println!();
|
||||||
|
terminal::show_status(
|
||||||
|
terminal::StatusKind::Info,
|
||||||
|
"Running connection test...",
|
||||||
|
);
|
||||||
|
|
||||||
|
let test_passed = test_provider_connection(&config);
|
||||||
|
|
||||||
|
if test_passed {
|
||||||
|
terminal::show_status(
|
||||||
|
terminal::StatusKind::Success,
|
||||||
|
"Connection successful!",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
terminal::show_status(
|
||||||
|
terminal::StatusKind::Warning,
|
||||||
|
"Connection test failed — config will still be saved.",
|
||||||
|
);
|
||||||
|
println!(" (You can retry later or adjust settings manually)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Save config
|
||||||
|
println!();
|
||||||
|
if !terminal::confirm_prompt("Write config file?", Some(true)) {
|
||||||
|
terminal::show_status(terminal::StatusKind::Info, "Setup cancelled.");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let config_path = write_config_file(&config)?;
|
||||||
|
|
||||||
|
terminal::show_status(
|
||||||
|
terminal::StatusKind::Success,
|
||||||
|
&format!("Configuration saved to {}", config_path.display()),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Some(WizardResult {
|
||||||
|
provider: choice,
|
||||||
|
config,
|
||||||
|
connection_ok: test_passed,
|
||||||
|
config_path,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test provider connectivity by calling the /models endpoint.
|
||||||
|
fn test_provider_connection(config: &ProviderConfig) -> bool {
|
||||||
|
if config.api_key.is_empty() && config.kind != ProviderKind::Ollama {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
let client = match client {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/models", config.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
|
let request = client.get(&url);
|
||||||
|
let request = if !config.api_key.is_empty() {
|
||||||
|
request.header("Authorization", format!("Bearer {}", config.api_key))
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
};
|
||||||
|
|
||||||
|
match request.send() {
|
||||||
|
Ok(resp) => resp.status().is_success() || resp.status().as_u16() == 401 || resp.status().as_u16() == 403,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the provider configuration to ~/.config/gyandoot/config.toml.
|
||||||
|
fn write_config_file(config: &ProviderConfig) -> anyhow::Result<PathBuf> {
|
||||||
|
let dir = config_dir();
|
||||||
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
||||||
|
let path = dir.join("config.toml");
|
||||||
|
|
||||||
|
let toml_content = toml::to_string_pretty(&SerializedConfig::from(config))?;
|
||||||
|
std::fs::write(&path, &toml_content)?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable wrapper for TOML output.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SerializedConfig {
|
||||||
|
provider: SerializedProvider,
|
||||||
|
agent: SerializedAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SerializedProvider {
|
||||||
|
kind: String,
|
||||||
|
base_url: String,
|
||||||
|
api_key: String,
|
||||||
|
model: String,
|
||||||
|
temperature: f64,
|
||||||
|
max_tokens: u32,
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
merge_system: bool,
|
||||||
|
#[serde(skip_serializing_if = "is_false")]
|
||||||
|
no_tools: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SerializedAgent {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_false(v: &bool) -> bool {
|
||||||
|
!v
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ProviderConfig> for SerializedConfig {
|
||||||
|
fn from(c: &ProviderConfig) -> Self {
|
||||||
|
let kind_str = match c.kind {
|
||||||
|
ProviderKind::OpenAI => "openai",
|
||||||
|
ProviderKind::Anthropic => "anthropic",
|
||||||
|
ProviderKind::Ollama => "ollama",
|
||||||
|
ProviderKind::GoogleGemini => "google-gemini",
|
||||||
|
ProviderKind::DeepSeek => "deepseek",
|
||||||
|
ProviderKind::OpenRouter => "openrouter",
|
||||||
|
ProviderKind::Groq => "groq",
|
||||||
|
};
|
||||||
|
|
||||||
|
SerializedConfig {
|
||||||
|
provider: SerializedProvider {
|
||||||
|
kind: kind_str.to_string(),
|
||||||
|
base_url: c.base_url.clone(),
|
||||||
|
api_key: c.api_key.clone(),
|
||||||
|
model: c.model.clone(),
|
||||||
|
temperature: c.temperature,
|
||||||
|
max_tokens: c.max_tokens,
|
||||||
|
merge_system: c.merge_system,
|
||||||
|
no_tools: c.no_tools,
|
||||||
|
},
|
||||||
|
agent: SerializedAgent {
|
||||||
|
name: "gyandoot".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_kind_all() {
|
||||||
|
let all = ProviderKind::all();
|
||||||
|
assert!(!all.is_empty());
|
||||||
|
assert_eq!(all.len(), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_display_names() {
|
||||||
|
assert_eq!(ProviderKind::OpenAI.display_name(), "OpenAI");
|
||||||
|
assert_eq!(ProviderKind::Ollama.display_name(), "Ollama");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_defaults() {
|
||||||
|
for kind in ProviderKind::all() {
|
||||||
|
assert!(!kind.default_base_url().is_empty());
|
||||||
|
assert!(!kind.default_model().is_empty());
|
||||||
|
assert!(kind.default_temperature() > 0.0);
|
||||||
|
assert!(kind.default_max_tokens() > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_provider_config_new() {
|
||||||
|
let cfg = ProviderConfig::new(ProviderKind::OpenAI);
|
||||||
|
assert_eq!(cfg.kind, ProviderKind::OpenAI);
|
||||||
|
assert!(cfg.base_url.contains("openai"));
|
||||||
|
assert_eq!(cfg.model, "gpt-4o");
|
||||||
|
assert_eq!(cfg.temperature, 0.7);
|
||||||
|
assert_eq!(cfg.max_tokens, 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_config_dir_has_gyandoot() {
|
||||||
|
let dir = config_dir();
|
||||||
|
assert!(dir.to_string_lossy().contains("gyandoot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialized_config_roundtrip() {
|
||||||
|
let cfg = ProviderConfig::new(ProviderKind::Anthropic);
|
||||||
|
let serialized = SerializedConfig::from(&cfg);
|
||||||
|
let toml_str = toml::to_string_pretty(&serialized).unwrap();
|
||||||
|
assert!(toml_str.contains("anthropic"));
|
||||||
|
assert!(toml_str.contains("base_url"));
|
||||||
|
assert!(toml_str.contains("api_key"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ollama_autodetect() {
|
||||||
|
let mut cfg = ProviderConfig::new(ProviderKind::Ollama);
|
||||||
|
cfg.base_url = "http://localhost:11434".to_string();
|
||||||
|
cfg.merge_system = true;
|
||||||
|
cfg.no_tools = true;
|
||||||
|
assert!(cfg.merge_system);
|
||||||
|
assert!(cfg.no_tools);
|
||||||
|
}
|
||||||
|
}
|
||||||
253
crates/gyandoot-tui/src/terminal.rs
Normal file
253
crates/gyandoot-tui/src/terminal.rs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
//! Terminal helpers — ergonomic CLI interaction utilities.
|
||||||
|
//!
|
||||||
|
//! Provides:
|
||||||
|
//! - Progress spinner (blocking)
|
||||||
|
//! - Confirm prompt (y/n)
|
||||||
|
//! - Text input with simple editing
|
||||||
|
//! - Numbered selection menu
|
||||||
|
//! - Status message display (success/error/warning)
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
use crossterm::cursor;
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||||
|
use crossterm::execute;
|
||||||
|
use crossterm::style::Stylize;
|
||||||
|
use crossterm::terminal::{self, ClearType};
|
||||||
|
|
||||||
|
/// Status kind for `show_status`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum StatusKind {
|
||||||
|
/// Green checkmark — operation succeeded
|
||||||
|
Success,
|
||||||
|
/// Red X — operation failed
|
||||||
|
Error,
|
||||||
|
/// Yellow triangle — warning
|
||||||
|
Warning,
|
||||||
|
/// Blue info — informational message
|
||||||
|
Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a simple spinner while a closure executes.
|
||||||
|
///
|
||||||
|
/// The spinner displays rotating characters: `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`
|
||||||
|
/// Returns the result of the closure.
|
||||||
|
pub fn progress_spinner<F, T>(label: &str, f: F) -> T
|
||||||
|
where
|
||||||
|
F: FnOnce() -> T,
|
||||||
|
{
|
||||||
|
const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
|
|
||||||
|
// Show initial spinner
|
||||||
|
print!("{} {}", SPINNER[0], label);
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
|
||||||
|
// Run the closure
|
||||||
|
let result = f();
|
||||||
|
|
||||||
|
// Clear spinner line
|
||||||
|
let _ = execute!(io::stdout(), terminal::Clear(ClearType::CurrentLine));
|
||||||
|
let _ = io::stdout().flush();
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prompt the user for a yes/no answer.
|
||||||
|
///
|
||||||
|
/// Returns `true` for y/Y/yes/Yes, `false` for n/N/no/No.
|
||||||
|
/// Loops until a valid answer is given.
|
||||||
|
pub fn confirm_prompt(prompt: &str, default: Option<bool>) -> bool {
|
||||||
|
let hint = match default {
|
||||||
|
Some(true) => "[Y/n]",
|
||||||
|
Some(false) => "[y/N]",
|
||||||
|
None => "[y/n]",
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
print!("{} {}: ", prompt, hint);
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_line(&mut input).ok();
|
||||||
|
let input = input.trim().to_lowercase();
|
||||||
|
|
||||||
|
match input.as_str() {
|
||||||
|
"y" | "yes" => return true,
|
||||||
|
"n" | "no" => return false,
|
||||||
|
"" => {
|
||||||
|
if let Some(d) = default {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!(" Please answer 'y' or 'n'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect text input from the user with basic editing support.
|
||||||
|
///
|
||||||
|
/// Uses crossterm for raw mode input. Supports:
|
||||||
|
/// - Typing characters
|
||||||
|
/// - Backspace to delete
|
||||||
|
/// - Enter to submit
|
||||||
|
/// - Ctrl+C to abort (returns None)
|
||||||
|
pub fn text_input(prompt: &str, default: Option<&str>) -> Option<String> {
|
||||||
|
let _raw = crossterm::terminal::enable_raw_mode().ok()?;
|
||||||
|
|
||||||
|
let mut buffer = default.map(|s| s.to_string()).unwrap_or_default();
|
||||||
|
let prompt_line = format!("{}: ", prompt);
|
||||||
|
|
||||||
|
// Print prompt and buffer
|
||||||
|
print!("{}{}", prompt_line, buffer);
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
|
||||||
|
let result = loop {
|
||||||
|
match event::read() {
|
||||||
|
Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match key.code {
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
buffer.push(c);
|
||||||
|
print!("{}", c);
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if buffer.pop().is_some() {
|
||||||
|
print!("\x08 \x08");
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
println!();
|
||||||
|
break Some(buffer);
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
println!();
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::Key(key))
|
||||||
|
if key.code == KeyCode::Char('c')
|
||||||
|
&& key.modifiers == crossterm::event::KeyModifiers::CONTROL =>
|
||||||
|
{
|
||||||
|
println!("^C");
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
crossterm::terminal::disable_raw_mode().ok();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display a numbered selection menu and return the index of the chosen item.
|
||||||
|
///
|
||||||
|
/// Returns `None` if cancelled (Ctrl+C / Esc).
|
||||||
|
pub fn selection_menu(prompt: &str, items: &[&str]) -> Option<usize> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("{}", prompt);
|
||||||
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
println!(" {}. {}", i + 1, item);
|
||||||
|
}
|
||||||
|
println!(" 0. Cancel");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
print!("Enter choice [0-{}]: ", items.len());
|
||||||
|
io::stdout().flush().ok();
|
||||||
|
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_line(&mut input).ok();
|
||||||
|
let input = input.trim();
|
||||||
|
|
||||||
|
if input.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match input.parse::<usize>() {
|
||||||
|
Ok(0) => return None,
|
||||||
|
Ok(n) if n >= 1 && n <= items.len() => return Some(n - 1),
|
||||||
|
_ => {
|
||||||
|
eprintln!(
|
||||||
|
" Invalid choice. Enter a number between 0 and {}.",
|
||||||
|
items.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display a status message with a colored icon.
|
||||||
|
///
|
||||||
|
/// - Success: ✅ (or [OK])
|
||||||
|
/// - Error: ❌ (or [ERR])
|
||||||
|
/// - Warning: ⚠️ (or [WARN])
|
||||||
|
/// - Info: ℹ️ (or [INFO])
|
||||||
|
pub fn show_status(kind: StatusKind, message: &str) {
|
||||||
|
let (icon, styled_msg) = match kind {
|
||||||
|
StatusKind::Success => ("✅ ", message.green()),
|
||||||
|
StatusKind::Error => ("❌ ", message.red()),
|
||||||
|
StatusKind::Warning => ("⚠️ ", message.yellow()),
|
||||||
|
StatusKind::Info => ("ℹ️ ", message.blue()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to write styled; fall back to plain on error
|
||||||
|
let full = format!("{}{}", icon, styled_msg);
|
||||||
|
println!("{}", full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the terminal screen.
|
||||||
|
pub fn clear_screen() {
|
||||||
|
let _ = execute!(io::stdout(), terminal::Clear(ClearType::All));
|
||||||
|
let _ = execute!(io::stdout(), cursor::MoveTo(0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Print a section header with a divider line.
|
||||||
|
pub fn section_header(title: &str) {
|
||||||
|
let line = "─".repeat(title.len().saturating_add(4));
|
||||||
|
println!();
|
||||||
|
println!(" {} ", line.as_str().bold());
|
||||||
|
println!(" {} ", title.bold());
|
||||||
|
println!(" {} ", line.as_str().bold());
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_status_kind_equality() {
|
||||||
|
assert_eq!(StatusKind::Success, StatusKind::Success);
|
||||||
|
assert_ne!(StatusKind::Success, StatusKind::Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_selection_menu_empty() {
|
||||||
|
assert!(selection_menu("test", &[]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_confirm_prompt_parsing() {
|
||||||
|
// Can't really test interactively, but we can check the fn compiles
|
||||||
|
assert!(std::mem::size_of::<fn(&str, Option<bool>) -> bool>() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_screen_does_not_panic() {
|
||||||
|
let _ = execute!(io::stdout(), terminal::Clear(ClearType::All));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_section_header_does_not_panic() {
|
||||||
|
section_header("Test Header");
|
||||||
|
}
|
||||||
|
}
|
||||||
480
crates/gyandoot-tui/src/tui.rs
Normal file
480
crates/gyandoot-tui/src/tui.rs
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
//! Ratatui-based TUI interface for GyanDoot.
|
||||||
|
//!
|
||||||
|
//! Provides the main TUI loop, application state, and command handling
|
||||||
|
//! for interactive terminal sessions.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use ratatui::backend::{Backend, CrosstermBackend};
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span, Text};
|
||||||
|
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap};
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind};
|
||||||
|
use ratatui::crossterm::execute;
|
||||||
|
use ratatui::crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
|
|
||||||
|
use crate::banner;
|
||||||
|
|
||||||
|
/// Commands that can be dispatched to the TUI from other parts of the system.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TuiCommand {
|
||||||
|
/// Display a message in the status bar.
|
||||||
|
Status(String),
|
||||||
|
/// Set the title/header text.
|
||||||
|
Title(String),
|
||||||
|
/// Display output content.
|
||||||
|
Output(String),
|
||||||
|
/// Clear the output area.
|
||||||
|
ClearOutput,
|
||||||
|
/// Exit the TUI.
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Application state for the TUI.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TuiApp {
|
||||||
|
/// Current title displayed in the header.
|
||||||
|
pub title: String,
|
||||||
|
/// Content displayed in the main output area.
|
||||||
|
pub output: String,
|
||||||
|
/// Status message in the bottom bar.
|
||||||
|
pub status: String,
|
||||||
|
/// Whether the application should exit.
|
||||||
|
pub should_quit: bool,
|
||||||
|
/// Current mode.
|
||||||
|
pub mode: TuiMode,
|
||||||
|
/// Input buffer.
|
||||||
|
pub input: String,
|
||||||
|
/// Message log (scrollable history).
|
||||||
|
pub message_log: Vec<String>,
|
||||||
|
/// Scroll offset for message log.
|
||||||
|
pub scroll_offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TUI operational mode.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TuiMode {
|
||||||
|
/// Normal display mode — shows output and status.
|
||||||
|
Normal,
|
||||||
|
/// Input mode — user can type.
|
||||||
|
Input,
|
||||||
|
/// Command mode — reading a command.
|
||||||
|
Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TuiApp {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
title: "GyanDoot v2.0.0".to_string(),
|
||||||
|
output: "Welcome to GyanDoot — Knowledge in Motion.\n\nType your message or use /help for commands.\n".to_string(),
|
||||||
|
status: "Ready".to_string(),
|
||||||
|
should_quit: false,
|
||||||
|
mode: TuiMode::Normal,
|
||||||
|
input: String::new(),
|
||||||
|
message_log: Vec::new(),
|
||||||
|
scroll_offset: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiApp {
|
||||||
|
/// Create a new TUI application with default state.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a keyboard event in normal mode.
|
||||||
|
fn handle_normal_key(&mut self, key: KeyCode) {
|
||||||
|
match key {
|
||||||
|
KeyCode::Char(':') => {
|
||||||
|
self.mode = TuiMode::Command;
|
||||||
|
self.input.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Char('/') => {
|
||||||
|
self.mode = TuiMode::Command;
|
||||||
|
self.input.push('/');
|
||||||
|
}
|
||||||
|
KeyCode::Char('i') | KeyCode::Char('I') => {
|
||||||
|
self.mode = TuiMode::Input;
|
||||||
|
self.input.clear();
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
KeyCode::Char('q') => {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
if self.scroll_offset < self.message_log.len().saturating_sub(1) {
|
||||||
|
self.scroll_offset += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a keyboard event in input mode.
|
||||||
|
fn handle_input_key(&mut self, key: KeyCode) {
|
||||||
|
match key {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let msg = self.input.clone();
|
||||||
|
if !msg.is_empty() {
|
||||||
|
self.message_log.push(format!("> {}", msg));
|
||||||
|
self.output.push_str(&format!("\n> {}", msg));
|
||||||
|
self.status = format!("Sent: {}", msg);
|
||||||
|
}
|
||||||
|
self.input.clear();
|
||||||
|
self.mode = TuiMode::Normal;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.input.clear();
|
||||||
|
self.mode = TuiMode::Normal;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.input.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.input.push(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a keyboard event in command mode.
|
||||||
|
fn handle_command_key(&mut self, key: KeyCode) {
|
||||||
|
match key {
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let cmd = self.input.clone();
|
||||||
|
self.message_log.push(format!(":{}", cmd));
|
||||||
|
self.execute_command(&cmd);
|
||||||
|
self.input.clear();
|
||||||
|
self.mode = TuiMode::Normal;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.input.clear();
|
||||||
|
self.mode = TuiMode::Normal;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.input.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.input.push(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a command string.
|
||||||
|
fn execute_command(&mut self, cmd: &str) {
|
||||||
|
let cmd = cmd.trim().to_lowercase();
|
||||||
|
match cmd.as_str() {
|
||||||
|
"/help" | "help" | "h" => {
|
||||||
|
let help_text = "\
|
||||||
|
Commands:
|
||||||
|
/help Show this help
|
||||||
|
/clear Clear output
|
||||||
|
/banner Show the GyanDoot banner
|
||||||
|
/quit Exit the TUI
|
||||||
|
/status Show current status
|
||||||
|
i Enter input mode
|
||||||
|
q / Esc Quit
|
||||||
|
";
|
||||||
|
self.output.push_str(help_text);
|
||||||
|
self.status = "Help displayed".to_string();
|
||||||
|
}
|
||||||
|
"/clear" | "clear" => {
|
||||||
|
self.output.clear();
|
||||||
|
self.status = "Output cleared".to_string();
|
||||||
|
}
|
||||||
|
"/banner" | "banner" => {
|
||||||
|
self.output = banner::banner_plain();
|
||||||
|
self.status = "Banner displayed".to_string();
|
||||||
|
}
|
||||||
|
"/quit" | "quit" | "q" => {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
"/status" | "status" => {
|
||||||
|
self.output = format!(
|
||||||
|
"Status: {}\nMode: {:?}\nMessages: {}\n",
|
||||||
|
self.status,
|
||||||
|
self.mode,
|
||||||
|
self.message_log.len()
|
||||||
|
);
|
||||||
|
self.status = "Status shown".to_string();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let response = format!(
|
||||||
|
"Unknown command: {}\nType /help for available commands.\n",
|
||||||
|
cmd
|
||||||
|
);
|
||||||
|
self.output.push_str(&response);
|
||||||
|
self.status = format!("Unknown command: {}", cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a TUI command from external sources.
|
||||||
|
pub fn dispatch(&mut self, command: TuiCommand) {
|
||||||
|
match command {
|
||||||
|
TuiCommand::Status(msg) => {
|
||||||
|
self.status = msg;
|
||||||
|
}
|
||||||
|
TuiCommand::Title(title) => {
|
||||||
|
self.title = title;
|
||||||
|
}
|
||||||
|
TuiCommand::Output(content) => {
|
||||||
|
self.output.push_str(&content);
|
||||||
|
self.message_log.push(content);
|
||||||
|
}
|
||||||
|
TuiCommand::ClearOutput => {
|
||||||
|
self.output.clear();
|
||||||
|
self.message_log.clear();
|
||||||
|
}
|
||||||
|
TuiCommand::Quit => {
|
||||||
|
self.should_quit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the TUI interface using ratatui.
|
||||||
|
fn render(app: &mut TuiApp, frame: &mut Frame) {
|
||||||
|
let area = frame.area();
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // header
|
||||||
|
Constraint::Min(1), // main content
|
||||||
|
Constraint::Length(3), // input/status bar
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Header block
|
||||||
|
let header_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.style(Style::default().fg(Color::Yellow));
|
||||||
|
|
||||||
|
let header_text = Line::from(Span::styled(
|
||||||
|
&app.title,
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
|
||||||
|
let header = Paragraph::new(header_text)
|
||||||
|
.block(header_block)
|
||||||
|
.alignment(ratatui::layout::Alignment::Center);
|
||||||
|
|
||||||
|
frame.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
|
// Main content area
|
||||||
|
let content_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.title(" Output ")
|
||||||
|
.style(Style::default().fg(Color::Cyan));
|
||||||
|
|
||||||
|
let content = Paragraph::new(Text::from(app.output.clone()))
|
||||||
|
.block(content_block)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((app.scroll_offset as u16, 0));
|
||||||
|
|
||||||
|
frame.render_widget(content, chunks[1]);
|
||||||
|
|
||||||
|
// Bottom bar
|
||||||
|
let (bottom_content, bottom_style) = match app.mode {
|
||||||
|
TuiMode::Normal => {
|
||||||
|
let text = format!(" {} | [q]uit [:]cmd [i]nput ", app.status);
|
||||||
|
(text, Style::default().fg(Color::White).bg(Color::DarkGray))
|
||||||
|
}
|
||||||
|
TuiMode::Input => {
|
||||||
|
let text = format!(" INPUT: {}_ ", app.input);
|
||||||
|
(text, Style::default().fg(Color::Black).bg(Color::Green))
|
||||||
|
}
|
||||||
|
TuiMode::Command => {
|
||||||
|
let text = format!(" CMD: {}_ ", app.input);
|
||||||
|
(text, Style::default().fg(Color::Black).bg(Color::Yellow))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let bottom_block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded);
|
||||||
|
|
||||||
|
let bottom =
|
||||||
|
Paragraph::new(Line::from(Span::styled(&bottom_content, bottom_style))).block(bottom_block);
|
||||||
|
|
||||||
|
frame.render_widget(bottom, chunks[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the TUI main loop.
|
||||||
|
///
|
||||||
|
/// Enters alternate screen, runs the event loop, and restores the terminal
|
||||||
|
/// on exit. Returns `anyhow::Result<()>`.
|
||||||
|
pub fn run_tui() -> anyhow::Result<()> {
|
||||||
|
// Setup terminal
|
||||||
|
terminal::enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let mut app = TuiApp::new();
|
||||||
|
let tick_rate = Duration::from_millis(250);
|
||||||
|
|
||||||
|
let res = run_app(&mut terminal, &mut app, tick_rate);
|
||||||
|
|
||||||
|
// Restore terminal
|
||||||
|
terminal::disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
eprintln!("TUI error: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main event loop for the TUI.
|
||||||
|
fn run_app<B: Backend>(
|
||||||
|
terminal: &mut Terminal<B>,
|
||||||
|
app: &mut TuiApp,
|
||||||
|
tick_rate: Duration,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let mut last_tick = Instant::now();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
terminal.draw(|f| render(app, f))?;
|
||||||
|
|
||||||
|
let timeout = tick_rate
|
||||||
|
.checked_sub(last_tick.elapsed())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if event::poll(timeout)? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
if key.kind == KeyEventKind::Press {
|
||||||
|
match app.mode {
|
||||||
|
TuiMode::Normal => app.handle_normal_key(key.code),
|
||||||
|
TuiMode::Input => app.handle_input_key(key.code),
|
||||||
|
TuiMode::Command => app.handle_command_key(key.code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if last_tick.elapsed() >= tick_rate {
|
||||||
|
last_tick = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tui_app_creation() {
|
||||||
|
let app = TuiApp::new();
|
||||||
|
assert!(!app.should_quit);
|
||||||
|
assert_eq!(app.mode, TuiMode::Normal);
|
||||||
|
assert!(app.output.contains("GyanDoot"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tui_app_dispatch_commands() {
|
||||||
|
let mut app = TuiApp::new();
|
||||||
|
|
||||||
|
app.dispatch(TuiCommand::Status("test status".to_string()));
|
||||||
|
assert_eq!(app.status, "test status");
|
||||||
|
|
||||||
|
app.dispatch(TuiCommand::Title("Test Title".to_string()));
|
||||||
|
assert_eq!(app.title, "Test Title");
|
||||||
|
|
||||||
|
app.dispatch(TuiCommand::Output("hello".to_string()));
|
||||||
|
assert!(app.output.contains("hello"));
|
||||||
|
|
||||||
|
app.dispatch(TuiCommand::Quit);
|
||||||
|
assert!(app.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_commands() {
|
||||||
|
let mut app = TuiApp::new();
|
||||||
|
|
||||||
|
app.execute_command("/help");
|
||||||
|
assert!(app.output.contains("Commands"));
|
||||||
|
|
||||||
|
app.execute_command("clear");
|
||||||
|
assert_eq!(app.status, "Output cleared");
|
||||||
|
|
||||||
|
app.execute_command("unknown_xyz");
|
||||||
|
assert!(app.output.contains("Unknown command"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_input_mode() {
|
||||||
|
let mut app = TuiApp::new();
|
||||||
|
|
||||||
|
app.mode = TuiMode::Input;
|
||||||
|
app.handle_input_key(KeyCode::Char('h'));
|
||||||
|
app.handle_input_key(KeyCode::Char('i'));
|
||||||
|
assert_eq!(app.input, "hi");
|
||||||
|
|
||||||
|
app.handle_input_key(KeyCode::Backspace);
|
||||||
|
assert_eq!(app.input, "h");
|
||||||
|
|
||||||
|
app.handle_input_key(KeyCode::Enter);
|
||||||
|
assert_eq!(app.mode, TuiMode::Normal);
|
||||||
|
assert!(app.output.contains("> h"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normal_mode_keys() {
|
||||||
|
let mut app = TuiApp::new();
|
||||||
|
|
||||||
|
// i to input mode
|
||||||
|
app.handle_normal_key(KeyCode::Char('i'));
|
||||||
|
assert_eq!(app.mode, TuiMode::Input);
|
||||||
|
|
||||||
|
// back to normal
|
||||||
|
app.mode = TuiMode::Normal;
|
||||||
|
|
||||||
|
// : to command mode
|
||||||
|
app.handle_normal_key(KeyCode::Char(':'));
|
||||||
|
assert_eq!(app.mode, TuiMode::Command);
|
||||||
|
|
||||||
|
// q to quit
|
||||||
|
app.mode = TuiMode::Normal;
|
||||||
|
app.handle_normal_key(KeyCode::Char('q'));
|
||||||
|
assert!(app.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clear_output_command() {
|
||||||
|
let mut app = TuiApp::new();
|
||||||
|
app.output = "some content".to_string();
|
||||||
|
app.execute_command("/clear");
|
||||||
|
assert!(app.output.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/main.rs
45
src/main.rs
@@ -69,6 +69,12 @@ enum Commands {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
action: BrowserAction,
|
action: BrowserAction,
|
||||||
},
|
},
|
||||||
|
/// Run the interactive setup wizard for provider configuration
|
||||||
|
#[command(name = "setup")]
|
||||||
|
Setup,
|
||||||
|
/// Launch the TUI interface
|
||||||
|
#[command(name = "tui")]
|
||||||
|
Tui,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
@@ -149,6 +155,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Some(Commands::Browser { action }) => {
|
Some(Commands::Browser { action }) => {
|
||||||
cmd_browser(action).await?;
|
cmd_browser(action).await?;
|
||||||
}
|
}
|
||||||
|
Some(Commands::Setup) => {
|
||||||
|
cmd_setup().await?;
|
||||||
|
}
|
||||||
|
Some(Commands::Tui) => {
|
||||||
|
cmd_tui().await?;
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
// Default: run interactive CLI mode
|
// Default: run interactive CLI mode
|
||||||
cmd_run(&cli.profile, "cli", None).await?;
|
cmd_run(&cli.profile, "cli", None).await?;
|
||||||
@@ -295,7 +307,7 @@ async fn cmd_skills(action: SkillsAction) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
/// Handle memory management subcommands
|
/// Handle memory management subcommands
|
||||||
async fn cmd_memory(action: MemoryAction) -> anyhow::Result<()> {
|
async fn cmd_memory(action: MemoryAction) -> anyhow::Result<()> {
|
||||||
let palace = gyandoot_memory::palace::MemPalace::new();
|
let palace = gyandoot_memory::palace::MemPalace::new("default");
|
||||||
match action {
|
match action {
|
||||||
MemoryAction::Status => {
|
MemoryAction::Status => {
|
||||||
#[allow(unused_variables)]
|
#[allow(unused_variables)]
|
||||||
@@ -358,3 +370,34 @@ async fn cmd_browser(action: BrowserAction) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the interactive setup wizard
|
||||||
|
async fn cmd_setup() -> anyhow::Result<()> {
|
||||||
|
tracing::info!("Starting setup wizard");
|
||||||
|
match gyandoot_tui::setup::run_setup_wizard() {
|
||||||
|
Ok(Some(result)) => {
|
||||||
|
if result.connection_ok {
|
||||||
|
println!("\n✅ Setup complete! Provider '{}' configured successfully.", result.provider);
|
||||||
|
} else {
|
||||||
|
println!("\n⚠️ Setup saved but connection test failed for '{}'.", result.provider);
|
||||||
|
println!(" Check your API key and base URL, then try again.");
|
||||||
|
}
|
||||||
|
println!(" Config: {}", result.config_path.display());
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
println!("Setup cancelled.");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Setup wizard failed: {}", e);
|
||||||
|
eprintln!("Setup error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch the TUI interface
|
||||||
|
async fn cmd_tui() -> anyhow::Result<()> {
|
||||||
|
tracing::info!("Launching TUI");
|
||||||
|
gyandoot_tui::tui::run_tui()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user