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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -128,6 +134,17 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -167,6 +184,21 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "cc"
|
||||
version = "1.2.63"
|
||||
@@ -259,6 +291,20 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -274,6 +320,31 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -294,6 +365,40 @@ dependencies = [
|
||||
"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]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -337,6 +442,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -353,6 +464,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -541,6 +658,7 @@ dependencies = [
|
||||
"gyandoot-security",
|
||||
"gyandoot-skills",
|
||||
"gyandoot-subagents",
|
||||
"gyandoot-tui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -561,6 +679,7 @@ dependencies = [
|
||||
"gyandoot-providers",
|
||||
"gyandoot-security",
|
||||
"gyandoot-skills",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
@@ -595,6 +714,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"toml",
|
||||
@@ -752,12 +872,37 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
@@ -773,6 +918,15 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
@@ -992,6 +1146,12 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1025,6 +1185,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -1034,6 +1203,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "ipnet"
|
||||
version = "2.12.0"
|
||||
@@ -1046,6 +1228,15 @@ version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1090,6 +1281,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
@@ -1111,6 +1314,15 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -1139,6 +1351,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -1208,6 +1421,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
@@ -1404,6 +1623,27 @@ dependencies = [
|
||||
"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]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -1424,6 +1664,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -1449,7 +1701,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -1469,12 +1723,14 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
@@ -1499,6 +1755,32 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
@@ -1648,6 +1930,27 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -1686,12 +1989,40 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -1729,6 +2060,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
@@ -1832,6 +2176,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
@@ -1997,6 +2354,35 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -2177,6 +2563,19 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
@@ -2218,6 +2617,28 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
|
||||
@@ -20,8 +20,8 @@ members = [
|
||||
"crates/gyandoot-profile",
|
||||
"crates/gyandoot-gateway",
|
||||
"crates/gyandoot-browser",
|
||||
"crates/gyandoot-tui",
|
||||
]
|
||||
edition = "2021"
|
||||
|
||||
[workspace.package]
|
||||
version = "2.0.0"
|
||||
@@ -65,6 +65,7 @@ gyandoot-skills = { path = "crates/gyandoot-skills" }
|
||||
gyandoot-profile = { path = "crates/gyandoot-profile" }
|
||||
gyandoot-gateway = { path = "crates/gyandoot-gateway" }
|
||||
gyandoot-browser = { path = "crates/gyandoot-browser" }
|
||||
gyandoot-tui = { path = "crates/gyandoot-tui" }
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -21,3 +21,4 @@ tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.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 →
|
||||
//! call LLM → process tool calls → repeat or respond.
|
||||
//! `AgentLoop` manages the full cycle: receive message → build context →
|
||||
//! 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::time::Duration;
|
||||
|
||||
use gyandoot_core::{
|
||||
AgentConfig, AgentStatus, ConversationContext, Message, MessageRole, ToolCall, ToolResult,
|
||||
AgentConfig, AgentError, AgentResult, AgentStatus, ConversationContext, Message,
|
||||
MessageRole,
|
||||
};
|
||||
use gyandoot_memory::palace::MemPalace;
|
||||
use gyandoot_providers::Provider;
|
||||
use gyandoot_providers::{Provider, ProviderResponse};
|
||||
use gyandoot_security::sanitizer::Sanitizer;
|
||||
use gyandoot_skills::SkillsManager;
|
||||
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::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 {
|
||||
config: AgentConfig,
|
||||
provider: Box<dyn Provider + Send + Sync>,
|
||||
tool_executor: ToolExecutor,
|
||||
context_compressor: ContextCompressor,
|
||||
memory: Arc<Mutex<MemPalace>>,
|
||||
skills_manager: SkillsManager,
|
||||
_skills_manager: SkillsManager,
|
||||
sanitizer: Sanitizer,
|
||||
status: Arc<Mutex<AgentStatus>>,
|
||||
retry_cfg: RetryConfig,
|
||||
}
|
||||
|
||||
impl AgentLoop {
|
||||
/// Create a new agent loop with the given configuration.
|
||||
///
|
||||
/// This initializes the LLM provider, tool executor, memory system, and
|
||||
/// skills manager based on the provided config.
|
||||
/// Create a new agent loop from a fully-resolved configuration.
|
||||
pub fn new(config: AgentConfig) -> Self {
|
||||
let provider = create_provider(&config);
|
||||
let tool_executor = ToolExecutor::new();
|
||||
let context_compressor = ContextCompressor::new();
|
||||
let memory = Arc::new(Mutex::new(MemPalace::new()));
|
||||
let tool_executor = ToolExecutor::with_defaults();
|
||||
let context_compressor = ContextCompressor::new(config.max_tokens);
|
||||
let memory = Arc::new(Mutex::new(MemPalace::new("default")));
|
||||
let skills_manager = SkillsManager::new();
|
||||
let sanitizer = Sanitizer::new();
|
||||
|
||||
Self {
|
||||
config,
|
||||
@@ -49,96 +136,81 @@ impl AgentLoop {
|
||||
tool_executor,
|
||||
context_compressor,
|
||||
memory,
|
||||
skills_manager,
|
||||
sanitizer,
|
||||
_skills_manager: skills_manager,
|
||||
sanitizer: Sanitizer::new(),
|
||||
status: Arc::new(Mutex::new(AgentStatus::Idle)),
|
||||
retry_cfg: RetryConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the agent loop interactively, reading from stdin and writing to stdout.
|
||||
///
|
||||
/// This is the primary entry point for CLI usage. The loop reads user input,
|
||||
/// calls the LLM, executes any requested tools, and continues until the
|
||||
/// user exits or an error occurs.
|
||||
/// Create an agent loop with a custom retry policy.
|
||||
pub fn with_retry(mut self, cfg: RetryConfig) -> Self {
|
||||
self.retry_cfg = cfg;
|
||||
self
|
||||
}
|
||||
|
||||
// ── 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<()> {
|
||||
let span = span!(Level::INFO, "agent_loop", agent = %self.config.name);
|
||||
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 {
|
||||
// Read user input
|
||||
// 1. Read user input
|
||||
let user_input = self.read_input().await?;
|
||||
|
||||
// Check for exit conditions
|
||||
// 2. Check exit conditions
|
||||
if self.should_exit(&user_input) {
|
||||
info!("User requested exit");
|
||||
break;
|
||||
}
|
||||
|
||||
// Sanitize input
|
||||
// 3. Sanitize
|
||||
let clean_input = self.sanitizer.sanitize(&user_input);
|
||||
|
||||
// Add user message
|
||||
messages.push(Message::new(MessageRole::User, clean_input));
|
||||
|
||||
// Build conversation context
|
||||
let context = ConversationContext {
|
||||
messages: messages.clone(),
|
||||
system_prompt: self.config.system_prompt.clone(),
|
||||
tools: self.tool_executor.definitions(),
|
||||
max_tokens: self.config.max_tokens,
|
||||
};
|
||||
// 4. Run the core turn (LLM + tool execution)
|
||||
let new_messages = self.execute_turn(messages).await?;
|
||||
messages = new_messages;
|
||||
|
||||
// Call the LLM
|
||||
*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
|
||||
// 5. Store conversation turn in memory
|
||||
{
|
||||
let mut memory = self.memory.lock().await;
|
||||
memory.store_conversation_turn(&messages).await?;
|
||||
let memory = self.memory.lock().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
|
||||
if self.should_compress(&messages) {
|
||||
debug!("Context size threshold reached, compressing");
|
||||
messages = self.context_compressor.compress(messages).await;
|
||||
// 6. Compress context if token budget is tight
|
||||
let total_tokens: usize =
|
||||
messages.iter().map(|m| estimate_tokens(&m.content)).sum();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -146,11 +218,263 @@ impl AgentLoop {
|
||||
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> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
let mut stdout = tokio::io::stdout();
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let mut stdout = tokio::io::stdout();
|
||||
stdout.write_all(b"\n> ").await?;
|
||||
stdout.flush().await?;
|
||||
|
||||
@@ -158,76 +482,79 @@ impl AgentLoop {
|
||||
let reader = tokio::io::BufReader::new(stdin);
|
||||
let mut lines = reader.lines();
|
||||
let mut input = String::new();
|
||||
|
||||
if let Some(line) = lines.next_line().await? {
|
||||
input = line;
|
||||
}
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
/// Check if the input indicates the user wants to exit.
|
||||
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) {
|
||||
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()
|
||||
}
|
||||
// ─── 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>,
|
||||
}
|
||||
|
||||
/// Create the appropriate LLM provider based on configuration.
|
||||
// ─── 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> {
|
||||
match config.provider.default.as_str() {
|
||||
"openai" => {
|
||||
let openai_config = config
|
||||
.provider
|
||||
.openai
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let cfg = config.provider.openai.clone().unwrap_or_default();
|
||||
Box::new(gyandoot_providers::openai::OpenAIProvider::new(
|
||||
openai_config.api_key.clone(),
|
||||
openai_config.model.clone(),
|
||||
openai_config.base_url.clone(),
|
||||
cfg.api_key,
|
||||
cfg.model,
|
||||
cfg.base_url,
|
||||
))
|
||||
}
|
||||
"anthropic" => {
|
||||
let anthropic_config = config
|
||||
.provider
|
||||
.anthropic
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let cfg = config.provider.anthropic.clone().unwrap_or_default();
|
||||
Box::new(gyandoot_providers::anthropic::AnthropicProvider::new(
|
||||
anthropic_config.api_key.clone(),
|
||||
anthropic_config.model.clone(),
|
||||
cfg.api_key,
|
||||
cfg.model,
|
||||
))
|
||||
}
|
||||
"ollama" => {
|
||||
let ollama_config = config
|
||||
.provider
|
||||
.ollama
|
||||
.clone()
|
||||
.unwrap_or_default();
|
||||
let cfg = config.provider.ollama.clone().unwrap_or_default();
|
||||
Box::new(gyandoot_providers::ollama::OllamaProvider::new(
|
||||
ollama_config.base_url.clone(),
|
||||
ollama_config.model.clone(),
|
||||
cfg.base_url,
|
||||
cfg.model,
|
||||
))
|
||||
}
|
||||
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(
|
||||
String::new(),
|
||||
config.model.clone(),
|
||||
@@ -237,10 +564,48 @@ fn create_provider(config: &AgentConfig) -> Box<dyn Provider + Send + Sync> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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]
|
||||
fn test_should_exit() {
|
||||
let config = AgentConfig::default();
|
||||
@@ -249,15 +614,24 @@ mod tests {
|
||||
"gpt-4o".to_string(),
|
||||
None,
|
||||
);
|
||||
let _agent = AgentLoop {
|
||||
let tool_executor = ToolExecutor::with_defaults();
|
||||
let context_compressor = ContextCompressor::new(4096);
|
||||
|
||||
let loop_ = AgentLoop {
|
||||
config,
|
||||
provider: Box::new(provider),
|
||||
tool_executor: ToolExecutor::new(),
|
||||
context_compressor: ContextCompressor::new(),
|
||||
memory: Arc::new(Mutex::new(MemPalace::new())),
|
||||
skills_manager: SkillsManager::new(&gyandoot_core::SkillsConfig::default()),
|
||||
tool_executor,
|
||||
context_compressor,
|
||||
memory: Arc::new(Mutex::new(MemPalace::new("default"))),
|
||||
_skills_manager: SkillsManager::new(),
|
||||
sanitizer: Sanitizer::new(),
|
||||
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
|
||||
//! intelligently compresses or summarizes older messages while preserving
|
||||
//! critical information.
|
||||
//! Manages LLM conversation context by estimating token usage, pruning verbose
|
||||
//! tool outputs, summarizing older messages, and assembling the final context
|
||||
//! that gets sent to the provider.
|
||||
|
||||
use gyandoot_core::Message;
|
||||
use gyandoot_core::{ConversationContext, Message, MessageRole, ToolDefinition};
|
||||
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 {
|
||||
/// Hard limit on context messages before summarization kicks in.
|
||||
max_messages_before_compress: usize,
|
||||
/// Minimum recent messages to keep intact.
|
||||
recent_context_count: usize,
|
||||
}
|
||||
|
||||
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
|
||||
/// (roughly corresponding to 15 turns of conversation).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
max_messages_before_compress: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a compressor with a custom message threshold.
|
||||
pub fn with_threshold(max_messages: usize) -> Self {
|
||||
/// `max_tokens` is the model's context window size; compress triggers
|
||||
/// at 75% of this value.
|
||||
pub fn new(max_tokens: usize) -> Self {
|
||||
let max_messages = (max_tokens / 200).max(20);
|
||||
Self {
|
||||
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:
|
||||
/// - Keep the system prompt (first message).
|
||||
/// - Keep the most recent N messages (recent context).
|
||||
/// - Summarize the messages in between into a single condensed message.
|
||||
/// - Preserve all tool call/result pairs.
|
||||
pub async fn compress(&self, messages: Vec<Message>) -> Vec<Message> {
|
||||
if messages.len() <= self.max_messages_before_compress {
|
||||
/// 1. Always keep the system prompt (first message).
|
||||
/// 2. Prune verbose tool result bodies from middle messages.
|
||||
/// 3. Summarize older non-recent messages into a single condensed entry.
|
||||
/// 4. Keep the most recent N messages intact.
|
||||
pub async fn 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;
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Compressing {} messages (threshold: {})",
|
||||
"Compressing {} messages (estimated {} tokens, threshold {} tokens)",
|
||||
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);
|
||||
|
||||
// Always keep the system prompt
|
||||
@@ -55,108 +154,240 @@ impl ContextCompressor {
|
||||
compressed.push(system.clone());
|
||||
}
|
||||
|
||||
// Keep the most recent N-2 messages (reserve space for summary)
|
||||
let recent_count = (self.max_messages_before_compress / 2).max(2);
|
||||
let keep_start = messages.len().saturating_sub(recent_count);
|
||||
|
||||
// Summarize middle messages
|
||||
let middle: Vec<&Message> = messages[1..keep_start]
|
||||
// Messages to summarize: everything between system prompt and recent tail
|
||||
let summarize_end = messages.len().saturating_sub(keep_recent);
|
||||
let middle: Vec<&Message> = messages[1..summarize_end]
|
||||
.iter()
|
||||
.filter(|m| m.role != gyandoot_core::MessageRole::Tool)
|
||||
.filter(|m| m.role != MessageRole::Tool)
|
||||
.collect();
|
||||
|
||||
if !middle.is_empty() {
|
||||
let summary = self.summarize_messages(&middle);
|
||||
let summary = self.build_summary(&middle);
|
||||
compressed.push(Message::new(
|
||||
gyandoot_core::MessageRole::System,
|
||||
format!("[Summarized context from previous conversation: {}]", summary),
|
||||
MessageRole::System,
|
||||
format!(
|
||||
"[Summarized conversation history — {}]\n\
|
||||
(Context from earlier turns in this session)",
|
||||
summary
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// Append the most recent messages
|
||||
let keep_start = messages.len().saturating_sub(keep_recent).max(1);
|
||||
for msg in messages.iter().skip(keep_start) {
|
||||
compressed.push(msg.clone());
|
||||
}
|
||||
|
||||
debug!("Compressed to {} messages", compressed.len());
|
||||
compressed
|
||||
}
|
||||
|
||||
/// Create a text summary from a slice of messages.
|
||||
fn summarize_messages(&self, messages: &[&Message]) -> String {
|
||||
/// Build a text summary from a slice of middle messages.
|
||||
fn build_summary(&self, messages: &[&Message]) -> String {
|
||||
let count = messages.len();
|
||||
let user_msgs: Vec<&str> = messages
|
||||
.iter()
|
||||
.filter(|m| m.role == gyandoot_core::MessageRole::User)
|
||||
.filter(|m| m.role == MessageRole::User)
|
||||
.map(|m| m.content.as_str())
|
||||
.collect();
|
||||
|
||||
if user_msgs.is_empty() {
|
||||
format!("{} previous assistant messages", count)
|
||||
format!("{} assistant messages", count)
|
||||
} else if user_msgs.len() <= 3 {
|
||||
format!(
|
||||
"Earlier discussion: {}",
|
||||
user_msgs.join(" | ")
|
||||
"Previous discussion: {}",
|
||||
user_msgs
|
||||
.iter()
|
||||
.map(|s| truncate_str(s, 80))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ")
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} previous turns covering topics: {}, ... (and {} more)",
|
||||
"{} turns covering: {}, ... (and {} more)",
|
||||
count,
|
||||
user_msgs[..3].join(", "),
|
||||
user_msgs[..3]
|
||||
.iter()
|
||||
.map(|s| truncate_str(s, 80))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
user_msgs.len().saturating_sub(3)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the final `ConversationContext` from messages, system
|
||||
/// prompt, and tool definitions.
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ContextCompressor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
/// 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)]
|
||||
mod tests {
|
||||
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]
|
||||
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)
|
||||
.map(|i| Message::new(MessageRole::User, format!("message {}", i)))
|
||||
.map(|i| make_msg(MessageRole::User, format!("msg {i}")))
|
||||
.collect();
|
||||
|
||||
let result = compressor.compress(messages.clone()).await;
|
||||
let result = compressor.compress(messages.clone(), 9999).await;
|
||||
assert_eq!(result.len(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_compress_above_threshold() {
|
||||
let compressor = ContextCompressor::with_threshold(10);
|
||||
let mut messages = vec![Message::new(MessageRole::System, "You are an AI.")];
|
||||
let compressor = ContextCompressor::with_thresholds(10, 4);
|
||||
let mut messages = vec![make_msg(MessageRole::System, "You are an AI.")];
|
||||
for i in 0..20 {
|
||||
messages.push(Message::new(MessageRole::User, format!("user message {}", i)));
|
||||
messages.push(Message::new(MessageRole::Assistant, format!("response {}", i)));
|
||||
messages.push(make_msg(MessageRole::User, format!("user msg {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_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]
|
||||
fn test_summarize_empty() {
|
||||
let compressor = ContextCompressor::new();
|
||||
let summary = compressor.summarize_messages(&[]);
|
||||
fn test_prune_tool_results_short() {
|
||||
let compressor = ContextCompressor::with_thresholds(20, 5);
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_summarize_few() {
|
||||
let compressor = ContextCompressor::new();
|
||||
let msg = Message::new(MessageRole::User, "hello");
|
||||
let summary = compressor.summarize_messages(&[&msg]);
|
||||
assert!(summary.contains("hello"));
|
||||
fn test_build_summary_few_messages() {
|
||||
let compressor = ContextCompressor::with_thresholds(20, 5);
|
||||
let msg = make_msg(MessageRole::User, "hello there");
|
||||
let msgs = vec![&msg];
|
||||
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 context;
|
||||
|
||||
pub use agent_loop::AgentLoop;
|
||||
pub use tools::ToolExecutor;
|
||||
pub use agent_loop::{AgentLoop, AgentErrorKind, ConversationOutput, RetryConfig};
|
||||
pub use tools::{BuiltinTool, BashTool, BashToolConfig, Tool, ToolExecutor, ReadTool, WriteTool, EditTool, GrepTool, FindTool, LsTool};
|
||||
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::time::Duration;
|
||||
|
||||
use gyandoot_core::{ToolCall, ToolDefinition, ToolResult};
|
||||
use serde_json;
|
||||
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 {
|
||||
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 {
|
||||
/// Create a new executor with no tools loaded.
|
||||
/// Create an empty executor (no tools registered).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
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>) {
|
||||
let name = tool.name().to_string();
|
||||
debug!("Registering tool: {}", name);
|
||||
debug!("Registering tool: {name}");
|
||||
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> {
|
||||
self.tools
|
||||
.values()
|
||||
@@ -48,17 +125,21 @@ impl ToolExecutor {
|
||||
.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> {
|
||||
let span = tracing::span!(tracing::Level::INFO, "tool_exec", tool = %call.name);
|
||||
let _guard = span.enter();
|
||||
|
||||
match self.tools.get(&call.name) {
|
||||
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 {
|
||||
Ok(output) => {
|
||||
debug!("Tool '{}' succeeded", call.name);
|
||||
debug!("Tool '{}' succeeded ({} bytes)", call.name, output.len());
|
||||
Ok(ToolResult {
|
||||
tool_call_id: call.id.clone(),
|
||||
success: true,
|
||||
@@ -67,7 +148,7 @@ impl ToolExecutor {
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Tool '{}' failed: {}", call.name, e);
|
||||
error!("Tool '{}' failed: {e}", call.name);
|
||||
Ok(ToolResult {
|
||||
tool_call_id: call.id.clone(),
|
||||
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 {
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -143,4 +852,126 @@ mod tests {
|
||||
assert!(result.success);
|
||||
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
|
||||
tokio.workspace = true
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -1,10 +1,248 @@
|
||||
//! 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 crate::ConfigError;
|
||||
|
||||
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)]
|
||||
pub struct AgentConfig {
|
||||
pub name: String,
|
||||
@@ -12,42 +250,80 @@ pub struct AgentConfig {
|
||||
pub max_tokens: usize,
|
||||
pub temperature: f64,
|
||||
pub system_prompt: String,
|
||||
pub provider: ProviderConfig,
|
||||
pub platform: PlatformConfig,
|
||||
pub memory: MemoryConfig,
|
||||
pub security: SecurityConfig,
|
||||
pub kanban: KanbanConfig,
|
||||
pub skills: SkillsConfig,
|
||||
pub provider: ProviderRootConfig,
|
||||
pub platform: PlatformRootConfig,
|
||||
pub memory: MemoryRootConfig,
|
||||
pub security: SecurityRootConfig,
|
||||
pub kanban: KanbanRootConfig,
|
||||
pub skills: SkillsRootConfig,
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: "gyandoot".to_string(),
|
||||
model: "gpt-4o".to_string(),
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
name: default_agent_name(),
|
||||
model: default_agent_model(),
|
||||
max_tokens: default_max_tokens(),
|
||||
temperature: default_temperature(),
|
||||
system_prompt: String::new(),
|
||||
provider: ProviderConfig::default(),
|
||||
platform: PlatformConfig::default(),
|
||||
memory: MemoryConfig::default(),
|
||||
security: SecurityConfig::default(),
|
||||
kanban: KanbanConfig::default(),
|
||||
skills: SkillsConfig::default(),
|
||||
provider: ProviderRootConfig::default(),
|
||||
platform: PlatformRootConfig::default(),
|
||||
memory: MemoryRootConfig::default(),
|
||||
security: SecurityRootConfig::default(),
|
||||
kanban: KanbanRootConfig::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)]
|
||||
pub struct ProviderConfig {
|
||||
pub struct ProviderRootConfig {
|
||||
pub default: String,
|
||||
pub openai: Option<OpenAIConfig>,
|
||||
pub anthropic: Option<AnthropicConfig>,
|
||||
pub ollama: Option<OllamaConfig>,
|
||||
}
|
||||
|
||||
impl Default for ProviderConfig {
|
||||
impl Default for ProviderRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
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)]
|
||||
pub struct OpenAIConfig {
|
||||
pub api_key: String,
|
||||
@@ -66,6 +343,21 @@ pub struct OpenAIConfig {
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -84,6 +376,20 @@ pub struct AnthropicConfig {
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -101,6 +407,20 @@ pub struct OllamaConfig {
|
||||
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 {
|
||||
fn default() -> 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)]
|
||||
pub struct PlatformConfig {
|
||||
pub struct PlatformRootConfig {
|
||||
pub default: String,
|
||||
pub telegram: Option<TelegramConfig>,
|
||||
pub discord: Option<DiscordConfig>,
|
||||
pub webhook: Option<WebhookConfig>,
|
||||
}
|
||||
|
||||
impl Default for PlatformConfig {
|
||||
impl Default for PlatformRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default: "cli".to_string(),
|
||||
@@ -150,16 +472,15 @@ pub struct WebhookConfig {
|
||||
pub secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Memory system configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryConfig {
|
||||
pub struct MemoryRootConfig {
|
||||
pub backend: String,
|
||||
pub storage_path: Option<PathBuf>,
|
||||
pub max_entries: usize,
|
||||
pub auto_compress: bool,
|
||||
}
|
||||
|
||||
impl Default for MemoryConfig {
|
||||
impl Default for MemoryRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: "local".to_string(),
|
||||
@@ -170,15 +491,14 @@ impl Default for MemoryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Security configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecurityConfig {
|
||||
pub struct SecurityRootConfig {
|
||||
pub vault_path: Option<PathBuf>,
|
||||
pub encryption_enabled: bool,
|
||||
pub threat_detection: bool,
|
||||
}
|
||||
|
||||
impl Default for SecurityConfig {
|
||||
impl Default for SecurityRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
vault_path: None,
|
||||
@@ -188,15 +508,14 @@ impl Default for SecurityConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kanban task system configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KanbanConfig {
|
||||
pub struct KanbanRootConfig {
|
||||
pub enabled: bool,
|
||||
pub max_concurrent_tasks: usize,
|
||||
pub auto_decompose: bool,
|
||||
}
|
||||
|
||||
impl Default for KanbanConfig {
|
||||
impl Default for KanbanRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
@@ -206,15 +525,14 @@ impl Default for KanbanConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skills system configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillsConfig {
|
||||
pub struct SkillsRootConfig {
|
||||
pub enabled: bool,
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
pub auto_load: bool,
|
||||
}
|
||||
|
||||
impl Default for SkillsConfig {
|
||||
impl Default for SkillsRootConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
@@ -224,18 +542,218 @@ impl Default for SkillsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from a TOML file.
|
||||
pub fn load_config_from_file(path: &std::path::Path) -> anyhow::Result<AgentConfig> {
|
||||
// ─── ConfigLoader ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Layered configuration loader.
|
||||
///
|
||||
/// Resolution order (later overrides earlier):
|
||||
/// 1. Hardcoded defaults
|
||||
/// 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| anyhow::anyhow!("Failed to read config file {}: {}", path.display(), e))?;
|
||||
let config: AgentConfig = toml::from_str(&content)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e))?;
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
@@ -246,8 +764,97 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_provider_default() {
|
||||
let provider = ProviderConfig::default();
|
||||
let provider = ProviderRootConfig::default();
|
||||
assert_eq!(provider.default, "openai");
|
||||
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.
|
||||
//!
|
||||
//! Each subsystem defines its own error enum using `thiserror`,
|
||||
//! while application-level errors use `anyhow`.
|
||||
//! Each subsystem defines its own error enum using `thiserror` derive,
|
||||
//! providing rich context with `From` conversions between related types.
|
||||
//! Application-level errors use `anyhow`.
|
||||
|
||||
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.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CoreError {
|
||||
@@ -30,15 +63,80 @@ pub enum CoreError {
|
||||
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)]
|
||||
pub enum AgentError {
|
||||
#[error("LLM provider error: {0}")]
|
||||
Provider(String),
|
||||
|
||||
#[error("Tool not found: {0}")]
|
||||
ToolNotFound(String),
|
||||
|
||||
#[error("Tool execution error: {0}")]
|
||||
ToolExecution(String),
|
||||
|
||||
#[error("Max iterations reached ({0})")]
|
||||
MaxIterations(usize),
|
||||
|
||||
#[error("Context overflow: {0}")]
|
||||
ContextOverflow(String),
|
||||
|
||||
#[error("Context compression error: {0}")]
|
||||
ContextCompression(String),
|
||||
|
||||
@@ -49,66 +147,83 @@ pub enum AgentError {
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// LLM provider errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ProviderError {
|
||||
#[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),
|
||||
impl From<ProviderError> for AgentError {
|
||||
fn from(e: ProviderError) -> Self {
|
||||
AgentError::Provider(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Memory errors ────────────────────────────────────────────────────────
|
||||
|
||||
/// Memory system errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MemoryError {
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
#[error("Failed to store entry: {0}")]
|
||||
Store(String),
|
||||
|
||||
#[error("Query error: {0}")]
|
||||
Query(String),
|
||||
#[error("Failed to retrieve entry: {0}")]
|
||||
Retrieve(String),
|
||||
|
||||
#[error("Index corruption: {0}")]
|
||||
IndexCorruption(String),
|
||||
#[error("Corrupt storage index: {0}")]
|
||||
Corrupt(String),
|
||||
|
||||
#[error("Entry not found: {0}")]
|
||||
EntryNotFound(String),
|
||||
|
||||
#[error("Compression failed: {0}")]
|
||||
Compression(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
// ─── Platform errors ──────────────────────────────────────────────────────
|
||||
|
||||
/// Platform adapter errors.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PlatformError {
|
||||
#[error("Connection failed: {0}")]
|
||||
Connection(String),
|
||||
|
||||
#[error("Message send failed: {0}")]
|
||||
#[error("Failed to send message: {0}")]
|
||||
Send(String),
|
||||
|
||||
#[error("Failed to receive message: {0}")]
|
||||
Receive(String),
|
||||
|
||||
#[error("Authentication failed: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Platform unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
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.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SecurityError {
|
||||
@@ -217,15 +332,120 @@ pub enum GatewayError {
|
||||
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 ConfigResult<T> = Result<T, ConfigError>;
|
||||
pub type AgentResult<T> = Result<T, AgentError>;
|
||||
pub type ProviderResult<T> = Result<T, ProviderError>;
|
||||
pub type MemoryResult<T> = Result<T, MemoryError>;
|
||||
pub type PlatformResult<T> = Result<T, PlatformError>;
|
||||
pub type BrowserResult<T> = Result<T, BrowserError>;
|
||||
pub type SecurityResult<T> = Result<T, SecurityError>;
|
||||
pub type KanbanResult<T> = Result<T, KanbanError>;
|
||||
pub type SubAgentResult<T> = Result<T, SubAgentError>;
|
||||
pub type SkillsResult<T> = Result<T, SkillsError>;
|
||||
pub type ProfileResult<T> = Result<T, ProfileError>;
|
||||
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.
|
||||
//!
|
||||
//! 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::path::PathBuf;
|
||||
@@ -8,7 +12,9 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Unique identifier for an agent.
|
||||
// ─── Identifiers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Unique identifier for an agent instance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
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)]
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MessageRole {
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MessageRole {
|
||||
impl std::fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MessageRole::System => write!(f, "system"),
|
||||
MessageRole::User => write!(f, "user"),
|
||||
MessageRole::Assistant => write!(f, "assistant"),
|
||||
MessageRole::Tool => write!(f, "tool"),
|
||||
Role::System => write!(f, "system"),
|
||||
Role::User => write!(f, "user"),
|
||||
Role::Assistant => write!(f, "assistant"),
|
||||
Role::Tool => write!(f, "tool"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for MessageRole {
|
||||
impl FromStr for Role {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"system" => Ok(MessageRole::System),
|
||||
"user" => Ok(MessageRole::User),
|
||||
"assistant" => Ok(MessageRole::Assistant),
|
||||
"tool" => Ok(MessageRole::Tool),
|
||||
_ => Err(format!("Invalid message role: {}", s)),
|
||||
"system" => Ok(Role::System),
|
||||
"user" => Ok(Role::User),
|
||||
"assistant" => Ok(Role::Assistant),
|
||||
"tool" => Ok(Role::Tool),
|
||||
_ => 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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub role: MessageRole,
|
||||
pub role: Role,
|
||||
pub content: String,
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
pub tool_result: Option<String>,
|
||||
@@ -97,7 +112,8 @@ pub struct 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 {
|
||||
role,
|
||||
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 {
|
||||
self.tool_calls = Some(calls);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a tool result to this message (typically for tool messages).
|
||||
pub fn with_tool_result(mut self, result: String) -> Self {
|
||||
self.tool_result = Some(result);
|
||||
self
|
||||
}
|
||||
|
||||
/// Convenience: return true if this is a user message.
|
||||
pub fn is_user(&self) -> bool {
|
||||
self.role == Role::User
|
||||
}
|
||||
|
||||
/// A tool call invoked by the agent.
|
||||
/// 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())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tool types ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A tool call invoked by the LLM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
@@ -126,7 +171,18 @@ pub struct ToolCall {
|
||||
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)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
@@ -134,6 +190,17 @@ pub struct ToolDefinition {
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResult {
|
||||
@@ -143,6 +210,101 @@ pub struct ToolResult {
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PlatformKind {
|
||||
@@ -161,11 +323,24 @@ impl FromStr for PlatformKind {
|
||||
"telegram" => Ok(PlatformKind::Telegram),
|
||||
"discord" => Ok(PlatformKind::Discord),
|
||||
"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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AgentStatus {
|
||||
@@ -176,6 +351,20 @@ pub enum AgentStatus {
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationContext {
|
||||
@@ -185,6 +374,35 @@ pub struct ConversationContext {
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Priority {
|
||||
@@ -194,6 +412,18 @@ pub enum Priority {
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Task {
|
||||
@@ -210,53 +440,30 @@ pub struct Task {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
// ─── Config maps ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A profile configuration key-value map.
|
||||
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 {
|
||||
let home = dirs::home_dir().expect("Cannot determine home directory");
|
||||
home.join(".local").join("share").join("gyandoot")
|
||||
}
|
||||
|
||||
/// Resolve the GyanDoot config directory using `dirs` (cross-platform).
|
||||
pub fn config_dir() -> PathBuf {
|
||||
let base = dirs::config_dir().unwrap_or_else(|| {
|
||||
let home = dirs::home_dir().expect("Cannot determine home directory");
|
||||
home.join(".config").join("gyandoot")
|
||||
home.join(".config")
|
||||
});
|
||||
base.join("gyandoot")
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -275,9 +482,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_message_role_from_str() {
|
||||
assert_eq!("user".parse::<MessageRole>().unwrap(), MessageRole::User);
|
||||
assert_eq!("assistant".parse::<MessageRole>().unwrap(), MessageRole::Assistant);
|
||||
assert!("invalid".parse::<MessageRole>().is_err());
|
||||
assert_eq!("user".parse::<Role>().unwrap(), Role::User);
|
||||
assert_eq!("assistant".parse::<Role>().unwrap(), Role::Assistant);
|
||||
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]
|
||||
@@ -285,4 +500,111 @@ mod tests {
|
||||
assert_eq!("cli".parse::<PlatformKind>().unwrap(), PlatformKind::Cli);
|
||||
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.
|
||||
//!
|
||||
//! 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::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
|
||||
/// Agent identity information stored in memory.
|
||||
// ─── Data types ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Two-layer agent identity.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentIdentity {
|
||||
/// Unique agent identifier.
|
||||
pub agent_id: String,
|
||||
/// Short display name (L0).
|
||||
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>,
|
||||
/// Traits learned or assigned over time.
|
||||
pub learned_traits: Vec<String>,
|
||||
/// When this identity was created.
|
||||
pub created_at: String,
|
||||
/// When this identity was last updated.
|
||||
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.
|
||||
pub struct IdentityStore {
|
||||
/// In-memory identity map (agent_id → identity).
|
||||
identities: HashMap<String, AgentIdentity>,
|
||||
/// Optional directory for JSON file persistence.
|
||||
storage_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl IdentityStore {
|
||||
/// Create a new identity store.
|
||||
/// Create a new in-memory identity store.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
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) {
|
||||
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.
|
||||
@@ -39,27 +182,88 @@ impl IdentityStore {
|
||||
self.identities.get(agent_id)
|
||||
}
|
||||
|
||||
/// Update a preference for an agent.
|
||||
pub fn set_preference(&mut self, agent_id: &str, key: String, value: String) -> Option<()> {
|
||||
self.identities
|
||||
.get_mut(agent_id)?
|
||||
.preferences
|
||||
.insert(key, value);
|
||||
/// Get a mutable reference to an agent's identity.
|
||||
pub fn get_mut(&mut self, agent_id: &str) -> Option<&mut AgentIdentity> {
|
||||
self.identities.get_mut(agent_id)
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn add_trait(&mut self, agent_id: &str, trait_name: String) -> Option<()> {
|
||||
self.identities
|
||||
.get_mut(agent_id)?
|
||||
.learned_traits
|
||||
.push(trait_name);
|
||||
Some(())
|
||||
///
|
||||
/// Skips duplicates. Returns `None` if the agent ID was not found.
|
||||
pub fn add_trait(&mut self, agent_id: &str, trait_name: &str) -> Option<()> {
|
||||
self.update(agent_id, |id| {
|
||||
if !id.learned_traits.contains(&trait_name.to_string()) {
|
||||
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.
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_identity_register_and_retrieve() {
|
||||
let mut store = IdentityStore::new();
|
||||
store.register(AgentIdentity {
|
||||
agent_id: "agent-1".to_string(),
|
||||
name: "GyanDoot".to_string(),
|
||||
bio: "An AI Agent".to_string(),
|
||||
fn make_identity(name: &str, role: &str) -> AgentIdentity {
|
||||
AgentIdentity {
|
||||
agent_id: format!("agent-{name}"),
|
||||
name: name.to_string(),
|
||||
role: role.to_string(),
|
||||
user_name: "TestUser".to_string(),
|
||||
user_role: "Developer".to_string(),
|
||||
story: format!("{name} is a test agent serving as {role}."),
|
||||
preferences: HashMap::new(),
|
||||
learned_traits: vec![],
|
||||
created_at: "now".to_string(),
|
||||
updated_at: "now".to_string(),
|
||||
});
|
||||
let identity = store.get("agent-1").unwrap();
|
||||
created_at: Utc::now().to_rfc3339(),
|
||||
updated_at: Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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.role, "AI Assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preferences() {
|
||||
let mut store = IdentityStore::new();
|
||||
store.register(AgentIdentity {
|
||||
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.register(make_identity("Test", "Bot"));
|
||||
|
||||
store
|
||||
.set_preference("a1", "language".to_string(), "rust".to_string())
|
||||
.set_preference("agent-Test", "language", "rust")
|
||||
.unwrap();
|
||||
|
||||
let identity = store.get("agent-Test").unwrap();
|
||||
assert_eq!(
|
||||
store.get("a1").unwrap().preferences.get("language").unwrap(),
|
||||
identity.preferences.get("language").unwrap(),
|
||||
"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.
|
||||
//!
|
||||
//! The MemPalace stores conversation transcripts, agent identity data,
|
||||
//! and provides semantic query over past interactions using embeddings
|
||||
//! stored alongside text.
|
||||
//! and provides query over past interactions. Data is organized into
|
||||
//! "wings" under `~/.gyandoot/palace/`.
|
||||
|
||||
pub mod palace;
|
||||
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,
|
||||
//! handles memory compaction, and provides query capabilities.
|
||||
//! Directory structure:
|
||||
//! ```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 gyandoot_core::MemoryResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::debug;
|
||||
use chrono::Utc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::identity::IdentityStore;
|
||||
use crate::transcript::TranscriptStore;
|
||||
|
||||
/// A single memory entry stored in the palace.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryEntry {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub agent_id: String,
|
||||
pub session_id: String,
|
||||
pub entry_type: EntryType,
|
||||
pub embedding: Option<Vec<f64>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub metadata: std::collections::HashMap<String, String>,
|
||||
// ─── Error type ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Errors that may occur during MemPalace operations.
|
||||
#[derive(Debug)]
|
||||
pub enum PalaceError {
|
||||
Io(std::io::Error),
|
||||
NotFound(String),
|
||||
Serialization(String),
|
||||
}
|
||||
|
||||
/// The type of a memory entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum EntryType {
|
||||
Conversation,
|
||||
Fact,
|
||||
Identity,
|
||||
Task,
|
||||
Skill,
|
||||
impl std::fmt::Display for PalaceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PalaceError::Io(e) => write!(f, "IO error: {e}"),
|
||||
PalaceError::NotFound(s) => write!(f, "Not found: {s}"),
|
||||
PalaceError::Serialization(s) => write!(f, "Serialization error: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A memory query result with relevance score.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemoryQueryResult {
|
||||
pub content: String,
|
||||
pub relevance: f64,
|
||||
pub entry_type: EntryType,
|
||||
pub created_at: DateTime<Utc>,
|
||||
impl std::error::Error for PalaceError {}
|
||||
|
||||
impl From<std::io::Error> for PalaceError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
PalaceError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Status information for the memory palace.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MemoryStatus {
|
||||
pub entry_count: usize,
|
||||
pub backend: String,
|
||||
pub storage_size: u64,
|
||||
/// Convenience alias.
|
||||
pub type PalaceResult<T> = Result<T, PalaceError>;
|
||||
|
||||
// ─── Palace paths ───────────────────────────────────────────────────────
|
||||
|
||||
/// 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
|
||||
/// agent and session. Future versions will include vector embeddings
|
||||
/// for semantic search.
|
||||
/// Each wing corresponds to an agent profile or deployment context.
|
||||
/// Within a wing, conversations are logged as daily Markdown transcripts,
|
||||
/// 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 {
|
||||
entries: Vec<MemoryEntry>,
|
||||
storage_path: PathBuf,
|
||||
wing: String,
|
||||
base_dir: PathBuf,
|
||||
sessions_dir: PathBuf,
|
||||
identity_dir: PathBuf,
|
||||
transcript_store: TranscriptStore,
|
||||
identity_store: IdentityStore,
|
||||
}
|
||||
|
||||
impl MemPalace {
|
||||
/// Create a new MemPalace with memory directory in the user's data dir.
|
||||
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.
|
||||
/// Open (or create) the palace for a given wing.
|
||||
///
|
||||
/// Future versions will use embedding-based semantic search.
|
||||
pub fn query(&self, query: &str, limit: usize) -> MemoryResult<Vec<MemoryQueryResult>> {
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results: Vec<MemoryQueryResult> = self
|
||||
.entries
|
||||
.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();
|
||||
/// Creates directory structure if it does not exist.
|
||||
pub fn new(wing: &str) -> Self {
|
||||
let base = wing_dir(wing);
|
||||
let sessions = base.join("sessions");
|
||||
let identity = base.join("identity");
|
||||
|
||||
results.sort_by(|a, b| b.relevance.partial_cmp(&a.relevance).unwrap_or(std::cmp::Ordering::Equal));
|
||||
results.truncate(limit);
|
||||
Ok(results)
|
||||
// Ensure directory structure exists
|
||||
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}");
|
||||
});
|
||||
|
||||
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.
|
||||
pub fn export_to(&self, path: &PathBuf) -> MemoryResult<()> {
|
||||
let json = serde_json::to_string_pretty(&self.entries)
|
||||
.map_err(|e| gyandoot_core::MemoryError::Storage(e.to_string()))?;
|
||||
std::fs::write(path, &json)
|
||||
.map_err(|e| gyandoot_core::MemoryError::Storage(e.to_string()))?;
|
||||
/// Open the palace for a wing at a custom base path.
|
||||
pub fn with_path(wing: &str, base: PathBuf) -> Self {
|
||||
let sessions = base.join("sessions");
|
||||
let identity = base.join("identity");
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Get memory status information.
|
||||
pub fn status() -> MemoryResult<MemoryStatus> {
|
||||
let data_dir = gyandoot_core::data_dir().join("memory");
|
||||
let size = std::fs::read_dir(&data_dir)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.metadata().ok())
|
||||
.map(|m| m.len())
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
/// Query memory by simple string matching against stored JSON files.
|
||||
///
|
||||
/// Searches the `sessions/` directory for files whose content contains
|
||||
/// the query string. Returns up to `limit` matches.
|
||||
pub fn query(&self, query: &str, limit: usize) -> PalaceResult<Vec<String>> {
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results: Vec<(String, String)> = Vec::new(); // (filename, content snippet)
|
||||
|
||||
let entry_count = std::fs::read_dir(&data_dir)
|
||||
.map(|entries| entries.filter_map(|e| e.ok()).count())
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(MemoryStatus {
|
||||
entry_count,
|
||||
backend: "local-json".to_string(),
|
||||
storage_size: size,
|
||||
})
|
||||
let entries = std::fs::read_dir(&self.sessions_dir)?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(true, |e| e != "json") {
|
||||
continue;
|
||||
}
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
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 {
|
||||
&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.
|
||||
pub fn identity_store(&self) -> &IdentityStore {
|
||||
&self.identity_store
|
||||
}
|
||||
|
||||
/// Get a mutable reference to the identity store.
|
||||
pub fn identity_store_mut(&mut self) -> &mut IdentityStore {
|
||||
&mut self.identity_store
|
||||
}
|
||||
|
||||
impl Default for MemPalace {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
/// 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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn test_store_and_query() {
|
||||
let mut palace = MemPalace::new();
|
||||
let wing = test_wing();
|
||||
let palace = MemPalace::new(&wing);
|
||||
|
||||
palace
|
||||
.store(MemoryEntry {
|
||||
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(),
|
||||
})
|
||||
.store("test_key", &serde_json::json!({"content": "Python is great"}))
|
||||
.unwrap();
|
||||
|
||||
let results = palace.query("python", 10).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].content.contains("Python"));
|
||||
let results = palace.query("Python", 10).unwrap();
|
||||
assert!(!results.is_empty());
|
||||
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]
|
||||
fn test_export() {
|
||||
let palace = MemPalace::new();
|
||||
let tmp = std::env::temp_dir().join("gyandoot_test_export.json");
|
||||
fn test_identity_set_and_get() {
|
||||
let wing = test_wing();
|
||||
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();
|
||||
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.
|
||||
//!
|
||||
//! 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::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
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)]
|
||||
pub struct TranscriptTurn {
|
||||
/// Sequential turn number within the transcript (1-indexed).
|
||||
pub turn_number: u64,
|
||||
/// The user's message content.
|
||||
pub user_message: String,
|
||||
/// The assistant's response content.
|
||||
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,
|
||||
/// When this turn occurred.
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A complete conversation transcript.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Transcript {
|
||||
/// Unique transcript identifier (UUID).
|
||||
pub id: String,
|
||||
/// The agent that participated in this conversation.
|
||||
pub agent_id: String,
|
||||
/// The session this transcript belongs to.
|
||||
pub session_id: String,
|
||||
/// When the transcript was started.
|
||||
pub started_at: DateTime<Utc>,
|
||||
/// When the transcript was ended (None if still active).
|
||||
pub ended_at: Option<DateTime<Utc>>,
|
||||
/// Ordered list of conversation turns.
|
||||
pub turns: Vec<TranscriptTurn>,
|
||||
/// Optional summary / digest of the conversation.
|
||||
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 {
|
||||
/// In-memory transcript map.
|
||||
transcripts: HashMap<String, Transcript>,
|
||||
/// Optional directory for JSON file persistence.
|
||||
storage_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TranscriptStore {
|
||||
/// Create a new empty transcript store.
|
||||
/// Create a new empty transcript store (in-memory only).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
transcripts: HashMap::new(),
|
||||
storage_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new transcript.
|
||||
pub fn start_transcript(&mut self, id: String, agent_id: String, session_id: String) {
|
||||
self.transcripts.insert(
|
||||
id.clone(),
|
||||
Transcript {
|
||||
id,
|
||||
agent_id,
|
||||
session_id,
|
||||
/// Create a new transcript store with file-backed persistence.
|
||||
///
|
||||
/// Transcripts are saved as `{transcript_id}.json` in the given
|
||||
/// directory.
|
||||
pub fn with_persistence(storage_dir: PathBuf) -> Self {
|
||||
fs::create_dir_all(&storage_dir).ok();
|
||||
Self {
|
||||
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(),
|
||||
ended_at: None,
|
||||
turns: Vec::new(),
|
||||
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.
|
||||
pub fn append_turn(&mut self, transcript_id: &str, turn: TranscriptTurn) -> Option<()> {
|
||||
self.transcripts.get_mut(transcript_id)?.turns.push(turn);
|
||||
/// Append a turn to a transcript.
|
||||
///
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
self.transcripts
|
||||
.get_mut(transcript_id)?
|
||||
.ended_at = Some(Utc::now());
|
||||
let transcript = self.transcripts.get_mut(transcript_id)?;
|
||||
transcript.ended_at = Some(Utc::now());
|
||||
self.persist(transcript_id);
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Set a summary for the transcript.
|
||||
pub fn set_summary(&mut self, transcript_id: &str, summary: String) -> Option<()> {
|
||||
self.transcripts.get_mut(transcript_id)?.summary = Some(summary);
|
||||
/// Finalize a transcript with a 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(())
|
||||
}
|
||||
|
||||
@@ -82,9 +177,49 @@ impl TranscriptStore {
|
||||
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> {
|
||||
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)]
|
||||
mod tests {
|
||||
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]
|
||||
fn test_transcript_lifecycle() {
|
||||
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
|
||||
.append_turn(
|
||||
"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(),
|
||||
},
|
||||
)
|
||||
.append_turn(&id, make_turn(1, "Hello", "Hi there!"))
|
||||
.unwrap();
|
||||
|
||||
store.end_transcript("t1").unwrap();
|
||||
let t = store.get("t1").unwrap();
|
||||
store.end_transcript(&id).unwrap();
|
||||
|
||||
let t = store.get(&id).unwrap();
|
||||
assert_eq!(t.turns.len(), 1);
|
||||
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
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
reqwest.workspace = true
|
||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
//! 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 gyandoot_core::{ConversationContext, MessageRole, ProviderResult};
|
||||
use futures::StreamExt;
|
||||
use gyandoot_core::{ConversationContext, ProviderError, ProviderResult, ToolCall};
|
||||
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};
|
||||
|
||||
/// Provider for the Anthropic (Claude) API.
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
||||
/// Provider for the Anthropic (Claude) Messages API.
|
||||
pub struct AnthropicProvider {
|
||||
api_key: String,
|
||||
model: String,
|
||||
client: Client,
|
||||
request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
@@ -20,39 +31,158 @@ impl AnthropicProvider {
|
||||
Self {
|
||||
api_key,
|
||||
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.
|
||||
///
|
||||
/// 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> {
|
||||
context
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| m.role != MessageRole::System)
|
||||
.map(|m| {
|
||||
let role = match m.role {
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
MessageRole::Tool => "user",
|
||||
MessageRole::System => "user", // filtered above
|
||||
};
|
||||
let mut result = Vec::new();
|
||||
|
||||
for m in &context.messages {
|
||||
if m.is_system() {
|
||||
continue; // handled separately
|
||||
}
|
||||
|
||||
let serialized = match m.role.to_string().as_str() {
|
||||
"tool" => {
|
||||
// Tool results: send as user with content blocks
|
||||
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,
|
||||
});
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
/// Extract the system prompt from context (Anthropic uses a separate field).
|
||||
fn extract_system(&self, context: &ConversationContext) -> String {
|
||||
context
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.role == MessageRole::System)
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default()
|
||||
/// Send the non-streaming request.
|
||||
async fn send_request(&self, body: &serde_json::Value) -> ProviderResult<reqwest::Response> {
|
||||
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));
|
||||
}
|
||||
|
||||
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 messages = self.build_messages(&context);
|
||||
let tools = self.build_tools(&context);
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"model": self.model,
|
||||
@@ -70,40 +201,18 @@ impl Provider for AnthropicProvider {
|
||||
"max_tokens": context.max_tokens,
|
||||
});
|
||||
|
||||
if !system.is_empty() {
|
||||
body["system"] = serde_json::Value::String(system);
|
||||
if let Some(sys) = &system {
|
||||
body["system"] = serde_json::Value::String(sys.clone());
|
||||
}
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.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
|
||||
)));
|
||||
if !tools.is_empty() {
|
||||
body["tools"] = serde_json::Value::Array(tools);
|
||||
}
|
||||
|
||||
let response = self.send_request(&body).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 {
|
||||
content,
|
||||
tool_calls: None,
|
||||
finish_reason,
|
||||
})
|
||||
Ok(self.parse_response(&data)?)
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
@@ -111,12 +220,238 @@ impl Provider for AnthropicProvider {
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||
{
|
||||
// Fall back to non-streaming for now
|
||||
let result = self.chat(context).await?;
|
||||
let stream = tokio_stream::StreamExt::chain(
|
||||
tokio_stream::once(Ok(result.content)),
|
||||
tokio_stream::empty(),
|
||||
);
|
||||
debug!("Anthropic streaming chat (model: {})", self.model);
|
||||
|
||||
let system = self.extract_system(&context);
|
||||
let messages = self.build_messages(&context);
|
||||
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))
|
||||
}
|
||||
|
||||
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.
|
||||
//!
|
||||
//! Defines the `Provider` trait and implementations for OpenAI, Anthropic,
|
||||
//! and Ollama. Each provider handles authentication, request formatting,
|
||||
//! response parsing, and streaming.
|
||||
//! Defines the [`Provider`] trait and implementations for OpenAI, Anthropic,
|
||||
//! Ollama, and other providers. Each implementation handles authentication,
|
||||
//! request formatting, response parsing, and streaming.
|
||||
//!
|
||||
//! The [`ProviderFactory`] creates provider instances from
|
||||
//! [`gyandoot_core::ProviderConfig`].
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gyandoot_core::{ConversationContext, ProviderResult};
|
||||
use gyandoot_core::{ConversationContext, ProviderResult, ToolCall};
|
||||
|
||||
pub mod openai;
|
||||
pub mod anthropic;
|
||||
pub mod ollama;
|
||||
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.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderResponse {
|
||||
pub content: String,
|
||||
pub tool_calls: Option<Vec<gyandoot_core::ToolCall>>,
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
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.
|
||||
#[async_trait]
|
||||
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>;
|
||||
|
||||
/// 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(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> 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.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gyandoot_core::{ConversationContext, MessageRole, ProviderResult};
|
||||
use gyandoot_core::{ConversationContext, ProviderResult};
|
||||
use reqwest::Client;
|
||||
use tracing::debug;
|
||||
|
||||
@@ -59,10 +59,13 @@ impl Provider for OllamaProvider {
|
||||
.await?;
|
||||
|
||||
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: {}",
|
||||
response.status()
|
||||
)));
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await?;
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
//! 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 gyandoot_core::{ConversationContext, MessageRole, ProviderResult};
|
||||
use futures::StreamExt;
|
||||
use gyandoot_core::{ConversationContext, ProviderError, ProviderResult, ToolCall};
|
||||
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};
|
||||
|
||||
/// Provider for the OpenAI chat completion API.
|
||||
/// Provider for OpenAI-compatible chat completion APIs.
|
||||
pub struct OpenAIProvider {
|
||||
api_key: String,
|
||||
model: String,
|
||||
base_url: String,
|
||||
client: Client,
|
||||
request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl OpenAIProvider {
|
||||
/// 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 {
|
||||
Self {
|
||||
api_key,
|
||||
model,
|
||||
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.
|
||||
fn build_request(&self, context: &ConversationContext) -> serde_json::Value {
|
||||
let messages: Vec<serde_json::Value> = context
|
||||
@@ -61,47 +86,23 @@ impl OpenAIProvider {
|
||||
|
||||
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 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 tool_calls = message["tool_calls"]
|
||||
.as_array()
|
||||
.map(|calls| {
|
||||
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 from the response
|
||||
let tool_calls = message["tool_calls"].as_array().map(|calls| {
|
||||
calls
|
||||
.iter()
|
||||
.map(|tc| gyandoot_core::ToolCall {
|
||||
.map(|tc| ToolCall {
|
||||
id: tc["id"].as_str().unwrap_or_default().to_string(),
|
||||
name: tc["function"]["name"].as_str().unwrap_or_default().to_string(),
|
||||
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,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||
{
|
||||
debug!("OpenAI streaming chat request (model: {})", self.model);
|
||||
|
||||
let request_body = self.build_request(&context);
|
||||
let url = format!("{}/chat/completions", self.base_url);
|
||||
body: &serde_json::Value,
|
||||
) -> ProviderResult<reqwest::Response> {
|
||||
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.json(&request_body)
|
||||
.json(body)
|
||||
.timeout(self.request_timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(gyandoot_core::ProviderError::Api(format!(
|
||||
"OpenAI stream error: {}",
|
||||
response.status()
|
||||
)));
|
||||
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));
|
||||
}
|
||||
|
||||
// We can't easily return a boxed stream from reqwest's streaming API
|
||||
// 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(
|
||||
tokio_stream::once(Ok(result.content)),
|
||||
tokio_stream::empty(),
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OpenAIProvider {
|
||||
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))
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gyandoot_core::{Message, ToolDefinition};
|
||||
use gyandoot_core::{Message, Role, ToolDefinition};
|
||||
|
||||
#[test]
|
||||
fn test_build_request() {
|
||||
let provider = OpenAIProvider::new(
|
||||
fn test_provider() -> OpenAIProvider {
|
||||
OpenAIProvider::new(
|
||||
"sk-test".to_string(),
|
||||
"gpt-4o".to_string(),
|
||||
None,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_basic() {
|
||||
let provider = test_provider();
|
||||
let context = ConversationContext {
|
||||
messages: vec![
|
||||
Message::new(MessageRole::System, "You are an AI."),
|
||||
Message::new(MessageRole::User, "Hello"),
|
||||
Message::new(Role::System, "You are an AI."),
|
||||
Message::new(Role::User, "Hello"),
|
||||
],
|
||||
system_prompt: "You are an AI.".to_string(),
|
||||
tools: vec![],
|
||||
@@ -184,4 +330,107 @@ mod tests {
|
||||
assert_eq!(body["model"], "gpt-4o");
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -55,4 +137,38 @@ mod tests {
|
||||
let result = collect_stream(stream).await.unwrap();
|
||||
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)]
|
||||
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)]
|
||||
@@ -149,6 +155,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
Some(Commands::Browser { action }) => {
|
||||
cmd_browser(action).await?;
|
||||
}
|
||||
Some(Commands::Setup) => {
|
||||
cmd_setup().await?;
|
||||
}
|
||||
Some(Commands::Tui) => {
|
||||
cmd_tui().await?;
|
||||
}
|
||||
None => {
|
||||
// Default: run interactive CLI mode
|
||||
cmd_run(&cli.profile, "cli", None).await?;
|
||||
@@ -295,7 +307,7 @@ async fn cmd_skills(action: SkillsAction) -> anyhow::Result<()> {
|
||||
|
||||
/// Handle memory management subcommands
|
||||
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 {
|
||||
MemoryAction::Status => {
|
||||
#[allow(unused_variables)]
|
||||
@@ -358,3 +370,34 @@ async fn cmd_browser(action: BrowserAction) -> anyhow::Result<()> {
|
||||
}
|
||||
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