From 54d8ca3774e457d6dc4aaf200a30e0dbbf0d4afa Mon Sep 17 00:00:00 2001 From: Tara Menon Date: Thu, 11 Jun 2026 20:53:38 +0530 Subject: [PATCH] P0 deep-dive: core, providers, agent, memory, TUI, setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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// 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. --- AGENTS.md | 44 ++ Cargo.lock | 421 ++++++++++ Cargo.toml | 3 +- crates/gyandoot-agent/Cargo.toml | 3 +- crates/gyandoot-agent/src/agent_loop.rs | 630 ++++++++++++--- crates/gyandoot-agent/src/context.rs | 363 +++++++-- crates/gyandoot-agent/src/lib.rs | 4 +- crates/gyandoot-agent/src/tools.rs | 871 ++++++++++++++++++++- crates/gyandoot-core/Cargo.toml | 5 +- crates/gyandoot-core/src/config.rs | 693 +++++++++++++++- crates/gyandoot-core/src/error.rs | 296 ++++++- crates/gyandoot-core/src/types.rs | 436 +++++++++-- crates/gyandoot-memory/src/identity.rs | 416 +++++++++- crates/gyandoot-memory/src/lib.rs | 4 +- crates/gyandoot-memory/src/palace.rs | 659 ++++++++++++---- crates/gyandoot-memory/src/transcript.rs | 310 +++++++- crates/gyandoot-providers/Cargo.toml | 2 +- crates/gyandoot-providers/src/anthropic.rs | 449 +++++++++-- crates/gyandoot-providers/src/lib.rs | 305 +++++++- crates/gyandoot-providers/src/ollama.rs | 13 +- crates/gyandoot-providers/src/openai.rs | 405 ++++++++-- crates/gyandoot-providers/src/streaming.rs | 116 +++ crates/gyandoot-tui/Cargo.toml | 25 + crates/gyandoot-tui/src/banner.rs | 185 +++++ crates/gyandoot-tui/src/lib.rs | 20 + crates/gyandoot-tui/src/setup.rs | 510 ++++++++++++ crates/gyandoot-tui/src/terminal.rs | 253 ++++++ crates/gyandoot-tui/src/tui.rs | 480 ++++++++++++ src/main.rs | 45 +- 29 files changed, 7211 insertions(+), 755 deletions(-) create mode 100644 AGENTS.md create mode 100644 crates/gyandoot-tui/Cargo.toml create mode 100644 crates/gyandoot-tui/src/banner.rs create mode 100644 crates/gyandoot-tui/src/lib.rs create mode 100644 crates/gyandoot-tui/src/setup.rs create mode 100644 crates/gyandoot-tui/src/terminal.rs create mode 100644 crates/gyandoot-tui/src/tui.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8d5d94f --- /dev/null +++ b/AGENTS.md @@ -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 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 57c57a1..e4b2b2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 51fb930..a9186b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/crates/gyandoot-agent/Cargo.toml b/crates/gyandoot-agent/Cargo.toml index 3b880fb..98c8b8c 100644 --- a/crates/gyandoot-agent/Cargo.toml +++ b/crates/gyandoot-agent/Cargo.toml @@ -20,4 +20,5 @@ anyhow.workspace = true tracing.workspace = true uuid.workspace = true chrono.workspace = true -async-trait.workspace = true \ No newline at end of file +async-trait.workspace = true +rand.workspace = true \ No newline at end of file diff --git a/crates/gyandoot-agent/src/agent_loop.rs b/crates/gyandoot-agent/src/agent_loop.rs index ff6815f..28f95d9 100644 --- a/crates/gyandoot-agent/src/agent_loop.rs +++ b/crates/gyandoot-agent/src/agent_loop.rs @@ -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, tool_executor: ToolExecutor, context_compressor: ContextCompressor, memory: Arc>, - skills_manager: SkillsManager, + _skills_manager: SkillsManager, sanitizer: Sanitizer, status: Arc>, + 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 = vec![ - Message::new(MessageRole::System, &self.config.system_prompt), - ]; + let mut messages: Vec = + 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 { + let span = span!(Level::INFO, "conversation", agent = %self.config.name); + let _guard = span.enter(); + debug!("run_conversation: processing message"); + + let mut messages: Vec = + 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 { + 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) -> AgentResult> { + 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, + ) -> AgentResult<(Vec, 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 = 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 { + 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 { 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() - } } -/// Create the appropriate LLM provider based on configuration. +// ─── ConversationOutput (for non-interactive callers) ─────────────────── + +/// The result of a single `run_conversation` call. +#[derive(Debug, Clone)] +pub struct ConversationOutput { + /// The final assistant response text. + pub text: String, + /// Any tool outputs generated during the turn. + pub tool_results: Vec, +} + +// ─── 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::() * 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 { 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 { } } +// ─── 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")); } } \ No newline at end of file diff --git a/crates/gyandoot-agent/src/context.rs b/crates/gyandoot-agent/src/context.rs index 7a2f676..8a1b5fd 100644 --- a/crates/gyandoot-agent/src/context.rs +++ b/crates/gyandoot-agent/src/context.rs @@ -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) -> Vec { - 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, + max_tokens: usize, + ) -> Vec { + 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) -> Vec { + let keep_recent = self.recent_context_count; + let mut result: Vec = 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) -> Vec { + let keep_recent = self.recent_context_count; let mut compressed: Vec = 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::>() + .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::>() + .join(", "), user_msgs.len().saturating_sub(3) ) } } -} -impl Default for ContextCompressor { - fn default() -> Self { - Self::new() + /// 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 + } } } +/// 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) -> 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 = (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"); } } \ No newline at end of file diff --git a/crates/gyandoot-agent/src/lib.rs b/crates/gyandoot-agent/src/lib.rs index 506d63c..761622a 100644 --- a/crates/gyandoot-agent/src/lib.rs +++ b/crates/gyandoot-agent/src/lib.rs @@ -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; \ No newline at end of file diff --git a/crates/gyandoot-agent/src/tools.rs b/crates/gyandoot-agent/src/tools.rs index 554729b..bede79b 100644 --- a/crates/gyandoot-agent/src/tools.rs +++ b/crates/gyandoot-agent/src/tools.rs @@ -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; +} + +// ─── 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>, } -/// 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; -} - 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) { 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 { 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 { 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 { + 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::>() + .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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()); + } } \ No newline at end of file diff --git a/crates/gyandoot-core/Cargo.toml b/crates/gyandoot-core/Cargo.toml index 84a5b03..0c015fe 100644 --- a/crates/gyandoot-core/Cargo.toml +++ b/crates/gyandoot-core/Cargo.toml @@ -16,4 +16,7 @@ uuid.workspace = true chrono.workspace = true dirs.workspace = true tokio.workspace = true -reqwest = { workspace = true, features = ["json"] } \ No newline at end of file +reqwest = { workspace = true, features = ["json"] } + +[dev-dependencies] +tempfile = "3" \ No newline at end of file diff --git a/crates/gyandoot-core/src/config.rs b/crates/gyandoot-core/src/config.rs index 4c64851..c97c6de 100644 --- a/crates/gyandoot-core/src/config.rs +++ b/crates/gyandoot-core/src/config.rs @@ -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, +} + +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, +} + +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, + /// 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, +} + +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 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, pub anthropic: Option, pub ollama: Option, } -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, pub discord: Option, pub webhook: Option, } -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, } -/// Memory system configuration. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemoryConfig { +pub struct MemoryRootConfig { pub backend: String, pub storage_path: Option, 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, 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, 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 { - 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))?; - Ok(config) +// ─── 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) -> Self { + Self { + env_prefix: prefix.into(), + } + } + + /// Read and parse a single TOML config file. + pub fn from_file(path: &std::path::Path) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| ConfigError::FileNotFound { + path: path.to_path_buf(), + source: e, + })?; + toml::from_str(&content).map_err(|e| ConfigError::ParseError { + path: path.to_path_buf(), + detail: e.to_string(), + }) + } + + /// Override a config with environment variables. + /// + /// Supported variables: + /// - `GYANDOOT_AGENT_NAME`, `GYANDOOT_AGENT_MODEL`, + /// `GYANDOOT_AGENT_MAX_TOKENS`, `GYANDOOT_AGENT_TEMPERATURE`, + /// `GYANDOOT_AGENT_SYSTEM_PROMPT` + /// - `GYANDOOT_PROVIDERS_DEFAULT` + /// - `GYANDOOT_PLATFORM_DEFAULT` + /// - `GYANDOOT_MEMORY_BACKEND`, `GYANDOOT_MEMORY_PATH` + /// - `GYANDOOT_GATEWAY_ENABLED`, `GYANDOOT_GATEWAY_PORT`, `GYANDOOT_GATEWAY_HOST` + /// - `GYANDOOT_BROWSER_ENABLED`, `GYANDOOT_BROWSER_CAMOFOX_URL` + /// - Provider-specific: `GYANDOOT_OPENAI_API_KEY`, `GYANDOOT_OPENAI_MODEL`, + /// `GYANDOOT_OPENAI_BASE_URL`, `GYANDOOT_ANTHROPIC_API_KEY`, etc. + pub fn from_env(&self, config: &mut GyandootConfig) { + macro_rules! env_opt { + ($key:literal, $target:expr) => { + if let Ok(val) = std::env::var(concat!(env_prefix_str!(), $key)) { + $target = val; + } + }; + } + // We can't use concat! with a runtime string, so we build keys manually. + let prefix = &self.env_prefix; + let p = |k: &str| format!("{prefix}{k}"); + + // Agent + if let Ok(v) = std::env::var(p("AGENT_NAME")) { + config.agent.name = v; + } + if let Ok(v) = std::env::var(p("AGENT_MODEL")) { + config.agent.model = v; + } + if let Ok(v) = std::env::var(p("AGENT_MAX_TOKENS")) { + if let Ok(n) = v.parse::() { + config.agent.max_tokens = n; + } + } + if let Ok(v) = std::env::var(p("AGENT_TEMPERATURE")) { + if let Ok(n) = v.parse::() { + 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::() { + 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 { + 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 { + 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 { + 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); + } } \ No newline at end of file diff --git a/crates/gyandoot-core/src/error.rs b/crates/gyandoot-core/src/error.rs index 7c14c40..fb81504 100644 --- a/crates/gyandoot-core/src/error.rs +++ b/crates/gyandoot-core/src/error.rs @@ -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 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 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 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 = Result; +pub type ConfigResult = Result; pub type AgentResult = Result; pub type ProviderResult = Result; pub type MemoryResult = Result; pub type PlatformResult = Result; +pub type BrowserResult = Result; pub type SecurityResult = Result; pub type KanbanResult = Result; pub type SubAgentResult = Result; pub type SkillsResult = Result; pub type ProfileResult = Result; -pub type GatewayResult = Result; \ No newline at end of file +pub type GatewayResult = Result; + +// ─── 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(_))); + } +} \ No newline at end of file diff --git a/crates/gyandoot-core/src/types.rs b/crates/gyandoot-core/src/types.rs index c575185..321f636 100644 --- a/crates/gyandoot-core/src/types.rs +++ b/crates/gyandoot-core/src/types.rs @@ -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 { 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>, pub tool_result: Option, @@ -97,7 +112,8 @@ pub struct Message { } impl Message { - pub fn new(role: MessageRole, content: impl Into) -> Self { + /// Create a new message with the given role and content. + pub fn new(role: Role, content: impl Into) -> 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) -> 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 + } + + /// Convenience: return true if this is a system message. + pub fn is_system(&self) -> bool { + self.role == Role::System + } + + /// Convenience: return true if this is an assistant message. + pub fn is_assistant(&self) -> bool { + self.role == Role::Assistant + } + + /// Convenience: return true if this is a tool response message. + pub fn is_tool(&self) -> bool { + self.role == Role::Tool + } + + /// Return true if this message has at least one tool call attached. + pub fn has_tool_calls(&self) -> bool { + self.tool_calls.as_ref().map_or(false, |c| !c.is_empty()) + } } -/// A tool call invoked by the agent. +// ─── Tool types ─────────────────────────────────────────────────────────── + +/// A tool call invoked by the LLM. #[derive(Debug, Clone, Serialize, Deserialize)] 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, name: impl Into, 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, description: impl Into, 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, } +impl ToolResult { + /// Create a successful tool result. + pub fn success(tool_call_id: impl Into, output: impl Into) -> 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, error: impl Into) -> 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, + api_key: impl Into, + base_url: impl Into, + model: impl Into, + ) -> 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, + system_prompt: impl Into, + tools: Vec, + 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, } -#[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 { - 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; -/// 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 home = dirs::home_dir().expect("Cannot determine home directory"); - home.join(".config").join("gyandoot") + let base = dirs::config_dir().unwrap_or_else(|| { + let home = dirs::home_dir().expect("Cannot determine home directory"); + 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::().unwrap(), MessageRole::User); - assert_eq!("assistant".parse::().unwrap(), MessageRole::Assistant); - assert!("invalid".parse::().is_err()); + assert_eq!("user".parse::().unwrap(), Role::User); + assert_eq!("assistant".parse::().unwrap(), Role::Assistant); + assert!("invalid".parse::().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::().unwrap(), PlatformKind::Cli); assert_eq!("discord".parse::().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()); + } } \ No newline at end of file diff --git a/crates/gyandoot-memory/src/identity.rs b/crates/gyandoot-memory/src/identity.rs index 3180d0d..89fe44f 100644 --- a/crates/gyandoot-memory/src/identity.rs +++ b/crates/gyandoot-memory/src/identity.rs @@ -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, + /// Traits learned or assigned over time. pub learned_traits: Vec, + /// 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, + /// Optional directory for JSON file persistence. + storage_dir: Option, } 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::(&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(&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 { + 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 { - self.identities.keys().cloned().collect() + let mut ids: Vec = 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" + ); + } } \ No newline at end of file diff --git a/crates/gyandoot-memory/src/lib.rs b/crates/gyandoot-memory/src/lib.rs index 5f1eebb..487e390 100644 --- a/crates/gyandoot-memory/src/lib.rs +++ b/crates/gyandoot-memory/src/lib.rs @@ -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; diff --git a/crates/gyandoot-memory/src/palace.rs b/crates/gyandoot-memory/src/palace.rs index f92e6fd..5cc31ce 100644 --- a/crates/gyandoot-memory/src/palace.rs +++ b/crates/gyandoot-memory/src/palace.rs @@ -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// +//! 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>, - pub created_at: DateTime, - pub metadata: std::collections::HashMap, +// ─── 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, +impl std::error::Error for PalaceError {} + +impl From 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 = Result; + +// ─── 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, - 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> { - let query_lower = query.to_lowercase(); - let mut results: Vec = 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 { - 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> { + 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); + 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, + )); + } + } - Ok(MemoryStatus { - entry_count, - backend: "local-json".to_string(), - storage_size: size, - }) + results.truncate(limit); + Ok(results + .into_iter() + .map(|(name, snippet)| format!("[{name}]\n{snippet}")) + .collect()) } - /// Get a reference to the transcript store. + /// 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 { + 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 } -} -impl Default for MemPalace { - fn default() -> Self { - Self::new() + /// Get a mutable reference to the identity store. + pub fn identity_store_mut(&mut self) -> &mut IdentityStore { + &mut self.identity_store + } + + /// The wing name for this palace. + pub fn wing(&self) -> &str { + &self.wing + } + + /// The base directory of this palace. + pub fn base_dir(&self) -> &Path { + &self.base_dir } } +// ─── Helpers ──────────────────────────────────────────────────────────── + +/// Rough token estimation (~4 chars per token). +fn estimate_tokens(text: &str) -> usize { + (text.len() / 4).max(1) +} + +// ─── Tests ────────────────────────────────────────────────────────────── + #[cfg(test)] 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(); } } \ No newline at end of file diff --git a/crates/gyandoot-memory/src/transcript.rs b/crates/gyandoot-memory/src/transcript.rs index c5c32f1..3b34631 100644 --- a/crates/gyandoot-memory/src/transcript.rs +++ b/crates/gyandoot-memory/src/transcript.rs @@ -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, + /// Tool call identifiers made during this turn. + pub tool_call_ids: Vec, + /// Round-trip latency in milliseconds. pub latency_ms: u64, + /// When this turn occurred. pub timestamp: DateTime, } /// 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, + /// When the transcript was ended (None if still active). pub ended_at: Option>, + /// Ordered list of conversation turns. pub turns: Vec, + /// Optional summary / digest of the conversation. pub summary: Option, } -/// 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, + /// Optional directory for JSON file persistence. + storage_dir: Option, } 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, - started_at: Utc::now(), - ended_at: None, - turns: Vec::new(), - summary: None, - }, - ); + /// 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), + } } - /// 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); + /// 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 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 { - self.transcripts.keys().cloned().collect() + let mut ids: Vec = self.transcripts.keys().cloned().collect(); + ids.sort(); + ids + } + + /// Delete a transcript by ID. + pub fn delete(&mut self, transcript_id: &str) -> Option { + 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(); } } \ No newline at end of file diff --git a/crates/gyandoot-providers/Cargo.toml b/crates/gyandoot-providers/Cargo.toml index 1819c41..ac6a5f4 100644 --- a/crates/gyandoot-providers/Cargo.toml +++ b/crates/gyandoot-providers/Cargo.toml @@ -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 diff --git a/crates/gyandoot-providers/src/anthropic.rs b/crates/gyandoot-providers/src/anthropic.rs index 128730f..1f12cf4 100644 --- a/crates/gyandoot-providers/src/anthropic.rs +++ b/crates/gyandoot-providers/src/anthropic.rs @@ -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 { + 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 { + 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": "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 = 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 { context - .messages + .tools .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 - }; + .map(|td| { serde_json::json!({ - "role": role, - "content": m.content, + "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 { + 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 { + // 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> + 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::>(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::(&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> { + // 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"); + } } \ No newline at end of file diff --git a/crates/gyandoot-providers/src/lib.rs b/crates/gyandoot-providers/src/lib.rs index 7f8ad39..dc2a048 100644 --- a/crates/gyandoot-providers/src/lib.rs +++ b/crates/gyandoot-providers/src/lib.rs @@ -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>, + pub tool_calls: Option>, pub finish_reason: String, } +impl ProviderResponse { + /// Create a new provider response with text content only. + pub fn new(content: impl Into) -> 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, + calls: Vec, + finish_reason: impl Into, + ) -> 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; /// 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> + Send + Unpin>>; + + /// Return the list of models available for this provider. + async fn models(&self) -> ProviderResult> { + // 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 { + 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> { + 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> { + 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> { + 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); + } } \ No newline at end of file diff --git a/crates/gyandoot-providers/src/ollama.rs b/crates/gyandoot-providers/src/ollama.rs index df89ccd..b13ccd7 100644 --- a/crates/gyandoot-providers/src/ollama.rs +++ b/crates/gyandoot-providers/src/ollama.rs @@ -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!( - "Ollama API error: {}", - response.status() - ))); + 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?; diff --git a/crates/gyandoot-providers/src/openai.rs b/crates/gyandoot-providers/src/openai.rs index 6a6cba6..7bb526a 100644 --- a/crates/gyandoot-providers/src/openai.rs +++ b/crates/gyandoot-providers/src/openai.rs @@ -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) -> 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 = context @@ -61,56 +86,32 @@ impl OpenAIProvider { body } -} - -#[async_trait] -impl Provider for OpenAIProvider { - async fn chat(&self, context: ConversationContext) -> ProviderResult { - 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 { 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| { - calls - .iter() - .map(|tc| gyandoot_core::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"] - .as_str() - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or(serde_json::Value::Null), - }) - .collect() - }); + 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| 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"] + .as_str() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(serde_json::Value::Null), + }) + .collect() + }); Ok(ProviderResponse { content, @@ -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> + 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 { + 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 { + 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> + 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::>(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> { + 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"); + } } \ No newline at end of file diff --git a/crates/gyandoot-providers/src/streaming.rs b/crates/gyandoot-providers/src/streaming.rs index 6ef0832..cf51eaa 100644 --- a/crates/gyandoot-providers/src/streaming.rs +++ b/crates/gyandoot-providers/src/streaming.rs @@ -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 { + 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")); + } } \ No newline at end of file diff --git a/crates/gyandoot-tui/Cargo.toml b/crates/gyandoot-tui/Cargo.toml new file mode 100644 index 0000000..42fe88e --- /dev/null +++ b/crates/gyandoot-tui/Cargo.toml @@ -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 \ No newline at end of file diff --git a/crates/gyandoot-tui/src/banner.rs b/crates/gyandoot-tui/src/banner.rs new file mode 100644 index 0000000..79159a7 --- /dev/null +++ b/crates/gyandoot-tui/src/banner.rs @@ -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[")); + } +} \ No newline at end of file diff --git a/crates/gyandoot-tui/src/lib.rs b/crates/gyandoot-tui/src/lib.rs new file mode 100644 index 0000000..bdc629d --- /dev/null +++ b/crates/gyandoot-tui/src/lib.rs @@ -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}; \ No newline at end of file diff --git a/crates/gyandoot-tui/src/setup.rs b/crates/gyandoot-tui/src/setup.rs new file mode 100644 index 0000000..35cbe76 --- /dev/null +++ b/crates/gyandoot-tui/src/setup.rs @@ -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> { + // 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::() { + 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::() { + 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 { + 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); + } +} \ No newline at end of file diff --git a/crates/gyandoot-tui/src/terminal.rs b/crates/gyandoot-tui/src/terminal.rs new file mode 100644 index 0000000..6fedc64 --- /dev/null +++ b/crates/gyandoot-tui/src/terminal.rs @@ -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(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 { + 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 { + 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 { + 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::() { + 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::) -> 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"); + } +} \ No newline at end of file diff --git a/crates/gyandoot-tui/src/tui.rs b/crates/gyandoot-tui/src/tui.rs new file mode 100644 index 0000000..a66c14c --- /dev/null +++ b/crates/gyandoot-tui/src/tui.rs @@ -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, + /// 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( + terminal: &mut Terminal, + 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()); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 7fb18b4..806a64a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,6 +69,12 @@ enum Commands { #[command(subcommand)] action: BrowserAction, }, + /// Run the interactive setup wizard for provider configuration + #[command(name = "setup")] + Setup, + /// Launch the TUI interface + #[command(name = "tui")] + Tui, } #[derive(Subcommand, Debug)] @@ -149,6 +155,12 @@ async fn main() -> anyhow::Result<()> { Some(Commands::Browser { action }) => { cmd_browser(action).await?; } + Some(Commands::Setup) => { + cmd_setup().await?; + } + Some(Commands::Tui) => { + cmd_tui().await?; + } None => { // Default: run interactive CLI mode cmd_run(&cli.profile, "cli", None).await?; @@ -295,7 +307,7 @@ async fn cmd_skills(action: SkillsAction) -> anyhow::Result<()> { /// Handle memory management subcommands async fn cmd_memory(action: MemoryAction) -> anyhow::Result<()> { - let palace = gyandoot_memory::palace::MemPalace::new(); + let palace = gyandoot_memory::palace::MemPalace::new("default"); match action { MemoryAction::Status => { #[allow(unused_variables)] @@ -357,4 +369,35 @@ 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(()) } \ No newline at end of file