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:
2026-06-11 20:53:38 +05:30
parent 03c103061a
commit 54d8ca3774
29 changed files with 7211 additions and 755 deletions

44
AGENTS.md Normal file
View 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
View File

@@ -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"

View File

@@ -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

View File

@@ -21,3 +21,4 @@ tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
async-trait.workspace = true
rand.workspace = true

View File

@@ -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"));
}
}

View File

@@ -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");
}
}

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -17,3 +17,6 @@ chrono.workspace = true
dirs.workspace = true
tokio.workspace = true
reqwest = { workspace = true, features = ["json"] }
[dev-dependencies]
tempfile = "3"

View File

@@ -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);
}
}

View File

@@ -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(_)));
}
}

View File

@@ -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());
}
}

View File

@@ -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"
);
}
}

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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?;

View File

@@ -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");
}
}

View File

@@ -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"));
}
}

View 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

View 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["));
}
}

View 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};

View 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);
}
}

View 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");
}
}

View 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());
}
}

View File

@@ -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(())
}