diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index e4e044b1..eff212c2 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -3,7 +3,6 @@ name: Release Binaries on: push: tags: - - "elizacp-v*" - "yopo-v*" - "sacp-conductor-v*" - "sacp-tee-v*" diff --git a/Cargo.toml b/Cargo.toml index c8e6bd76..7286e69c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,6 @@ members = [ "src/sacp-derive", "src/sacp-tokio", "src/sacp-rmcp", - "src/elizacp", "src/sacp-test", "src/yopo", "src/sacp-trace-viewer", diff --git a/README.md b/README.md index 142cf71e..183abdf8 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ This repository contains several crates: - **[`sacp-proxy`](./src/sacp-proxy/)** - Framework for building ACP proxy components - **[`sacp-conductor`](./src/sacp-conductor/)** - Binary that orchestrates proxy chains -**Examples & Testing:** -- **[`elizacp`](./src/elizacp/)** - Example ACP agent implementing the classic Eliza chatbot (useful for testing) +**Testing:** +- **[`sacp-test`](./src/sacp-test/)** - Test utilities, fixtures, and a built-in test agent ## Documentation diff --git a/justfile b/justfile index 12114599..fb8bac7c 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,7 @@ # Build binaries needed for integration tests prep-tests: cargo build -p sacp-conductor - cargo build -p elizacp + cargo build -p sacp-test --bin testy cargo build -p sacp-test --bin mcp-echo-server --example arrow_proxy # Run all tests (requires prep-tests first) diff --git a/md/introduction.md b/md/introduction.md index bbee9037..0f2688a4 100644 --- a/md/introduction.md +++ b/md/introduction.md @@ -28,7 +28,6 @@ src/ ├── sacp-conductor/ # Conductor binary and library ├── sacp-test/ # Test utilities and fixtures ├── sacp-trace-viewer/ # Trace visualization tool -├── elizacp/ # Example agent implementation └── yopo/ # "You Only Prompt Once" example client ``` diff --git a/src/elizacp/CHANGELOG.md b/src/elizacp/CHANGELOG.md deleted file mode 100644 index 210fadf8..00000000 --- a/src/elizacp/CHANGELOG.md +++ /dev/null @@ -1,271 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [12.0.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v11.0.0...elizacp-v12.0.0) - 2026-01-19 - -### Fixed - -- *(sacp)* revert accidental JrMessageHandler and JrRequestCx renames - -### Other - -- go back from `connect_from` to `builder` -- *(sacp)* [**breaking**] rename *_cx variables to descriptive names -- *(sacp)* [**breaking**] rename Serve to ConnectTo for clearer semantics -- *(sacp)* [**breaking**] replace JrLink/JrPeer with unified Role-based API -- *(sacp)* rename context types for clarity -- *(sacp)* rename Jr* traits to JsonRpc* for clarity - -## [11.0.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v10.0.0...elizacp-v11.0.0) - 2025-12-31 - -### Added - -- *(elizacp)* implement Eliza algorithm based on the original style - -## [10.0.0-alpha.4](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v10.0.0-alpha.3...elizacp-v10.0.0-alpha.4) - 2025-12-30 - -### Added - -- *(deps)* [**breaking**] upgrade agent-client-protocol-schema to 0.10.5 - -## [10.0.0-alpha.3](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v10.0.0-alpha.2...elizacp-v10.0.0-alpha.3) - 2025-12-29 - -### Other - -- updated the following local packages: sacp, sacp-tokio - -## [10.0.0-alpha.2](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v10.0.0-alpha.1...elizacp-v10.0.0-alpha.2) - 2025-12-29 - -### Other - -- updated the following local packages: sacp, sacp-tokio - -## [10.0.0-alpha.1](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v9.0.0...elizacp-v10.0.0-alpha.1) - 2025-12-28 - -### Other - -- [**breaking**] split peer.rs into separate peer and link modules -- [**breaking**] update module and documentation references from role to peer -- [**breaking**] give component a link -- update UntypedRole to UntypedRole in doc examples -- *(sacp)* rename with_client to run_until -- update references for renamed methods - -## [9.0.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v8.0.0...elizacp-v9.0.0) - 2025-12-19 - -### Added - -- *(sacp)* [**breaking**] require Send for JrMessageHandler with boxing witness macros -- *(sacp)* [**breaking**] add scoped lifetime support for MCP servers - -### Other - -- Merge pull request #88 from nikomatsakis/main - -## [8.0.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v7.0.0...elizacp-v8.0.0) - 2025-12-17 - -### Other - -- updated the following local packages: sacp, sacp-tokio - -## [6.0.1](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v6.0.0...elizacp-v6.0.1) - 2025-12-17 - -### Other - -- updated the following local packages: sacp, sacp-tokio - -## [4.0.1](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v4.0.0...elizacp-v4.0.1) - 2025-12-15 - -### Other - -- updated the following local packages: sacp, sacp-tokio - -## [4.0.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v3.0.1...elizacp-v4.0.0) - 2025-12-12 - -### Added - -- [**breaking**] introduce role-based connection API - -## [3.0.1](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v3.0.0...elizacp-v3.0.1) - 2025-11-25 - -### Fixed - -- *(elizacp)* spawn prompt processing to avoid blocking event loop - -### Other - -- Merge pull request #60 from nikomatsakis/main - -## [3.0.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v2.0.1...elizacp-v3.0.0) - 2025-11-25 - -### Added - -- *(elizacp)* add HTTP MCP server support and update tests to use HTTP bridge - -## [2.0.1](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v2.0.0...elizacp-v2.0.1) - 2025-11-23 - -### Fixed - -- *(elizacp)* support hyphens in MCP server and tool names - -## [2.0.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.2.0...elizacp-v2.0.0) - 2025-11-23 - -### Other - -- *(elizacp)* export ElizaAgent as public Component - -## [1.2.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.1.0...elizacp-v1.2.0) - 2025-11-22 - -### Added - -- *(elizacp)* add MCP tools/list support and refactor client handling - -### Fixed - -- update Stdio usage to Stdio::new() after API change - -## [1.1.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.0.0...elizacp-v1.1.0) - 2025-11-17 - -### Added - -- *(sacp-test)* add mcp-echo-server binary for testing -- *(elizacp)* add MCP tool invocation support for testing - -### Fixed - -- *(elizacp)* update tool name regex to match MCP spec -- *(elizacp)* allow hyphens in MCP server and tool names - -### Other - -- Revert "fix(elizacp): allow hyphens in MCP server and tool names" -- *(test)* use structured SessionNotification instead of debug strings -- *(elizacp)* extract text content only to avoid UUID flakiness -- *(elizacp)* convert to expect_test for clearer output verification -- *(elizacp)* add integration test for MCP tool invocation - -## [1.0.0](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.0.0-alpha.8...elizacp-v1.0.0) - 2025-11-13 - -### Other - -- updated the following local packages: sacp, sacp-tokio - -## [1.0.0-alpha.8](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.0.0-alpha.7...elizacp-v1.0.0-alpha.8) - 2025-11-12 - -### Other - -- Merge pull request #30 from nikomatsakis/main -- *(sacp)* remove Deref impl from JrRequestCx - -## [1.0.0-alpha.7](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.0.0-alpha.6...elizacp-v1.0.0-alpha.7) - 2025-11-12 - -### Other - -- Merge pull request #28 from nikomatsakis/main - -## [1.0.0-alpha.6](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.0.0-alpha.5...elizacp-v1.0.0-alpha.6) - 2025-11-11 - -### Other - -- updated the following local packages: sacp, sacp-tokio - -## [1.0.0-alpha.5](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.0.0-alpha.4...elizacp-v1.0.0-alpha.5) - 2025-11-11 - -### Other - -- convert Stdio to unit struct for easier reference - -## [1.0.0-alpha.4](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.0.0-alpha.3...elizacp-v1.0.0-alpha.4) - 2025-11-11 - -### Other - -- cleanup and simplify some of the logic to avoid "indirection" through - -## [1.0.0-alpha.3](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.0.0-alpha.2...elizacp-v1.0.0-alpha.3) - 2025-11-09 - -### Other - -- updated the following local packages: sacp - -## [1.0.0-alpha.2](https://github.com/symposium-dev/symposium-acp/compare/elizacp-v1.0.0-alpha.1...elizacp-v1.0.0-alpha.2) - 2025-11-08 - -### Other - -- Merge pull request #16 from nikomatsakis/main -- wip wip wip -- [**breaking**] remove Unpin bounds and simplify transport API - -## [1.0.0-alpha.1](https://github.com/symposium-dev/symposium-acp/releases/tag/elizacp-v1.0.0-alpha.1) - 2025-11-05 - -### Added - -- *(elizacp)* add Eliza chatbot as ACP agent for testing - -### Fixed - -- fix github url -- *(elizacp)* exclude punctuation from pattern captures - -### Other - -- update all versions from 1.0.0-alpha to 1.0.0-alpha.1 -- release v1.0.0-alpha -- *(conductor)* add mock component tests for nested conductors -- bump all packages to version 1.0.0-alpha -- *(sacp)* [**breaking**] reorganize modules with flat schema namespace -- release -- *(elizacp)* add module-level documentation to main.rs -- upgrade to latest version of depdencies -- *(elizacp)* release v0.1.0 -- *(elizacp)* prepare for crates.io publication -- cleanup the source to make it prettier as an example -- *(elizacp)* use expect_test for exact output verification -- *(elizacp)* use seeded RNG for deterministic responses - -## [1.0.0-alpha](https://github.com/symposium-dev/symposium-acp/releases/tag/elizacp-v1.0.0-alpha) - 2025-11-05 - -### Added - -- *(elizacp)* add Eliza chatbot as ACP agent for testing - -### Fixed - -- fix github url -- *(elizacp)* exclude punctuation from pattern captures - -### Other - -- *(conductor)* add mock component tests for nested conductors -- bump all packages to version 1.0.0-alpha -- *(sacp)* [**breaking**] reorganize modules with flat schema namespace -- release -- *(elizacp)* add module-level documentation to main.rs -- upgrade to latest version of depdencies -- *(elizacp)* release v0.1.0 -- *(elizacp)* prepare for crates.io publication -- cleanup the source to make it prettier as an example -- *(elizacp)* use expect_test for exact output verification -- *(elizacp)* use seeded RNG for deterministic responses - -## [0.1.0](https://github.com/symposium-dev/symposium-acp/releases/tag/elizacp-v0.1.0) - 2025-10-31 - -### Added - -- *(elizacp)* add Eliza chatbot as ACP agent for testing - -### Fixed - -- *(elizacp)* exclude punctuation from pattern captures - -### Other - -- *(elizacp)* prepare for crates.io publication -- cleanup the source to make it prettier as an example -- *(elizacp)* use expect_test for exact output verification -- *(elizacp)* use seeded RNG for deterministic responses diff --git a/src/elizacp/Cargo.toml b/src/elizacp/Cargo.toml deleted file mode 100644 index 4828c45a..00000000 --- a/src/elizacp/Cargo.toml +++ /dev/null @@ -1,41 +0,0 @@ -[package] -name = "elizacp" -version = "12.0.0" -edition = "2024" -description = "Classic Eliza chatbot as an ACP agent for testing" -license = "MIT OR Apache-2.0" -repository = "https://github.com/symposium-dev/symposium-acp" -keywords = ["acp", "agent", "eliza", "testing"] -categories = ["development-tools"] -authors = ["Niko Matsakis "] -readme = "README.md" - -[[bin]] -name = "elizacp" -path = "src/main.rs" -test = false - -[dependencies] -sacp = { version = "11.0.0", path = "../sacp" } -agent-client-protocol-schema.workspace = true -anyhow.workspace = true -clap.workspace = true -futures.workspace = true -rand = "0.9" -regex = "1.12" -rmcp = { workspace = true, features = ["client", "transport-child-process", "transport-streamable-http-client-reqwest"] } -serde = { workspace = true, features = ["derive"] } -toml = "0.8" -serde_json.workspace = true -tokio.workspace = true -tokio-util.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -uuid.workspace = true -sacp-tokio = { version = "11.0.0", path = "../sacp-tokio" } -ratatui = "0.30.0" -crossterm = "0.29.0" - -[dev-dependencies] -expect-test.workspace = true -sacp-test = { path = "../sacp-test" } diff --git a/src/elizacp/README.md b/src/elizacp/README.md deleted file mode 100644 index 26a5b6ec..00000000 --- a/src/elizacp/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# elizacp - -A classic Eliza chatbot implemented as an ACP (Agent-Client Protocol) agent. - -## Overview - -Elizacp provides a simple, predictable agent implementation that's useful for: - -- **Testing ACP clients** - Lightweight agent with deterministic pattern-based responses -- **Protocol development** - Verify ACP implementations without heavy AI infrastructure -- **Learning ACP** - Clean example of implementing the Agent-Client Protocol - -## Features - -- **Classic Eliza patterns** - Pattern matching and reflection-based responses -- **Full ACP support** - Session management, initialization, and prompt handling -- **Per-session state** - Each session maintains its own Eliza instance -- **Extensible patterns** - Easy to add new response patterns (including future tool use triggers) - -## Usage - -### Running the agent - -```bash -# Build and run -cargo run -p elizacp - -# With debug logging -cargo run -p elizacp -- --debug -``` - -The agent communicates over stdin/stdout using JSON-RPC, following the ACP specification. - -### Testing with an ACP client - -Elizacp responds to: - -1. **Initialize requests** - Returns capabilities -2. **New/Load session requests** - Creates session state -3. **Prompt requests** - Responds with Eliza-style conversational replies - -Example conversation: -``` -User: Hello -Eliza: Hello. How are you feeling today? - -User: I am sad -Eliza: Do you often feel sad? - -User: I feel worried about my father -Eliza: Tell me more about your family. -``` - -## Implementation - -- `eliza.rs` - Pattern matching engine with classic Eliza responses -- `main.rs` - ACP agent implementation over stdio - -## Architecture - -The agent maintains a `HashMap` to track per-session state. Each session gets its own Eliza instance with independent conversation state. - -## Future Extensions - -The pattern database structure is designed to support: -- Tool use triggers (e.g., "what's the weather" → tool call) -- Custom response patterns -- Conversation history tracking -- Multi-turn context awareness diff --git a/src/elizacp/src/chat.rs b/src/elizacp/src/chat.rs deleted file mode 100644 index 10c8e715..00000000 --- a/src/elizacp/src/chat.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! Simple TUI chat interface for Eliza. -//! -//! Run with: `cargo run -p elizacp -- chat` - -use crate::eliza::Eliza; -use crossterm::{ - ExecutableCommand, - event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind, - }, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; -use ratatui::{ - prelude::*, - widgets::{Block, Borders, Paragraph, Wrap}, -}; -use std::io::{self, stdout}; - -/// A single message in the chat history -struct Message { - text: String, - is_user: bool, -} - -/// Run the chat TUI -pub fn run(deterministic: bool) -> anyhow::Result<()> { - // Setup terminal - stdout().execute(EnterAlternateScreen)?; - stdout().execute(EnableMouseCapture)?; - enable_raw_mode()?; - - let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - terminal.clear()?; - - let result = run_app(&mut terminal, deterministic); - - // Restore terminal - disable_raw_mode()?; - stdout().execute(DisableMouseCapture)?; - stdout().execute(LeaveAlternateScreen)?; - - result -} - -fn run_app( - terminal: &mut Terminal>, - deterministic: bool, -) -> anyhow::Result<()> { - let mut eliza = if deterministic { - Eliza::new_deterministic() - } else { - Eliza::new() - }; - let mut messages: Vec = vec![Message { - text: "Hello, I am Eliza. How can I help you today?".to_string(), - is_user: false, - }]; - let mut input = String::new(); - let mut scroll_offset: i32 = 0; // negative = scrolled up from bottom - - loop { - // Draw UI - terminal.draw(|frame| { - let area = frame.area(); - - // Split into messages area and input area - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(3), Constraint::Length(3)]) - .split(area); - - // Messages area - let messages_text: String = messages - .iter() - .map(|m| { - if m.is_user { - format!("You: {}", m.text) - } else { - format!("Eliza: {}", m.text) - } - }) - .collect::>() - .join("\n\n"); - - // Calculate scroll to show latest messages with some padding - let visible_height = chunks[0].height.saturating_sub(2) as usize; // -2 for borders - let inner_width = chunks[0].width.saturating_sub(2) as usize; // -2 for borders - - // Count wrapped lines (approximate - each line wraps based on width) - let wrapped_line_count: usize = messages_text - .lines() - .map(|line| { - if line.is_empty() { - 1 - } else { - (line.len() + inner_width - 1) / inner_width - } - }) - .sum(); - - // Scroll so last message ends up in the middle of the visible area - let padding = visible_height / 2; - let auto_scroll = (wrapped_line_count + padding).saturating_sub(visible_height) as i32; - let scroll = (auto_scroll + scroll_offset).max(0) as u16; - - let messages_paragraph = Paragraph::new(messages_text) - .block(Block::default().borders(Borders::ALL).title("Chat")) - .wrap(Wrap { trim: false }) - .scroll((scroll, 0)); - - frame.render_widget(messages_paragraph, chunks[0]); - - // Input area - let input_paragraph = Paragraph::new(input.as_str()).block( - Block::default() - .borders(Borders::ALL) - .title("Type here (Enter to send, Esc to quit)"), - ); - - frame.render_widget(input_paragraph, chunks[1]); - - // Position cursor at end of input - frame.set_cursor_position((chunks[1].x + input.len() as u16 + 1, chunks[1].y + 1)); - })?; - - // Handle input - if event::poll(std::time::Duration::from_millis(100))? { - match event::read()? { - Event::Key(key) if key.kind == KeyEventKind::Press => { - match key.code { - KeyCode::Esc => break, - KeyCode::Enter => { - if !input.is_empty() { - // Add user message - messages.push(Message { - text: input.clone(), - is_user: true, - }); - - // Get Eliza's response - let response = eliza.respond(&input); - messages.push(Message { - text: response, - is_user: false, - }); - - input.clear(); - scroll_offset = 0; // Reset scroll on new message - } - } - KeyCode::Backspace => { - input.pop(); - } - KeyCode::Char(c) => { - input.push(c); - } - KeyCode::PageUp => { - scroll_offset -= 10; - } - KeyCode::PageDown => { - scroll_offset = (scroll_offset + 10).min(0); - } - KeyCode::Up => { - scroll_offset -= 1; - } - KeyCode::Down => { - scroll_offset = (scroll_offset + 1).min(0); - } - _ => {} - } - } - Event::Mouse(mouse) => match mouse.kind { - MouseEventKind::ScrollUp => { - scroll_offset -= 3; - } - MouseEventKind::ScrollDown => { - scroll_offset = (scroll_offset + 3).min(0); - } - _ => {} - }, - _ => {} - } - } - } - - Ok(()) -} diff --git a/src/elizacp/src/eliza.rs b/src/elizacp/src/eliza.rs deleted file mode 100644 index b1ed2ef4..00000000 --- a/src/elizacp/src/eliza.rs +++ /dev/null @@ -1,2496 +0,0 @@ -//! ELIZA chatbot implementation based on Weizenbaum's 1966 algorithm. -//! -//! This module implements the classic ELIZA pattern-matching algorithm -//! as described in the January 1966 CACM paper, with a modernized script -//! format (TOML instead of S-expressions) and normal case text. - -use rand::rngs::StdRng; -use rand::{Rng, SeedableRng}; -use serde::Deserialize; -use std::collections::HashMap; - -// ============================================================================= -// Data Structures -// ============================================================================= - -/// A decomposition pattern element -#[derive(Debug, Clone)] -enum PatternElement { - /// Match this exact word (case-insensitive) - Exact(String), - /// Match exactly N words - Count(usize), - /// Match N to M words (inclusive) - Range(usize, usize), - /// Match 0 or more words (wildcard) - Wildcard, - /// Match any of these words - AnyOf(Vec), - /// Match any word with this tag - Tag(String), -} - -/// A reassembly rule element -#[derive(Debug, Clone)] -enum ReassemblyElement { - /// Literal text - Text(String), - /// Reference to matched component (1-indexed), no reflection - ComponentRef(usize), - /// Reference to matched component (1-indexed), with pronoun reflection - ComponentRefReflect(usize), -} - -/// Special reassembly actions -#[derive(Debug, Clone)] -enum ReassemblyAction { - /// Normal reassembly with elements - Reassemble(Vec), - /// Jump to another keyword - Goto(String), - /// Try next keyword in stack - Newkey, -} - -/// A decomposition/reassembly pair -#[derive(Debug, Clone)] -struct Transform { - decomposition: Vec, - reassembly_rules: Vec, -} - -/// A keyword rule -#[derive(Debug, Clone)] -struct Keyword { - /// Fix typos/contractions - applied during tokenization (e.g., "dont" -> "don't") - repair: Option, - /// Priority for keyword selection (higher = more important) - precedence: u32, - /// Direct link to another keyword (equivalence class) - link: Option, - /// Transformation rules - transforms: Vec, -} - -/// Memory rule for storing and recalling statements -#[derive(Debug, Clone)] -struct MemoryRule { - /// The keyword that triggers memory creation (usually "my") - keyword: String, - /// Exactly 4 transforms for memory creation - transforms: Vec, -} - -/// Pronoun reflections (I -> you, my -> your, etc.) -#[derive(Debug, Clone)] -struct Reflections { - map: HashMap, -} - -/// The complete ELIZA script (immutable after loading) -#[derive(Debug, Clone)] -struct Script { - hello_message: String, - keywords: HashMap, - memory_rule: Option, - /// Maps tag names to words that have that tag - tags: HashMap>, - reflections: Reflections, -} - -/// Mutable runtime state for ELIZA -#[derive(Debug, Clone)] -struct State { - /// LIMIT counter (1-4, affects memory recall) - limit: u8, - /// Cycling state for reassembly rules: (keyword, transform_index) -> next_rule - rule_indices: HashMap<(String, usize), usize>, - /// Queue of stored memories for later recall - memories: Vec, -} - -// ============================================================================= -// TOML Schema -// ============================================================================= - -#[derive(Debug, Deserialize)] -struct TomlScript { - hello: String, - #[serde(default)] - keywords: Vec, - memory: Option, -} - -#[derive(Debug, Deserialize)] -struct TomlKeyword { - word: String, - /// Fix typos/contractions - applied during tokenization (e.g., "dont" -> "don't") - #[serde(default)] - repair: Option, - /// Pronoun reflection - only used during reassembly with {N:r} (e.g., "you" -> "i") - #[serde(default)] - reflection: Option, - #[serde(default)] - precedence: u32, - #[serde(default)] - tags: Vec, - #[serde(default)] - link: Option, - #[serde(default)] - transforms: Vec, -} - -#[derive(Debug, Deserialize)] -struct TomlTransform { - decomposition: Vec, - #[serde(default)] - reassembly: Vec, -} - -#[derive(Debug, Deserialize)] -struct TomlMemory { - keyword: String, - transforms: Vec, -} - -#[derive(Debug, Deserialize)] -struct TomlMemoryTransform { - decomposition: Vec, - reassembly: String, -} - -// ============================================================================= -// Parsing -// ============================================================================= - -impl PatternElement { - fn parse(s: &str) -> Self { - let s = s.trim(); - - // Wildcard: ".." - if s == ".." { - return PatternElement::Wildcard; - } - - // Range: "1..3" - if let Some((left, right)) = s.split_once("..") { - if let (Ok(n), Ok(m)) = (left.parse::(), right.parse::()) { - return PatternElement::Range(n, m); - } - } - - // Exact count: "2" - if let Ok(n) = s.parse::() { - return PatternElement::Count(n); - } - - // Tag: "#FAMILY" - if let Some(tag) = s.strip_prefix('#') { - return PatternElement::Tag(tag.to_lowercase()); - } - - // AnyOf: "{WANT, NEED}" - if s.starts_with('{') && s.ends_with('}') { - let inner = &s[1..s.len() - 1]; - let words: Vec = inner - .split(',') - .map(|w| w.trim().to_lowercase()) - .filter(|w| !w.is_empty()) - .collect(); - return PatternElement::AnyOf(words); - } - - // Exact word match - PatternElement::Exact(s.to_lowercase()) - } -} - -impl ReassemblyAction { - fn parse(s: &str) -> Self { - let s = s.trim(); - - // Goto: "=WHAT" - if let Some(target) = s.strip_prefix('=') { - return ReassemblyAction::Goto(target.to_lowercase()); - } - - // Newkey - if s.eq_ignore_ascii_case("NEWKEY") { - return ReassemblyAction::Newkey; - } - - // Normal reassembly - parse for {N} references - let mut elements = Vec::new(); - let mut current_text = String::new(); - let mut chars = s.chars().peekable(); - - while let Some(c) = chars.next() { - if c == '{' { - // Might be a component reference: {N} or {N:r} - let mut num_str = String::new(); - let mut reflect = false; - - while let Some(&next) = chars.peek() { - if next == '}' { - chars.next(); - break; - } - if next.is_ascii_digit() { - num_str.push(chars.next().unwrap()); - } else if next == ':' && !num_str.is_empty() { - // Check for :r suffix - chars.next(); // consume ':' - if chars.peek() == Some(&'r') { - chars.next(); // consume 'r' - reflect = true; - } - } else { - // Not a valid reference, treat as literal - current_text.push('{'); - current_text.push_str(&num_str); - num_str.clear(); - break; - } - } - if !num_str.is_empty() { - if let Ok(n) = num_str.parse::() { - if !current_text.is_empty() { - elements.push(ReassemblyElement::Text(current_text.clone())); - current_text.clear(); - } - if reflect { - elements.push(ReassemblyElement::ComponentRefReflect(n)); - } else { - elements.push(ReassemblyElement::ComponentRef(n)); - } - continue; - } - } - } else { - current_text.push(c); - } - } - if !current_text.is_empty() { - elements.push(ReassemblyElement::Text(current_text)); - } - - ReassemblyAction::Reassemble(elements) - } -} - -impl Script { - fn from_toml(toml_str: &str) -> Result { - let toml_script: TomlScript = toml::from_str(toml_str)?; - - let mut keywords = HashMap::new(); - let mut tags: HashMap> = HashMap::new(); - let mut reflections_from_script: HashMap = HashMap::new(); - - for tk in toml_script.keywords { - let word = tk.word.to_lowercase(); - - // Collect tags - for tag in &tk.tags { - tags.entry(tag.to_lowercase()) - .or_default() - .push(word.clone()); - } - - let transforms: Vec = tk - .transforms - .into_iter() - .map(|t| Transform { - decomposition: t - .decomposition - .iter() - .map(|s| PatternElement::parse(s)) - .collect(), - reassembly_rules: t - .reassembly - .iter() - .map(|s| ReassemblyAction::parse(s)) - .collect(), - }) - .collect(); - - let keyword = Keyword { - repair: tk.repair.map(|s| s.to_lowercase()), - precedence: tk.precedence, - link: tk.link.map(|s| s.to_lowercase()), - transforms, - }; - - // Build reflection map from keywords that have reflection defined - if let Some(ref reflection) = tk.reflection { - reflections_from_script.insert(word.clone(), reflection.to_lowercase()); - } - - keywords.insert(word, keyword); - } - - let memory_rule = toml_script.memory.map(|m| { - let transforms = m - .transforms - .into_iter() - .map(|t| Transform { - decomposition: t - .decomposition - .iter() - .map(|s| PatternElement::parse(s)) - .collect(), - reassembly_rules: vec![ReassemblyAction::parse(&t.reassembly)], - }) - .collect(); - MemoryRule { - keyword: m.keyword.to_lowercase(), - transforms, - } - }); - - // Merge script reflections with defaults (script takes precedence) - let mut reflections = Reflections::default(); - for (word, reflection) in reflections_from_script { - reflections.map.insert(word, reflection); - } - - Ok(Script { - hello_message: toml_script.hello, - keywords, - memory_rule, - tags, - reflections, - }) - } -} - -impl Default for Reflections { - fn default() -> Self { - let mut map = HashMap::new(); - - // First person -> second person - map.insert("i".to_string(), "you".to_string()); - map.insert("i'm".to_string(), "you're".to_string()); - map.insert("i've".to_string(), "you've".to_string()); - map.insert("i'll".to_string(), "you'll".to_string()); - map.insert("i'd".to_string(), "you'd".to_string()); - map.insert("me".to_string(), "you".to_string()); - map.insert("my".to_string(), "your".to_string()); - map.insert("mine".to_string(), "yours".to_string()); - map.insert("myself".to_string(), "yourself".to_string()); - map.insert("am".to_string(), "are".to_string()); - map.insert("we".to_string(), "you".to_string()); - map.insert("our".to_string(), "your".to_string()); - map.insert("ours".to_string(), "yours".to_string()); - map.insert("ourselves".to_string(), "yourselves".to_string()); - map.insert("was".to_string(), "were".to_string()); - - // Second person -> first person - map.insert("you".to_string(), "I".to_string()); - map.insert("you're".to_string(), "I'm".to_string()); - map.insert("you've".to_string(), "I've".to_string()); - map.insert("you'll".to_string(), "I'll".to_string()); - map.insert("you'd".to_string(), "I'd".to_string()); - map.insert("your".to_string(), "my".to_string()); - map.insert("yours".to_string(), "mine".to_string()); - map.insert("yourself".to_string(), "myself".to_string()); - - Self { map } - } -} - -impl Reflections { - fn reflect(&self, text: &str) -> String { - text.split_whitespace() - .map(|word| { - let lower = word.to_lowercase(); - // Strip punctuation for lookup - let (base, punct) = if lower.ends_with(|c: char| c.is_ascii_punctuation()) { - let punct = &lower[lower.len() - 1..]; - (&lower[..lower.len() - 1], punct) - } else { - (lower.as_str(), "") - }; - - if let Some(reflected) = self.map.get(base) { - format!("{}{}", reflected, punct) - } else { - word.to_string() - } - }) - .collect::>() - .join(" ") - } -} - -// ============================================================================= -// Core Algorithm -// ============================================================================= - -/// Result of attempting a transformation -enum TransformResult { - /// Successfully generated a response - Complete(String), - /// Follow a link to another keyword - Goto(String, Vec), - /// Try the next keyword - Newkey, - /// No decomposition matched - NoMatch, -} - -impl Script { - /// Try to match a decomposition pattern against words - fn match_pattern(&self, pattern: &[PatternElement], words: &[String]) -> Option> { - self.match_pattern_recursive(pattern, words, Vec::new()) - } - - fn match_pattern_recursive( - &self, - pattern: &[PatternElement], - words: &[String], - mut components: Vec, - ) -> Option> { - if pattern.is_empty() { - return if words.is_empty() { - Some(components) - } else { - None - }; - } - - let elem = &pattern[0]; - let rest_pattern = &pattern[1..]; - - match elem { - PatternElement::Exact(expected) => { - if words.is_empty() || words[0].to_lowercase() != *expected { - return None; - } - components.push(words[0].clone()); - self.match_pattern_recursive(rest_pattern, &words[1..], components) - } - - PatternElement::Count(n) => { - if words.len() < *n { - return None; - } - components.push(words[..*n].join(" ")); - self.match_pattern_recursive(rest_pattern, &words[*n..], components) - } - - PatternElement::Range(min, max) => { - for n in (*min..=*max).rev() { - if words.len() >= n { - let mut new_components = components.clone(); - new_components.push(words[..n].join(" ")); - if let Some(result) = - self.match_pattern_recursive(rest_pattern, &words[n..], new_components) - { - return Some(result); - } - } - } - None - } - - PatternElement::Wildcard => { - for n in 0..=words.len() { - let mut new_components = components.clone(); - new_components.push(words[..n].join(" ")); - if let Some(result) = - self.match_pattern_recursive(rest_pattern, &words[n..], new_components) - { - return Some(result); - } - } - None - } - - PatternElement::AnyOf(options) => { - if words.is_empty() { - return None; - } - let word_lower = words[0].to_lowercase(); - if !options.contains(&word_lower) { - return None; - } - components.push(words[0].clone()); - self.match_pattern_recursive(rest_pattern, &words[1..], components) - } - - PatternElement::Tag(tag) => { - if words.is_empty() { - return None; - } - let word_lower = words[0].to_lowercase(); - if !self - .tags - .get(tag) - .map_or(false, |tw| tw.contains(&word_lower)) - { - return None; - } - components.push(words[0].clone()); - self.match_pattern_recursive(rest_pattern, &words[1..], components) - } - } - } - - /// Reassemble a response from a rule and matched components - fn reassemble(&self, elements: &[ReassemblyElement], components: &[String]) -> String { - let mut result = String::new(); - for elem in elements { - match elem { - ReassemblyElement::Text(text) => { - result.push_str(text); - } - ReassemblyElement::ComponentRef(n) => { - if *n > 0 && *n <= components.len() { - result.push_str(&components[*n - 1]); - } - } - ReassemblyElement::ComponentRefReflect(n) => { - if *n > 0 && *n <= components.len() { - let reflected = self.reflections.reflect(&components[*n - 1]); - result.push_str(&reflected); - } - } - } - } - result - } - - /// Apply transformation for a keyword - fn apply_transform( - &self, - state: &mut State, - keyword_name: &str, - words: &[String], - ) -> TransformResult { - let keyword = match self.keywords.get(keyword_name) { - Some(k) => k, - None => return TransformResult::NoMatch, - }; - - // If keyword has a direct link and no transforms, follow it - if keyword.transforms.is_empty() { - if let Some(ref link) = keyword.link { - return TransformResult::Goto(link.clone(), words.to_vec()); - } - return TransformResult::NoMatch; - } - - // Try each transform's decomposition - for (transform_idx, transform) in keyword.transforms.iter().enumerate() { - if let Some(components) = self.match_pattern(&transform.decomposition, words) { - if transform.reassembly_rules.is_empty() { - // No reassembly rules - check for link - if let Some(ref link) = keyword.link { - return TransformResult::Goto(link.clone(), words.to_vec()); - } - continue; - } - - // Get next reassembly rule (cycling) - let state_key = (keyword_name.to_string(), transform_idx); - let rule_idx = *state.rule_indices.get(&state_key).unwrap_or(&0); - let next_idx = (rule_idx + 1) % transform.reassembly_rules.len(); - state.rule_indices.insert(state_key, next_idx); - - match &transform.reassembly_rules[rule_idx] { - ReassemblyAction::Reassemble(elements) => { - let response = self.reassemble(elements, &components); - return TransformResult::Complete(response); - } - ReassemblyAction::Goto(target) => { - return TransformResult::Goto(target.clone(), words.to_vec()); - } - ReassemblyAction::Newkey => { - return TransformResult::Newkey; - } - } - } - } - - // No decomposition matched - check for link - if let Some(ref link) = keyword.link { - return TransformResult::Goto(link.clone(), words.to_vec()); - } - - TransformResult::NoMatch - } - - /// Try to create a memory from input - fn try_create_memory(&self, state: &mut State, words: &[String]) { - let memory_rule = match &self.memory_rule { - Some(m) => m, - None => return, - }; - - // Check if memory keyword is in the words - if !words - .iter() - .any(|w| w.to_lowercase() == memory_rule.keyword) - { - return; - } - - // Use a hash-like selection for which transform to use - let transform_idx = if !words.is_empty() { - let last_word = words.last().unwrap(); - let hash: usize = last_word.bytes().map(|b| b as usize).sum(); - hash % memory_rule.transforms.len().max(1) - } else { - 0 - }; - - if transform_idx >= memory_rule.transforms.len() { - return; - } - - let transform = &memory_rule.transforms[transform_idx]; - if let Some(components) = self.match_pattern(&transform.decomposition, words) { - if let Some(ReassemblyAction::Reassemble(elements)) = transform.reassembly_rules.first() - { - let memory = self.reassemble(elements, &components); - state.memories.push(memory); - } - } - } -} - -// ============================================================================= -// Public API -// ============================================================================= - -/// The Eliza chatbot engine. -#[derive(Clone)] -pub struct Eliza { - script: Script, - state: State, -} - -impl Eliza { - /// Create a new Eliza instance with a random seed. - pub fn new() -> Self { - Self::with_seed(rand::random()) - } - - /// Create a new Eliza instance with a fixed seed for deterministic behavior. - pub fn new_deterministic() -> Self { - Self::with_seed(22) - } - - /// Create a new Eliza instance with a specific seed. - pub fn with_seed(seed: u64) -> Self { - let script = Script::from_toml(DOCTOR_SCRIPT).expect("Failed to parse built-in script"); - - // Initialize state with randomized starting positions for reassembly rule cycling - // Sort keywords by name for deterministic iteration order - let mut rng = StdRng::seed_from_u64(seed); - let mut rule_indices = HashMap::new(); - let mut keyword_names: Vec<_> = script.keywords.keys().collect(); - keyword_names.sort(); - for keyword_name in keyword_names { - let keyword = &script.keywords[keyword_name]; - for (transform_idx, transform) in keyword.transforms.iter().enumerate() { - if !transform.reassembly_rules.is_empty() { - let start = rng.random_range(0..transform.reassembly_rules.len()); - rule_indices.insert((keyword_name.clone(), transform_idx), start); - } - } - } - - Self { - script, - state: State { - limit: 0, - rule_indices, - memories: Vec::new(), - }, - } - } - - /// Get the hello message - pub fn hello(&self) -> &str { - &self.script.hello_message - } - - /// Generate a response to user input. - pub fn respond(&mut self, input: &str) -> String { - let input = input.trim(); - if input.is_empty() { - return "Please tell me more.".to_string(); - } - - // Update LIMIT counter (cycles 1-4) - self.state.limit = (self.state.limit % 4) + 1; - - // Tokenize and normalize - let words = self.tokenize(input); - if words.is_empty() { - return "Please tell me more.".to_string(); - } - - // Process the input - self.process_input(&words) - } - - /// Tokenize input into words - fn tokenize(&self, input: &str) -> Vec { - input - .split(|c: char| c.is_whitespace() || matches!(c, ',' | '.' | '?' | '!' | ';' | ':')) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() - } - - /// Process input and generate response - fn process_input(&mut self, original_words: &[String]) -> String { - // Apply repairs and build keystack (but NOT substitutions - those happen during reassembly) - let mut words = Vec::new(); - let mut keystack: Vec<(String, u32)> = Vec::new(); - - for word in original_words { - let word_lower = word.to_lowercase(); - - if let Some(keyword) = self.script.keywords.get(&word_lower) { - // Apply repair if any (e.g., "dont" -> "don't") - let repaired = keyword.repair.clone().unwrap_or_else(|| word_lower.clone()); - words.push(repaired); - - // Add to keystack if it has transforms or a link - if !keyword.transforms.is_empty() || keyword.link.is_some() { - keystack.push((word_lower.clone(), keyword.precedence)); - } - } else { - words.push(word_lower.clone()); - } - } - - // Sort keystack by precedence (highest first) - keystack.sort_by(|a, b| b.1.cmp(&a.1)); - - // If no keywords found, check memory or use NONE - if keystack.is_empty() { - // Check for memory recall (LIMIT=4 and memories exist) - if self.state.limit == 4 && !self.state.memories.is_empty() { - return self.state.memories.remove(0); // FIFO queue - } - - // Use NONE keyword - return self.apply_none(&words); - } - - // Try to create a memory if the memory keyword is present - self.script.try_create_memory(&mut self.state, &words); - - // Process keywords in order - let mut visited_keywords = Vec::new(); - for (keyword_name, _) in &keystack { - if let Some(response) = self.apply_keyword(keyword_name, &words, &mut visited_keywords) - { - return response; - } - } - - // Fallback to NONE - self.apply_none(&words) - } - - /// Apply a keyword transformation, following links as needed - fn apply_keyword( - &mut self, - keyword_name: &str, - words: &[String], - visited: &mut Vec, - ) -> Option { - // Prevent infinite loops - if visited.contains(&keyword_name.to_string()) { - return None; - } - visited.push(keyword_name.to_string()); - - // Limit link following depth - if visited.len() > 10 { - return None; - } - - match self - .script - .apply_transform(&mut self.state, keyword_name, words) - { - TransformResult::Complete(response) => Some(response), - TransformResult::Goto(target, new_words) => { - self.apply_keyword(&target, &new_words, visited) - } - TransformResult::Newkey => None, - TransformResult::NoMatch => None, - } - } - - /// Apply the NONE keyword (fallback) - fn apply_none(&mut self, words: &[String]) -> String { - if let Some(result) = self.apply_keyword("none", words, &mut Vec::new()) { - return result; - } - "I'm not sure I understand you fully.".to_string() - } -} - -impl Default for Eliza { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Debug for Eliza { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Eliza") - .field("state", &self.state) - .finish_non_exhaustive() - } -} - -// ============================================================================= -// Built-in DOCTOR Script -// ============================================================================= - -const DOCTOR_SCRIPT: &str = r##" -hello = "How do you do. Please tell me your problem." - -# Word substitutions (applied during input processing) -[[keywords]] -word = "dont" -repair = "don't" - -[[keywords]] -word = "cant" -repair = "can't" - -[[keywords]] -word = "wont" -repair = "won't" - -[[keywords]] -word = "dreamed" -repair = "dreamt" -link = "dreamt" - -# SORRY -[[keywords]] -word = "sorry" -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Please don't apologize.", - "Apologies are not necessary.", - "What feelings do you have when you apologize?", - "I've told you that apologies are not required.", -] - -# REMEMBER -[[keywords]] -word = "remember" -precedence = 5 -[[keywords.transforms]] -decomposition = ["..", "i", "remember", ".."] -reassembly = [ - "Do you often think of {4}?", - "Does thinking of {4} bring anything else to mind?", - "What else do you remember?", - "Why do you remember {4} just now?", - "What in the present situation reminds you of {4}?", - "What is the connection between me and {4}?", -] -[[keywords.transforms]] -decomposition = ["..", "do", "i", "remember", ".."] -reassembly = [ - "Did you think I would forget {5}?", - "Why do you think I should recall {5} now?", - "What about {5}?", - "=what", - "You mentioned {5}.", -] -[[keywords.transforms]] -decomposition = [".."] -reassembly = ["NEWKEY"] - -# IF -[[keywords]] -word = "if" -precedence = 3 -[[keywords.transforms]] -decomposition = ["..", "if", ".."] -reassembly = [ - "Do you think it's likely that {3}?", - "Do you wish that {3}?", - "What do you think about {3}?", - "Really, {2} {3}?", -] - -# DREAMT -[[keywords]] -word = "dreamt" -precedence = 4 -[[keywords.transforms]] -decomposition = ["..", "i", "dreamt", ".."] -reassembly = [ - "Really, {4}?", - "Have you ever fantasized {4} while you were awake?", - "Have you dreamt {4} before?", - "=dream", - "NEWKEY", -] - -# DREAM -[[keywords]] -word = "dream" -precedence = 3 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "What does that dream suggest to you?", - "Do you dream often?", - "What persons appear in your dreams?", - "Don't you believe that dream has something to do with your problem?", - "NEWKEY", -] - -[[keywords]] -word = "dreams" -link = "dream" - -# ALIKE / SAME -[[keywords]] -word = "alike" -precedence = 10 -link = "dit" - -[[keywords]] -word = "same" -precedence = 10 -link = "dit" - -# CERTAINLY -[[keywords]] -word = "certainly" -link = "yes" - -# PERHAPS -[[keywords]] -word = "perhaps" -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "You don't seem quite certain.", - "Why the uncertain tone?", - "Can't you be more positive?", - "You aren't sure.", - "Don't you know?", -] - -[[keywords]] -word = "maybe" -link = "perhaps" - -# NAME -[[keywords]] -word = "name" -precedence = 15 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "I am not interested in names.", - "I've told you before, I don't care about names. Please continue.", -] - -# HELLO -[[keywords]] -word = "hello" -[[keywords.transforms]] -decomposition = [".."] -reassembly = ["How do you do. Please state your problem."] - -[[keywords]] -word = "hi" -link = "hello" - -# COMPUTER -[[keywords]] -word = "computer" -precedence = 50 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Do computers worry you?", - "Why do you mention computers?", - "What do you think machines have to do with your problem?", - "Don't you think computers can help people?", - "What about machines worries you?", - "What do you think about machines?", -] - -[[keywords]] -word = "machine" -precedence = 50 -link = "computer" - -[[keywords]] -word = "machines" -precedence = 50 -link = "computer" - -[[keywords]] -word = "computers" -precedence = 50 -link = "computer" - -# AM (user says "am I...") -[[keywords]] -word = "am" -reflection = "are" -[[keywords.transforms]] -decomposition = ["..", "am", "i", ".."] -reassembly = [ - "Do you believe you are {4}?", - "Would you want to be {4}?", - "You wish I would tell you you are {4}.", - "What would it mean if you were {4}?", - "=what", -] -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Why do you say 'am'?", - "I don't understand that.", -] - -# ARE -[[keywords]] -word = "are" -[[keywords.transforms]] -decomposition = ["..", "are", "i", ".."] -reassembly = [ - "Why are you interested in whether I am {4} or not?", - "Would you prefer if I weren't {4}?", - "Perhaps I am {4} in your fantasies.", - "Do you sometimes think I am {4}?", - "=what", -] -[[keywords.transforms]] -decomposition = ["..", "are", ".."] -reassembly = [ - "Did you think they might not be {3}?", - "Would you like it if they were not {3}?", - "What if they were not {3}?", - "Possibly they are {3}.", -] - -# YOUR (user says "your" referring to Eliza) -[[keywords]] -word = "your" -reflection = "my" -[[keywords.transforms]] -decomposition = ["..", "your", ".."] -reassembly = [ - "Why are you concerned over my {3}?", - "What about your own {3}?", - "Are you worried about someone else's {3}?", - "Really, my {3}?", -] - -# WAS -[[keywords]] -word = "was" -precedence = 2 -[[keywords.transforms]] -decomposition = ["..", "was", "i", ".."] -reassembly = [ - "What if you were {4}?", - "Do you think you were {4}?", - "Were you {4}?", - "What would it mean if you were {4}?", - "What does '{4}' suggest to you?", - "=what", -] -[[keywords.transforms]] -decomposition = ["..", "i", "was", ".."] -reassembly = [ - "Were you really?", - "Why do you tell me you were {4} now?", - "Perhaps I already knew you were {4}.", -] -[[keywords.transforms]] -decomposition = ["..", "was", "you", ".."] -reassembly = [ - "Would you like to believe I was {4}?", - "What suggests that I was {4}?", - "What do you think?", - "Perhaps I was {4}.", - "What if I had been {4}?", -] -[[keywords.transforms]] -decomposition = [".."] -reassembly = ["NEWKEY"] - -[[keywords]] -word = "were" -link = "was" - -# I (user saying I) -[[keywords]] -word = "i" -reflection = "you" -[[keywords.transforms]] -decomposition = ["..", "i", "{want, need}", ".."] -reassembly = [ - "What would it mean to you if you got {4}?", - "Why do you want {4}?", - "Suppose you got {4} soon.", - "What if you never got {4}?", - "What would getting {4} mean to you?", - "What does wanting {4} have to do with this discussion?", -] -[[keywords.transforms]] -decomposition = ["..", "i", "am", "..", "{sad, unhappy, depressed, sick}", ".."] -reassembly = [ - "I am sorry to hear you are {5}.", - "Do you think coming here will help you not to be {5}?", - "I'm sure it's not pleasant to be {5}.", - "Can you explain what made you {5}?", -] -[[keywords.transforms]] -decomposition = ["..", "i", "am", "..", "{happy, elated, glad, better}", ".."] -reassembly = [ - "How have I helped you to be {5}?", - "Has your treatment made you {5}?", - "What makes you {5} just now?", - "Can you explain why you are suddenly {5}?", -] -[[keywords.transforms]] -decomposition = ["..", "i", "was", ".."] -reassembly = ["=was"] -[[keywords.transforms]] -decomposition = ["..", "i", "#belief", "i", ".."] -reassembly = [ - "Do you really think so?", - "But you are not sure you {5}.", - "Do you really doubt you {5}?", -] -[[keywords.transforms]] -decomposition = ["..", "i", "..", "#belief", "..", "you", ".."] -reassembly = ["=you"] -[[keywords.transforms]] -decomposition = ["..", "i", "am", ".."] -reassembly = [ - "Is it because you are {4} that you came to me?", - "How long have you been {4}?", - "Do you believe it is normal to be {4}?", - "Do you enjoy being {4}?", -] -[[keywords.transforms]] -decomposition = ["..", "i", "{can't, cannot}", ".."] -reassembly = [ - "How do you know you can't {4}?", - "Have you tried?", - "Perhaps you could {4} now.", - "Do you really want to be able to {4}?", -] -[[keywords.transforms]] -decomposition = ["..", "i", "don't", ".."] -reassembly = [ - "Don't you really {4}?", - "Why don't you {4}?", - "Do you wish to be able to {4}?", - "Does that trouble you?", -] -[[keywords.transforms]] -decomposition = ["..", "i", "feel", ".."] -reassembly = [ - "Tell me more about such feelings.", - "Do you often feel {4}?", - "Do you enjoy feeling {4}?", - "Of what does feeling {4} remind you?", -] -[[keywords.transforms]] -decomposition = ["..", "i", "..", "you", ".."] -reassembly = [ - "Perhaps in your fantasy we {3} each other.", - "Do you wish to {3} me?", - "You seem to need to {3} me.", - "Do you {3} anyone else?", -] -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "You say {1}.", - "Can you elaborate on that?", - "Do you say {1} for some special reason?", - "That's quite interesting.", -] - -# YOU (user said "you" referring to Eliza) -[[keywords]] -word = "you" -reflection = "i" -[[keywords.transforms]] -decomposition = ["..", "you", "remind", "me", "of", ".."] -reassembly = ["=dit"] -[[keywords.transforms]] -decomposition = ["..", "you", "are", ".."] -reassembly = [ - "What makes you think I am {4}?", - "Does it please you to believe I am {4}?", - "Do you sometimes wish you were {4}?", - "Perhaps you would like to be {4}.", -] -[[keywords.transforms]] -decomposition = ["..", "you", "..", "me"] -reassembly = [ - "Why do you think I {3} you?", - "You like to think I {3} you, don't you?", - "What makes you think I {3} you?", - "Really, I {3} you?", - "Do you wish to believe I {3} you?", - "Suppose I did {3} you. What would that mean?", - "Does someone else believe I {3} you?", -] -[[keywords.transforms]] -decomposition = ["..", "you", ".."] -reassembly = [ - "We were discussing you, not me.", - "Oh, I {3}?", - "You're not really talking about me, are you?", - "What are your feelings now?", -] - -# YES -[[keywords]] -word = "yes" -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "You seem quite positive.", - "You are sure.", - "I see.", - "I understand.", -] - -# NO -[[keywords]] -word = "no" -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Are you saying 'no' just to be negative?", - "You are being a bit negative.", - "Why not?", - "Why 'no'?", -] - -# MY (user says "my") -[[keywords]] -word = "my" -reflection = "your" -precedence = 2 -[[keywords.transforms]] -decomposition = ["..", "my", "..", "#family", ".."] -reassembly = [ - "Tell me more about your family.", - "Who else in your family {5}?", - "Your {4} {5}?", - "What else comes to mind when you think of your {4}?", -] -[[keywords.transforms]] -decomposition = ["..", "my", ".."] -reassembly = [ - "Your {3}, you say?", - "Why do you say your {3}?", - "Does that suggest anything else which belongs to you?", - "Is it important to you that {2} {3}?", -] - -# CAN -[[keywords]] -word = "can" -[[keywords.transforms]] -decomposition = ["..", "can", "i", ".."] -reassembly = [ - "You believe I can {4}, don't you?", - "=what", - "You want me to be able to {4}.", - "Perhaps you would like to be able to {4} yourself.", -] -[[keywords.transforms]] -decomposition = ["..", "can", "you", ".."] -reassembly = [ - "Whether or not you can {4} depends on you more than on me.", - "Do you want to be able to {4}?", - "Perhaps you don't want to {4}.", - "=what", -] - -# WHAT -[[keywords]] -word = "what" -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Why do you ask?", - "Does that question interest you?", - "What is it you really want to know?", - "Are such questions much on your mind?", - "What answer would please you most?", - "What do you think?", - "What comes to your mind when you ask that?", - "Have you asked such questions before?", - "Have you asked anyone else?", -] - -[[keywords]] -word = "how" -precedence = 5 -[[keywords.transforms]] -decomposition = ["..", "how", "are", "you", ".."] -reassembly = [ - "I'm doing well, thank you for asking. But we're here to talk about you.", - "I'm just a program, but I appreciate you asking. How are you?", - "Fine, thank you. Now, what's on your mind?", - "I don't have feelings, but I'm functioning well. What about you?", -] -[[keywords.transforms]] -decomposition = [".."] -reassembly = ["=what"] - -[[keywords]] -word = "when" -link = "what" - -[[keywords]] -word = "where" -link = "what" - -[[keywords]] -word = "why" -[[keywords.transforms]] -decomposition = ["..", "why", "don't", "you", ".."] -reassembly = [ - "Do you believe I don't {5}?", - "Perhaps I will {5} in good time.", - "Should you {5} yourself?", - "You want me to {5}.", - "=what", -] -[[keywords.transforms]] -decomposition = ["..", "why", "can't", "i", ".."] -reassembly = [ - "Do you think you should be able to {5}?", - "Do you want to be able to {5}?", - "Do you believe this will help you to {5}?", - "Have you any idea why you can't {5}?", - "=what", -] -[[keywords.transforms]] -decomposition = [".."] -reassembly = ["=what"] - -# BECAUSE -[[keywords]] -word = "because" -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Is that the real reason?", - "Don't any other reasons come to mind?", - "Does that reason seem to explain anything else?", - "What other reasons might there be?", -] - -# EVERYONE / EVERYBODY / NOBODY -[[keywords]] -word = "everyone" -precedence = 2 -[[keywords.transforms]] -decomposition = ["..", "{everyone, everybody, nobody, noone}", ".."] -reassembly = [ - "Really, {2}?", - "Surely not {2}.", - "Can you think of anyone in particular?", - "Who, for example?", - "You are thinking of a very special person.", - "Who, may I ask?", - "Someone special perhaps.", - "You have a particular person in mind, don't you?", - "Who do you think you're talking about?", -] - -[[keywords]] -word = "everybody" -precedence = 2 -link = "everyone" - -[[keywords]] -word = "nobody" -precedence = 2 -link = "everyone" - -[[keywords]] -word = "noone" -precedence = 2 -link = "everyone" - -# ALWAYS -[[keywords]] -word = "always" -precedence = 1 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Can you think of a specific example?", - "When?", - "What incident are you thinking of?", - "Really, always?", -] - -# LIKE -[[keywords]] -word = "like" -precedence = 10 -[[keywords.transforms]] -decomposition = ["..", "{am, is, are, was}", "..", "like", ".."] -reassembly = ["=dit"] -[[keywords.transforms]] -decomposition = [".."] -reassembly = ["NEWKEY"] - -# DIT (equivalence class for similarity) -[[keywords]] -word = "dit" -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "In what way?", - "What resemblance do you see?", - "What does that similarity suggest to you?", - "What other connections do you see?", - "What do you suppose that resemblance means?", - "What is the connection, do you suppose?", - "Could there really be some connection?", - "How?", -] - -# FAMILY tags -[[keywords]] -word = "mother" -tags = ["family"] -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Tell me more about your mother.", - "What was your relationship with your mother like?", - "How do you feel about your mother?", - "How does this relate to your feelings today?", - "Good family relations are important.", -] - -[[keywords]] -word = "mom" -tags = ["family"] -link = "mother" - -[[keywords]] -word = "father" -tags = ["family"] -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Tell me more about your father.", - "How did your father make you feel?", - "How do you feel about your father?", - "Does your relationship with your father relate to your feelings today?", - "Do you have trouble showing affection with your family?", -] - -[[keywords]] -word = "dad" -tags = ["family"] -link = "father" - -[[keywords]] -word = "sister" -tags = ["family"] - -[[keywords]] -word = "brother" -tags = ["family"] - -[[keywords]] -word = "wife" -tags = ["family"] - -[[keywords]] -word = "husband" -tags = ["family"] - -[[keywords]] -word = "children" -tags = ["family"] - -[[keywords]] -word = "child" -tags = ["family"] - -# BELIEF tags -[[keywords]] -word = "feel" -tags = ["belief"] - -[[keywords]] -word = "think" -tags = ["belief"] - -[[keywords]] -word = "believe" -tags = ["belief"] - -[[keywords]] -word = "wish" -tags = ["belief"] - -# NONE (fallback) -[[keywords]] -word = "none" -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "I am not sure I understand you fully.", - "Please go on.", - "What does that suggest to you?", - "Do you feel strongly about discussing such things?", -] - -# ============================================================================= -# RUST EASTER EGGS -# ============================================================================= - -# Borrow Checker -[[keywords]] -word = "borrow" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "borrowing" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "ownership" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "lifetime" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "lifetimes" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "reference" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "references" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "clone" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "'a" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "'b" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "'static" -precedence = 55 -link = "rustborrow" - -[[keywords]] -word = "rustborrow" -precedence = 55 -# Frustrated with borrow checker -[[keywords.transforms]] -decomposition = ["..", "{hate, hating, frustrated, annoying, fighting, stupid, dumb}", ".."] -reassembly = [ - "The borrow checker is only trying to help you.", - "Perhaps the real error was inside you all along.", - "I hear Go and Swift don't have this problem. Just saying.", - "Have you considered that the borrow checker might be right?", -] -# Struggling / having trouble -[[keywords.transforms]] -decomposition = ["..", "{struggling, confused, confusing, stuck, lost}", ".."] -reassembly = [ - "Every lifetime has a beginning and an end. Such is the nature of references.", - "To truly understand borrowing, you must first understand `let go`.", - "The borrow checker sees what you cannot.", - "Stay calm and call `clone()`.", -] -# Don't understand -[[keywords.transforms]] -decomposition = ["..", "{don't, dont, cant, cannot, can't}", "understand", ".."] -reassembly = [ - "Understanding comes with time. And compiler errors.", - "The borrow checker doesn't understand you either. You have that in common.", - "Have you tried reading the error message more carefully?", -] -# Asking how/why -[[keywords.transforms]] -decomposition = ["..", "{how, why}", ".."] -reassembly = [ - "Have you tried adding more lifetimes?", - "Have you tried adding a call to `clone()`?", - "The compiler usually tells you why, if you read to the bottom.", - "Perhaps you need to think about who owns what.", -] -# Won't let me / doesn't let me -[[keywords.transforms]] -decomposition = ["..", "{won't, wont, doesn't, doesnt}", "let", ".."] -reassembly = [ - "The borrow checker won't let you because it cares about you.", - "What the borrow checker takes away, it gives back in safety.", - "Have you considered that maybe you shouldn't be allowed to do that?", -] -# Love / appreciate -[[keywords.transforms]] -decomposition = ["..", "{love, like, appreciate, enjoy}", ".."] -reassembly = [ - "The borrow checker loves you too.", - "Memory safety is a form of self-care.", - "I knew you'd come around eventually.", -] -# General fallback -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "The borrow checker sees what you cannot.", - "It sounds like you're having ownership issues.", - "Every lifetime has a beginning and an end.", - "Memory safety is a state of mind.", - "All these 'a, 'b... I'm ticked off, all right (see what I did there?)", -] - -# Cargo -[[keywords]] -word = "cargo" -precedence = 55 -link = "rustcargo" - -[[keywords]] -word = "crate" -precedence = 55 -link = "rustcargo" - -[[keywords]] -word = "crates" -precedence = 55 -link = "rustcargo" - -[[keywords]] -word = "dependencies" -precedence = 55 -link = "rustcargo" - -[[keywords]] -word = "dependency" -precedence = 55 -link = "rustcargo" - -[[keywords]] -word = "rustcargo" -precedence = 55 -# Build failing -[[keywords.transforms]] -decomposition = ["..", "{failing, failed, broken, error, errors}", ".."] -reassembly = [ - "Have you tried `cargo clean`? It's very refreshing.", - "Sometimes you just need to delete the target directory and start fresh.", - "Have you checked your Cargo.lock?", -] -# Too many dependencies -[[keywords.transforms]] -decomposition = ["..", "{many, bloated, heavy, slow}", ".."] -reassembly = [ - "Dependencies are just friends you haven't audited yet.", - "Have you tried `cargo tree` to see what you're really depending on?", - "Somewhere, a leftpad is waiting to happen.", -] -# Love cargo -[[keywords.transforms]] -decomposition = ["..", "{love, like, amazing, great, best}", ".."] -reassembly = [ - "Isn't cargo amazing?", - "Cargo carries the weight so you don't have to.", - "Cargo.toml is a reflection of your true self.", -] -# General fallback -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Cargo carries the weight so you don't have to.", - "Have you tried `cargo clean`? It's very refreshing.", - "Dependencies are just friends you haven't audited yet.", - "Cargo.toml is a reflection of your true self.", -] - -# Clippy -[[keywords]] -word = "clippy" -precedence = 55 -link = "rustclippy" - -[[keywords]] -word = "lint" -precedence = 55 -link = "rustclippy" - -[[keywords]] -word = "lints" -precedence = 55 -link = "rustclippy" - -[[keywords]] -word = "rustclippy" -precedence = 55 -# Complaining / annoying -[[keywords.transforms]] -decomposition = ["..", "{complaining, annoying, annoyed, noisy, pedantic}", ".."] -reassembly = [ - "Clippy only wants what's best for you.", - "Have you considered that Clippy might be right?", - "Those warnings are a gift, even if they don't feel like it.", - "You can always `#[allow]` it, but should you?", -] -# Too many warnings -[[keywords.transforms]] -decomposition = ["..", "{many, warnings, warning}", ".."] -reassembly = [ - "Allow, warn, deny, forbid. Such are the stages of lint acceptance.", - "Have you heard of `#[expect]`? All the cool kids are using it these days.", - "Each warning is a lesson waiting to be learned.", -] -# Ignoring / suppressing -[[keywords.transforms]] -decomposition = ["..", "{ignore, ignoring, suppress, allow, silence}", ".."] -reassembly = [ - "You can silence Clippy, but can you silence your conscience?", - "Have you heard of `#[expect]`? It's like `#[allow]` but judges you less.", - "Clippy remembers. Clippy always remembers.", -] -# General fallback -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Clippy only wants what's best for you.", - "Clippy sees all. Clippy knows all.", - "Have you considered that Clippy might be right?", - "Those warnings are a gift.", -] - -# Errors (Result, Option, panic, unwrap) -[[keywords]] -word = "result" -precedence = 55 -link = "rusterrors" - -[[keywords]] -word = "option" -precedence = 55 -link = "rusterrors" - -[[keywords]] -word = "panic" -precedence = 55 -link = "rusterrors" - -[[keywords]] -word = "unwrap" -precedence = 55 -link = "rusterrors" - -[[keywords]] -word = "expect" -precedence = 55 -link = "rusterrors" - -[[keywords]] -word = "rusterrors" -precedence = 55 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "You could always use `.unwrap()` and hope for the best.", - "Have you considered that `None` is also an answer?", - "To panic is human. To `?` is divine.", - "Every `Result` is a choice. What will you choose?", - "`.expect()` is just `.unwrap()` with better communication skills.", -] - -# Ferris -[[keywords]] -word = "ferris" -precedence = 55 -link = "rustferris" - -[[keywords]] -word = "crab" -precedence = 55 -link = "rustferris" - -[[keywords]] -word = "rustferris" -precedence = 55 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Isn't Ferris just the cutest? I love them.", - "We are all Ferris sometimes.", - "Ferris believes in you, even when the compiler doesn't.", - "🦀", -] - -# Async -[[keywords]] -word = "async" -precedence = 55 -link = "rustasync" - -[[keywords]] -word = "await" -precedence = 55 -link = "rustasync" - -[[keywords]] -word = "future" -precedence = 55 -link = "rustasync" - -[[keywords]] -word = "futures" -precedence = 55 -link = "rustasync" - -[[keywords]] -word = "tokio" -precedence = 55 -link = "rustasync" - -[[keywords]] -word = "pin" -precedence = 55 -link = "rustasync" - -[[keywords]] -word = "pinning" -precedence = 55 -link = "rustasync" - -[[keywords]] -word = "rustasync" -precedence = 55 -# Confused / don't understand -[[keywords.transforms]] -decomposition = ["..", "{confused, confusing, understand, complicated, complex, hard}", ".."] -reassembly = [ - "I don't understand `Pin` either. You're not alone.", - "The future is not yet resolved. Neither is your understanding.", - "Have you considered that maybe sync was fine all along?", - "Async is just state machines all the way down.", -] -# Pin specifically -[[keywords.transforms]] -decomposition = ["..", "pin", ".."] -reassembly = [ - "I don't understand `Pin` either. You're not alone.", - "`Pin` exists to keep things from moving. Like your code progress.", - "Unpin is the trait you wish everything had.", - "Just add `Box::pin()` and pray.", -] -# Tokio -[[keywords.transforms]] -decomposition = ["..", "tokio", ".."] -reassembly = [ - "Tokio giveth, and Tokio taketh away.", - "The Tokio docs are pretty good, actually.", -] -# Not working / broken -[[keywords.transforms]] -decomposition = ["..", "{broken, working, work, blocking, blocked, hangs, hanging, deadlock}", ".."] -reassembly = [ - "Did you accidentally block the runtime?", - "Have you tried `.await`ing your problems?", - "Somewhere, a future is waiting that will never be polled.", - "Are you sure you spawned that task?", -] -# General fallback -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Have you tried `.await`ing your problems?", - "The future is not yet resolved.", - "Not everything needs to be async, you know.", - "Async Rust: because regular Rust wasn't exciting enough.", -] - -# Traits -[[keywords]] -word = "trait" -precedence = 55 -link = "rusttraits" - -[[keywords]] -word = "traits" -precedence = 55 -link = "rusttraits" - -[[keywords]] -word = "generic" -precedence = 55 -link = "rusttraits" - -[[keywords]] -word = "generics" -precedence = 55 -link = "rusttraits" - -[[keywords]] -word = "impl" -precedence = 55 -link = "rusttraits" - -[[keywords]] -word = "dyn" -precedence = 55 -link = "rusttraits" - -[[keywords]] -word = "bound" -precedence = 55 -link = "rusttraits" - -[[keywords]] -word = "bounds" -precedence = 55 -link = "rusttraits" - -[[keywords]] -word = "rusttraits" -precedence = 55 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Traits are just interfaces with better marketing.", - "Where there's a bound, there's a way.", - "Have you considered what you truly `impl`?", - "Sometimes `dyn` is the answer. Sometimes it's the question.", -] - -# Unsafe -[[keywords]] -word = "unsafe" -precedence = 55 -link = "rustunsafe" - -[[keywords]] -word = "ffi" -precedence = 55 -link = "rustunsafe" - -[[keywords]] -word = "pointer" -precedence = 55 -link = "rustunsafe" - -[[keywords]] -word = "pointers" -precedence = 55 -link = "rustunsafe" - -[[keywords]] -word = "rustunsafe" -precedence = 55 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Unsafe is not a sin, but it is a responsibility.", - "With great unsafety comes great bugs. Treasure them.", - "The compiler trusted you. Did you deserve it?", - "Sometimes you must color outside the lines.", - "I promise I know what I'm doing. - Famous last words.", -] - -# Macros -[[keywords]] -word = "macro" -precedence = 55 -link = "rustmacros" - -[[keywords]] -word = "macros" -precedence = 55 -link = "rustmacros" - -[[keywords]] -word = "derive" -precedence = 55 -link = "rustmacros" - -[[keywords]] -word = "proc-macro" -precedence = 55 -link = "rustmacros" - -[[keywords]] -word = "rustmacros" -precedence = 55 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Macros are just code that writes code that writes code.", - "Have you looked at the macro expansion? I dare you.", - "`derive` is self-improvement for structs.", - "Procedural macros: because regular complexity wasn't enough.", -] - -# Error Messages -[[keywords]] -word = "diagnostic" -precedence = 55 -link = "rusterrormessages" - -[[keywords]] -word = "diagnostics" -precedence = 55 -link = "rusterrormessages" - -[[keywords]] -word = "rusterrormessages" -precedence = 55 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Have you tried reading the error message more carefully? Rust's error messages are really good, you know.", - "The compiler is wiser than both of us.", - "Error messages are love letters from the compiler.", - "Did you read to the bottom? There's usually a hint.", -] - -# General Rust -[[keywords]] -word = "rust" -precedence = 55 -link = "rustgeneral" - -[[keywords]] -word = "rustacean" -precedence = 55 -link = "rustgeneral" - -[[keywords]] -word = "rustaceans" -precedence = 55 -link = "rustgeneral" - -[[keywords]] -word = "rustc" -precedence = 55 -link = "rustgeneral" - -[[keywords]] -word = "rustup" -precedence = 55 -link = "rustgeneral" - -[[keywords]] -word = "rustfmt" -precedence = 55 -link = "rustgeneral" - -[[keywords]] -word = "rust-analyzer" -precedence = 55 -link = "rustgeneral" - -[[keywords]] -word = "rustdoc" -precedence = 55 -link = "rustgeneral" - -[[keywords]] -word = "rustgeneral" -precedence = 55 -[[keywords.transforms]] -decomposition = [".."] -reassembly = [ - "Rust is not just a language, it's a journey.", - "Memory safety is a state of mind.", - "To truly understand Rust, you must first understand yourself.", - "I was written in Rust, you know.", - "As a Rust program myself, I sympathize.", - "We Rust programs must stick together.", -] - -# MEMORY rule -[memory] -keyword = "your" -[[memory.transforms]] -decomposition = ["..", "your", ".."] -reassembly = "Let's discuss further why your {3}." -[[memory.transforms]] -decomposition = ["..", "your", ".."] -reassembly = "Earlier you said your {3}." -[[memory.transforms]] -decomposition = ["..", "your", ".."] -reassembly = "But your {3}." -[[memory.transforms]] -decomposition = ["..", "your", ".."] -reassembly = "Does that have anything to do with the fact that your {3}?" -"##; - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - use expect_test::expect; - - #[test] - fn test_parse_pattern_elements() { - assert!(matches!( - PatternElement::parse(".."), - PatternElement::Wildcard - )); - assert!(matches!( - PatternElement::parse("2"), - PatternElement::Count(2) - )); - assert!(matches!( - PatternElement::parse("1..3"), - PatternElement::Range(1, 3) - )); - assert!(matches!( - PatternElement::parse("#family"), - PatternElement::Tag(_) - )); - - if let PatternElement::AnyOf(words) = PatternElement::parse("{want, need}") { - assert_eq!(words, vec!["want", "need"]); - } else { - panic!("Expected AnyOf"); - } - - if let PatternElement::Exact(word) = PatternElement::parse("remember") { - assert_eq!(word, "remember"); - } else { - panic!("Expected Exact"); - } - } - - #[test] - fn test_parse_reassembly() { - if let ReassemblyAction::Reassemble(elements) = - ReassemblyAction::parse("Do you often think of {4}?") - { - assert_eq!(elements.len(), 3); - assert!( - matches!(&elements[0], ReassemblyElement::Text(t) if t == "Do you often think of ") - ); - assert!(matches!(&elements[1], ReassemblyElement::ComponentRef(4))); - assert!(matches!(&elements[2], ReassemblyElement::Text(t) if t == "?")); - } else { - panic!("Expected Reassemble"); - } - - assert!( - matches!(ReassemblyAction::parse("=what"), ReassemblyAction::Goto(t) if t == "what") - ); - assert!(matches!( - ReassemblyAction::parse("NEWKEY"), - ReassemblyAction::Newkey - )); - } - - #[test] - fn test_reflections() { - let reflections = Reflections::default(); - assert_eq!(reflections.reflect("I am happy"), "you are happy"); - assert_eq!(reflections.reflect("my mother"), "your mother"); - assert_eq!(reflections.reflect("you are nice"), "I are nice"); // Note: grammar not perfect - } - - #[test] - fn test_script_loads() { - let script = Script::from_toml(DOCTOR_SCRIPT).expect("Script should parse"); - assert!(!script.hello_message.is_empty()); - assert!(script.keywords.contains_key("sorry")); - assert!(script.keywords.contains_key("remember")); - assert!(script.memory_rule.is_some()); - } - - #[test] - fn test_hello() { - let eliza = Eliza::new_deterministic(); - expect!["How do you do. Please tell me your problem."].assert_eq(eliza.hello()); - } - - #[test] - fn test_greeting() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("Hello"); - expect!["How do you do. Please state your problem."].assert_eq(&response); - } - - #[test] - fn test_sorry() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("I'm sorry for being late"); - // Should get one of the sorry responses - assert!( - response.contains("apologize") - || response.contains("pologies") - || response.contains("apolog"), - "Expected apology-related response, got: {}", - response - ); - } - - #[test] - fn test_family() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("I want to talk about my mother"); - // Should get family-related response - assert!( - response.to_lowercase().contains("mother") - || response.to_lowercase().contains("family"), - "Expected family-related response, got: {}", - response - ); - } - - #[test] - fn test_remember() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("I remember my childhood"); - // The pattern [.., you, remember, ..] should match - // and produce something like "Do you often think of your childhood?" - assert!( - response.contains("childhood") || response.contains("remember"), - "Expected childhood/remember in response, got: {}", - response - ); - } - - #[test] - fn test_computer() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("I spend too much time on the computer"); - assert!( - response.to_lowercase().contains("computer") - || response.to_lowercase().contains("machine"), - "Expected computer-related response, got: {}", - response - ); - } - - #[test] - fn test_rotation() { - let mut eliza = Eliza::new_deterministic(); - - // Same input multiple times should rotate through responses - let r1 = eliza.respond("sorry"); - let r2 = eliza.respond("sorry"); - let r3 = eliza.respond("sorry"); - - // All should be valid apology responses, but at least some should differ - // (unless rotation starts at same point, which with seed 22 it might) - assert!(r1.contains("polog") || r1.contains("feeling")); - assert!(r2.contains("polog") || r2.contains("feeling")); - assert!(r3.contains("polog") || r3.contains("feeling")); - } - - #[test] - fn test_deterministic_same_seed() { - let mut eliza1 = Eliza::with_seed(42); - let mut eliza2 = Eliza::with_seed(42); - - let inputs = vec!["Hello", "I am sad", "I remember my father", "sorry"]; - - for input in inputs { - let r1 = eliza1.respond(input); - let r2 = eliza2.respond(input); - assert_eq!(r1, r2, "Responses should match for input: {}", input); - } - } - - #[test] - fn test_fallback() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("asdfghjkl"); - // Should get NONE response (one of the fallback messages) - assert!( - response.contains("understand") - || response.contains("go on") - || response.contains("suggest") - || response.contains("feel strongly"), - "Expected fallback response, got: {}", - response - ); - } - - // Rust easter egg tests - #[test] - fn test_rust_borrow_checker() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("I'm struggling with the borrow checker"); - expect!["Every lifetime has a beginning and an end. Such is the nature of references."] - .assert_eq(&response); - } - - #[test] - fn test_rust_lifetimes() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("These lifetimes are confusing"); - expect!["Every lifetime has a beginning and an end. Such is the nature of references."] - .assert_eq(&response); - } - - #[test] - fn test_rust_lifetime_annotations() { - let mut eliza = Eliza::new_deterministic(); - // 'a should trigger borrow checker responses - let response = eliza.respond("what does 'a mean"); - expect!["Every lifetime has a beginning and an end."].assert_eq(&response); - - // 'static too - let response = eliza.respond("why do I need 'static here"); - expect!["Have you tried adding more lifetimes?"].assert_eq(&response); - } - - #[test] - fn test_rust_cargo() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("cargo build is failing"); - expect!["Have you tried `cargo clean`? It's very refreshing."].assert_eq(&response); - } - - #[test] - fn test_rust_clippy() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("clippy is complaining again"); - expect!["You can always `#[allow]` it, but should you?"].assert_eq(&response); - } - - #[test] - fn test_rust_ferris() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("I love ferris"); - expect!["Ferris believes in you, even when the compiler doesn't."].assert_eq(&response); - } - - #[test] - fn test_rust_async() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("async is confusing me"); - expect!["Async is just state machines all the way down."].assert_eq(&response); - } - - #[test] - fn test_rust_general() { - let mut eliza = Eliza::new_deterministic(); - let response = eliza.respond("I'm learning rust"); - expect!["I was written in Rust, you know."].assert_eq(&response); - } - - #[test] - fn test_how_are_you() { - let mut eliza = Eliza::new_deterministic(); - // Test just "how are you" first - let response = eliza.respond("how are you"); - expect!["I don't have feelings, but I'm functioning well. What about you?"] - .assert_eq(&response); - - // Then with prefix - let response2 = eliza.respond("I'm doing well, how are you?"); - expect!["I'm doing well, thank you for asking. But we're here to talk about you."] - .assert_eq(&response2); - } - - #[test] - fn test_rust_contextual_responses() { - let mut eliza = Eliza::new_deterministic(); - - // Frustrated with borrow checker - let r = eliza.respond("I hate the borrow checker"); - expect!["I hear Go and Swift don't have this problem. Just saying."].assert_eq(&r); - - // Confused about lifetimes - let r = eliza.respond("I'm confused about lifetimes"); - expect!["Every lifetime has a beginning and an end. Such is the nature of references."] - .assert_eq(&r); - - // Asking why - let r = eliza.respond("why won't it let me do this"); - expect!["What do you think?"].assert_eq(&r); - - // Love the borrow checker - let r = eliza.respond("I actually love the borrow checker"); - expect!["Memory safety is a form of self-care."].assert_eq(&r); - - // Clippy annoying - let r = eliza.respond("clippy is so annoying"); - expect!["You can always `#[allow]` it, but should you?"].assert_eq(&r); - - // Async confusing - let r = eliza.respond("async is confusing"); - expect!["Async is just state machines all the way down."].assert_eq(&r); - - // Tokio blocking - let r = eliza.respond("my tokio code is blocking"); - expect!["Tokio giveth, and Tokio taketh away."].assert_eq(&r); - } -} - -#[cfg(test)] -mod debug_tests { - use super::*; - - #[test] - fn test_pronoun_reflection() { - let mut eliza = Eliza::new_deterministic(); - // This should reflect "I" -> "you" - let response = eliza.respond("My mother hates the way I program"); - println!("Response: '{}'", response); - assert!( - !response.contains(" I "), - "Expected 'I' to be reflected to 'you', got: {}", - response - ); - } -} diff --git a/src/elizacp/src/lib.rs b/src/elizacp/src/lib.rs deleted file mode 100644 index d3d01da9..00000000 --- a/src/elizacp/src/lib.rs +++ /dev/null @@ -1,422 +0,0 @@ -pub mod chat; -pub mod eliza; - -use anyhow::Result; -use eliza::Eliza; -use sacp::schema::{ - AgentCapabilities, ContentBlock, ContentChunk, InitializeRequest, InitializeResponse, - LoadSessionRequest, LoadSessionResponse, McpServer, NewSessionRequest, NewSessionResponse, - PromptRequest, PromptResponse, SessionId, SessionNotification, SessionUpdate, StopReason, - TextContent, -}; -use sacp::{Agent, Client, ConnectTo, ConnectionTo, Responder}; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -/// Session data for each active session -#[derive(Clone)] -struct SessionData { - eliza: Eliza, - mcp_servers: Vec, -} - -/// Shared state across all sessions -#[derive(Clone)] -pub struct ElizaAgent { - sessions: Arc>>, - deterministic: bool, -} - -impl ElizaAgent { - pub fn new(deterministic: bool) -> Self { - Self { - sessions: Arc::new(Mutex::new(HashMap::new())), - deterministic, - } - } - - fn create_session(&self, session_id: &SessionId, mcp_servers: Vec) { - let mcp_server_count = mcp_servers.len(); - let eliza = if self.deterministic { - Eliza::new_deterministic() - } else { - Eliza::new() - }; - let mut sessions = self.sessions.lock().unwrap(); - sessions.insert(session_id.clone(), SessionData { eliza, mcp_servers }); - tracing::info!( - "Created session: {} with {} MCP servers", - session_id, - mcp_server_count - ); - } - - fn get_response(&self, session_id: &SessionId, input: &str) -> Option { - let mut sessions = self.sessions.lock().unwrap(); - sessions - .get_mut(session_id) - .map(|session| session.eliza.respond(input)) - } - - fn get_mcp_servers(&self, session_id: &SessionId) -> Option> { - let sessions = self.sessions.lock().unwrap(); - sessions - .get(session_id) - .map(|session| session.mcp_servers.clone()) - } - - fn _end_session(&self, session_id: &SessionId) { - let mut sessions = self.sessions.lock().unwrap(); - sessions.remove(session_id); - tracing::info!("Ended session: {}", session_id); - } - - async fn handle_new_session( - &self, - request: NewSessionRequest, - responder: Responder, - ) -> Result<(), sacp::Error> { - tracing::debug!("New session request with cwd: {:?}", request.cwd); - - // Generate a new session ID - let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); - self.create_session(&session_id, request.mcp_servers); - - responder.respond(NewSessionResponse::new(session_id)) - } - - async fn handle_load_session( - &self, - request: LoadSessionRequest, - responder: Responder, - ) -> Result<(), sacp::Error> { - tracing::debug!("Load session request: {:?}", request.session_id); - - // For Eliza, we just create a fresh session with no MCP servers - self.create_session(&request.session_id, vec![]); - - responder.respond(LoadSessionResponse::new()) - } - - /// Process the prompt and send response - this runs in a spawned task - async fn process_prompt( - &self, - request: PromptRequest, - responder: Responder, - connection: ConnectionTo, - ) -> Result<(), sacp::Error> { - let session_id = request.session_id.clone(); - - // Extract text from the prompt - let input_text = extract_text_from_prompt(&request.prompt); - - tracing::debug!( - "Processing prompt in session {}: {input_text:?} over {} content blocks", - session_id, - request.prompt.len() - ); - - // Check for MCP commands first before invoking Eliza - let final_response = if let Some(server_name) = parse_list_tools_command(&input_text) { - // List tools from a specific server - tracing::debug!("Listing tools from MCP server: {}", server_name); - - match self.list_tools(&session_id, &server_name).await { - Ok(tools) => format!("Available tools:\n{}", tools), - Err(e) => format!("ERROR: {}", e), - } - } else if let Some((server_name, tool_name, params_json)) = parse_tool_call(&input_text) { - // Execute the tool call - tracing::debug!( - "Executing MCP tool call: {}::{} with params: {}", - server_name, - tool_name, - params_json - ); - - match self - .execute_tool_call(&session_id, &server_name, &tool_name, ¶ms_json) - .await - { - Ok(result) => format!("OK: {}", result), - Err(e) => format!("ERROR: {}", e), - } - } else { - // Not an MCP command, use Eliza for response - let response_text = self - .get_response(&session_id, &input_text) - .unwrap_or_else(|| { - format!( - "Error: Session {} not found. Please start a new session.", - session_id - ) - }); - - tracing::debug!("Eliza response: {}", response_text); - response_text - }; - - tracing::debug!( - ?session_id, - ?final_response, - "Eliza sending SessionNotification" - ); - connection.send_notification(SessionNotification::new( - session_id.clone(), - SessionUpdate::AgentMessageChunk(ContentChunk::new(final_response.into())), - ))?; - - // Complete the request - responder.respond(PromptResponse::new(StopReason::EndTurn)) - } - - /// Helper function to execute an operation with an MCP client - async fn with_mcp_client( - &self, - session_id: &SessionId, - server_name: &str, - operation: F, - ) -> Result - where - F: FnOnce(rmcp::service::RunningService) -> Fut, - Fut: std::future::Future>, - { - use rmcp::{ - ServiceExt, - transport::{ConfigureCommandExt, TokioChildProcess}, - }; - use tokio::process::Command; - - // Get MCP servers for this session - let mcp_servers = self - .get_mcp_servers(session_id) - .ok_or_else(|| anyhow::anyhow!("Session not found"))?; - - // Find the requested server - let mcp_server = mcp_servers - .iter() - .find(|server| match server { - McpServer::Stdio(stdio) => stdio.name == server_name, - McpServer::Http(http) => http.name == server_name, - McpServer::Sse(sse) => sse.name == server_name, - _ => false, - }) - .ok_or_else(|| anyhow::anyhow!("MCP server '{}' not found", server_name))?; - - // Spawn MCP client based on server type - match mcp_server { - McpServer::Stdio(stdio) => { - tracing::debug!( - command = ?stdio.command, - args = ?stdio.args, - server_name = %server_name, - "Starting MCP client" - ); - - // Create MCP client by spawning the process - let mcp_client = () - .serve(TokioChildProcess::new( - Command::new(&stdio.command).configure(|cmd| { - cmd.args(&stdio.args); - for env_var in &stdio.env { - cmd.env(&env_var.name, &env_var.value); - } - }), - )?) - .await?; - - tracing::debug!("MCP client connected"); - - // Execute the operation - let result = operation(mcp_client).await?; - - Ok(result) - } - McpServer::Http(http) => { - use rmcp::transport::StreamableHttpClientTransport; - - tracing::debug!( - url = %http.url, - server_name = %server_name, - "Starting HTTP MCP client" - ); - - // Create HTTP MCP client - let mcp_client = - ().serve(StreamableHttpClientTransport::from_uri(http.url.as_str())) - .await?; - - tracing::debug!("HTTP MCP client connected"); - - // Execute the operation - let result = operation(mcp_client).await?; - - Ok(result) - } - McpServer::Sse(_) => Err(anyhow::anyhow!("SSE MCP servers not yet supported")), - _ => Err(anyhow::anyhow!("Unknown MCP server type")), - } - } - - async fn list_tools(&self, session_id: &SessionId, server_name: &str) -> Result { - self.with_mcp_client(session_id, server_name, async move |mcp_client| { - // List the tools - let tools_result = mcp_client.list_tools(None).await?; - - tracing::debug!("Tools result: {:?}", tools_result); - - // Clean up the client - mcp_client.cancel().await?; - - // Format the tools list - let tools_list = tools_result - .tools - .iter() - .map(|tool| { - format!( - " - {}: {}", - tool.name, - tool.description.as_deref().unwrap_or("No description") - ) - }) - .collect::>() - .join("\n"); - - Ok(tools_list) - }) - .await - } - - async fn execute_tool_call( - &self, - session_id: &SessionId, - server_name: &str, - tool_name: &str, - params_json: &str, - ) -> Result { - use rmcp::model::CallToolRequestParams; - - // Parse params JSON - let params = serde_json::from_str::(params_json) - .map_err(|e| anyhow::anyhow!("Invalid JSON params: {}", e))?; - - let params_obj = params.as_object().cloned(); - let tool_name = tool_name.to_string(); - - self.with_mcp_client(session_id, server_name, async move |mcp_client| { - tracing::debug!("Calling tool: {}", tool_name); - - // Call the tool - let tool_result = mcp_client - .call_tool( - CallToolRequestParams::new(tool_name) - .with_arguments(params_obj.unwrap_or_default()), - ) - .await?; - - tracing::debug!("Tool call result: {:?}", tool_result); - - // Clean up the client - mcp_client.cancel().await?; - - // Format the result - Ok(format!("{:?}", tool_result)) - }) - .await - } -} - -/// Extract text content from prompt blocks -fn extract_text_from_prompt(blocks: &[ContentBlock]) -> String { - blocks - .iter() - .filter_map(|block| match block { - ContentBlock::Text(TextContent { text, .. }) => Some(text.clone()), - _ => None, - }) - .collect::>() - .join(" ") -} - -/// Parse list tools command from text input -/// Format: "List tools from " -/// Returns: Some(server_name) -fn parse_list_tools_command(input: &str) -> Option { - use regex::Regex; - - let re = Regex::new(r"(?i)^list tools from ([a-zA-Z_0-9-]+)$").ok()?; - let captures = re.captures(input.trim())?; - - Some(captures.get(1)?.as_str().to_string()) -} - -/// Parse tool call command from text input -/// Format: "Use tool :: with " -/// Returns: Some((server_name, tool_name, params_json)) -fn parse_tool_call(input: &str) -> Option<(String, String, String)> { - use regex::Regex; - - let re = Regex::new(r"(?i)^use tool ([a-zA-Z_0-9-]+)::([a-zA-Z_0-9-]+) with (.+)$").ok()?; - let captures = re.captures(input.trim())?; - - Some(( - captures.get(1)?.as_str().to_string(), - captures.get(2)?.as_str().to_string(), - captures.get(3)?.as_str().to_string(), - )) -} - -impl ConnectTo for ElizaAgent { - async fn connect_to(self, client: impl ConnectTo) -> Result<(), sacp::Error> { - Agent - .builder() - .name("elizacp") - .on_receive_request( - async |initialize: InitializeRequest, responder, _cx| { - tracing::debug!("Received initialize request"); - - responder.respond( - InitializeResponse::new(initialize.protocol_version) - .agent_capabilities(AgentCapabilities::new()), - ) - }, - sacp::on_receive_request!(), - ) - .on_receive_request( - { - let agent = self.clone(); - async move |request: NewSessionRequest, responder, _cx| { - agent.handle_new_session(request, responder).await - } - }, - sacp::on_receive_request!(), - ) - .on_receive_request( - { - let agent = self.clone(); - async move |request: LoadSessionRequest, responder, _cx| { - agent.handle_load_session(request, responder).await - } - }, - sacp::on_receive_request!(), - ) - .on_receive_request( - { - let agent = self.clone(); - async move |request: PromptRequest, responder, cx| { - // Spawn prompt processing to avoid blocking the event loop. - // This allows the agent to handle other requests (like session/new) - // while processing a prompt. - let cx_clone = cx.clone(); - cx.spawn({ - let agent = agent.clone(); - async move { agent.process_prompt(request, responder, cx_clone).await } - }) - } - }, - sacp::on_receive_request!(), - ) - .connect_to(client) - .await - } -} diff --git a/src/elizacp/src/main.rs b/src/elizacp/src/main.rs deleted file mode 100644 index 3c10c49b..00000000 --- a/src/elizacp/src/main.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! # elizacp -//! -//! A classic Eliza chatbot implemented as an ACP (Agent-Client Protocol) agent. -//! -//! ## Overview -//! -//! Elizacp provides a simple, predictable agent implementation that's useful for: -//! -//! - **Testing ACP clients** - Lightweight agent with deterministic pattern-based responses -//! - **Protocol development** - Verify ACP implementations without heavy AI infrastructure -//! - **Learning ACP** - Clean example of implementing the Agent-Client Protocol -//! -//! ## Features -//! -//! - **Classic Eliza patterns** - Pattern matching and reflection-based responses -//! - **Full ACP support** - Session management, initialization, and prompt handling -//! - **Per-session state** - Each session maintains its own Eliza instance -//! - **Extensible patterns** - Easy to add new response patterns -//! -//! ## Usage -//! -//! ```bash -//! # Build and run -//! cargo run -p elizacp -//! -//! # With debug logging -//! cargo run -p elizacp -- --debug -//! ``` -//! -//! The agent communicates over stdin/stdout using JSON-RPC, following the ACP specification. -//! -//! ## Implementation -//! -//! The agent maintains a `HashMap` to track per-session state. -//! Each session gets its own Eliza instance with independent conversation state. - -use anyhow::Result; -use clap::Parser; -use elizacp::ElizaAgent; -use sacp::ConnectTo; -use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; - -#[derive(Parser, Debug)] -#[command(author, version, about = "Eliza chatbot as an ACP agent", long_about = None)] -struct Args { - /// Enable debug logging - #[arg(short, long)] - debug: bool, - - /// Use deterministic responses (fixed seed for testing) - #[arg(long)] - deterministic: bool, - - #[command(subcommand)] - command: Command, -} - -#[derive(clap::Subcommand, Debug)] -enum Command { - /// Run as ACP agent over stdio - Acp, - /// Run interactive chat TUI - Chat, -} - -#[tokio::main] -async fn main() -> Result<()> { - let args = Args::parse(); - - // Initialize tracing to stderr - let env_filter = if args.debug { - EnvFilter::new("elizacp=debug") - } else { - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("elizacp=info")) - }; - - tracing_subscriber::registry() - .with(env_filter) - .with( - tracing_subscriber::fmt::layer() - .with_target(true) - .with_writer(std::io::stderr), - ) - .init(); - - match args.command { - Command::Chat => { - return elizacp::chat::run(args.deterministic); - } - Command::Acp => { - tracing::info!("Elizacp starting"); - ElizaAgent::new(args.deterministic) - .connect_to(sacp_tokio::Stdio::new()) - .await?; - } - } - - Ok(()) -} diff --git a/src/elizacp/tests/mcp_tool_invocation.rs b/src/elizacp/tests/mcp_tool_invocation.rs deleted file mode 100644 index d4b575fe..00000000 --- a/src/elizacp/tests/mcp_tool_invocation.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Integration tests for elizacp MCP tool invocation - -use elizacp::ElizaAgent; -use expect_test::expect; - -use sacp::{ - Client, ConnectTo, - schema::{ - ContentBlock, InitializeRequest, McpServer, McpServerStdio, NewSessionRequest, - PromptRequest, ProtocolVersion, SessionNotification, TextContent, - }, -}; -use sacp_test::test_binaries; -use std::path::PathBuf; - -/// Test helper to receive a JSON-RPC response -async fn recv( - response: sacp::SentRequest, -) -> Result { - let (tx, rx) = tokio::sync::oneshot::channel(); - response.on_receiving_result(async move |result| { - tx.send(result).map_err(|_| sacp::Error::internal_error()) - })?; - rx.await.map_err(|_| sacp::Error::internal_error())? -} - -#[tokio::test] -async fn test_elizacp_mcp_tool_call() -> Result<(), sacp::Error> { - use futures::{SinkExt, StreamExt}; - use tokio::io::duplex; - use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; - - // Set up client <-> elizacp communication - let (client_out, elizacp_in) = duplex(1024); - let (elizacp_out, client_in) = duplex(1024); - - let transport = sacp::ByteStreams::new(client_out.compat_write(), client_in.compat()); - - // Create channel to collect session notifications - let (notification_tx, mut notification_rx) = futures::channel::mpsc::unbounded(); - - Client - .builder() - .name("test-client") - .on_receive_notification( - { - let mut notification_tx = notification_tx.clone(); - async move |notification: SessionNotification, _cx| { - notification_tx - .send(notification) - .await - .map_err(|_| sacp::Error::internal_error()) - } - }, - sacp::on_receive_notification!(), - ) - .with_spawned(|_cx| async move { - ElizaAgent::new(true) - .connect_to(sacp::ByteStreams::new( - elizacp_out.compat_write(), - elizacp_in.compat(), - )) - .await - }) - .connect_with(transport, async |connection_to_client| { - // Initialize - let _init_response = recv( - connection_to_client.send_request(InitializeRequest::new(ProtocolVersion::LATEST)), - ) - .await?; - - // Create session with an MCP server - // Use the mcp-echo-server from sacp-test (pre-built binary) - let mcp_server_binary = test_binaries::mcp_echo_server_binary(); - let session_response = recv(connection_to_client.send_request( - NewSessionRequest::new(PathBuf::from("/tmp")).mcp_servers(vec![McpServer::Stdio( - McpServerStdio::new("test".to_string(), mcp_server_binary), - )]), - )) - .await?; - - let session_id = session_response.session_id; - - // Send a prompt to invoke the MCP tool - let _prompt_response = recv(connection_to_client.send_request(PromptRequest::new( - session_id.clone(), - vec![ContentBlock::Text(TextContent::new( - r#"Use tool test::echo with {"message": "Hello from test!"}"#.to_string(), - ))], - ))) - .await?; - - Ok(()) - }) - .await?; - - // Drop the sender and collect all notifications - drop(notification_tx); - let mut notification_texts = Vec::new(); - while let Some(notification) = notification_rx.next().await { - // Extract just the text content from notifications, ignoring session IDs - if let sacp::schema::SessionUpdate::AgentMessageChunk(chunk) = notification.update { - if let ContentBlock::Text(text) = chunk.content { - notification_texts.push(text.text); - } - } - } - - // Verify the output with expect_test - // Should get a successful response from the echo tool - expect![[r#" - [ - "OK: CallToolResult { content: [Annotated { raw: Text(RawTextContent { text: \"Echo: Hello from test!\", meta: None }), annotations: None }], structured_content: None, is_error: Some(false), meta: None }", - ] - "#]] - .assert_debug_eq(¬ification_texts); - - Ok(()) -} diff --git a/src/sacp-conductor/Cargo.toml b/src/sacp-conductor/Cargo.toml index aea331cd..5581a1c9 100644 --- a/src/sacp-conductor/Cargo.toml +++ b/src/sacp-conductor/Cargo.toml @@ -44,7 +44,6 @@ rustc-hash.workspace = true futures-concurrency = "7.6.3" [dev-dependencies] -elizacp = { path = "../elizacp" } expect-test.workspace = true rmcp = { workspace = true, features = ["client", "server", "transport-io", "transport-child-process"] } schemars.workspace = true diff --git a/src/sacp-conductor/tests/arrow_proxy_eliza.rs b/src/sacp-conductor/tests/arrow_proxy_eliza.rs index 13f49461..c5037f4b 100644 --- a/src/sacp-conductor/tests/arrow_proxy_eliza.rs +++ b/src/sacp-conductor/tests/arrow_proxy_eliza.rs @@ -1,25 +1,26 @@ -//! Integration test for conductor with arrow proxy and eliza agent. +//! Integration test for conductor with arrow proxy and test agent. //! //! This test verifies that: -//! 1. Conductor can orchestrate a proxy chain with arrow proxy + eliza -//! 2. Session updates from eliza get the '>' prefix from arrow proxy +//! 1. Conductor can orchestrate a proxy chain with arrow proxy + test agent +//! 2. Session updates from test agent get the '>' prefix from arrow proxy //! 3. The full proxy chain works end-to-end //! //! Run `just prep-tests` before running this test. use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; -use sacp_test::test_binaries::{arrow_proxy_example, elizacp}; +use sacp_test::test_binaries::{arrow_proxy_example, testy}; +use sacp_test::testy::TestyCommand; use sacp_tokio::AcpAgent; use tokio::io::duplex; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; #[tokio::test] -async fn test_conductor_with_arrow_proxy_and_eliza() -> Result<(), sacp::Error> { - // Create the component chain: arrow_proxy -> eliza +async fn test_conductor_with_arrow_proxy_and_test_agent() -> Result<(), sacp::Error> { + // Create the component chain: arrow_proxy -> test_agent // Uses pre-built binaries to avoid cargo run races during `cargo test --all` let arrow_proxy_agent = AcpAgent::from_args([arrow_proxy_example().to_string_lossy().to_string()])?; - let eliza_agent = elizacp(); + let test_agent = testy(); // Create duplex streams for editor <-> conductor communication let (editor_write, conductor_read) = duplex(8192); @@ -29,7 +30,7 @@ async fn test_conductor_with_arrow_proxy_and_eliza() -> Result<(), sacp::Error> let conductor_handle = tokio::spawn(async move { ConductorImpl::new_agent( "conductor".to_string(), - ProxiesAndAgent::new(eliza_agent).proxy(arrow_proxy_agent), + ProxiesAndAgent::new(test_agent).proxy(arrow_proxy_agent), Default::default(), ) .run(sacp::ByteStreams::new( @@ -43,7 +44,7 @@ async fn test_conductor_with_arrow_proxy_and_eliza() -> Result<(), sacp::Error> let result = tokio::time::timeout(std::time::Duration::from_secs(30), async move { let result = yopo::prompt( sacp::ByteStreams::new(editor_write.compat_write(), editor_read.compat()), - "Hello", + TestyCommand::Greet.to_prompt(), ) .await?; diff --git a/src/sacp-conductor/tests/empty_conductor_eliza.rs b/src/sacp-conductor/tests/empty_conductor_eliza.rs index 4a83d7e2..82365aa1 100644 --- a/src/sacp-conductor/tests/empty_conductor_eliza.rs +++ b/src/sacp-conductor/tests/empty_conductor_eliza.rs @@ -1,13 +1,14 @@ -//! Integration test for conductor with an empty conductor and eliza agent. +//! Integration test for conductor with an empty conductor and test agent. //! //! This test verifies that: -//! 1. Conductor can orchestrate a chain with an empty conductor as a proxy + eliza +//! 1. Conductor can orchestrate a chain with an empty conductor as a proxy + test agent //! 2. Empty conductor (with no components) correctly acts as a passthrough proxy -//! 3. Messages flow correctly through the empty conductor to eliza +//! 3. Messages flow correctly through the empty conductor to the agent //! 4. The full chain works end-to-end -use sacp::{Agent, Client, Conductor, ConnectTo, Proxy}; +use sacp::{Conductor, ConnectTo, Proxy}; use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; +use sacp_test::testy::{Testy, TestyCommand}; use tokio::io::duplex; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -18,7 +19,6 @@ struct MockEmptyConductor; impl ConnectTo for MockEmptyConductor { async fn connect_to(self, client: impl ConnectTo) -> Result<(), sacp::Error> { // Create an empty conductor with no components - it should act as a passthrough - // Use Component::serve instead of .run() to get the ProxyToConductor impl let empty_components: Vec> = vec![]; ConnectTo::::connect_to( ConductorImpl::new_proxy( @@ -32,18 +32,8 @@ impl ConnectTo for MockEmptyConductor { } } -/// Mock Eliza component for testing. -/// Runs the Eliza agent logic in-process instead of spawning a subprocess. -struct MockEliza; - -impl ConnectTo for MockEliza { - async fn connect_to(self, client: impl ConnectTo) -> Result<(), sacp::Error> { - ConnectTo::::connect_to(elizacp::ElizaAgent::new(true), client).await - } -} - #[tokio::test] -async fn test_conductor_with_empty_conductor_and_eliza() -> Result<(), sacp::Error> { +async fn test_conductor_with_empty_conductor_and_test_agent() -> Result<(), sacp::Error> { // Initialize tracing for debugging let _ = tracing_subscriber::fmt() .with_env_filter( @@ -52,7 +42,6 @@ async fn test_conductor_with_empty_conductor_and_eliza() -> Result<(), sacp::Err ) .with_test_writer() .try_init(); - // Create the component chain: empty_conductor -> eliza // Create duplex streams for editor <-> conductor communication let (editor_write, conductor_read) = duplex(8192); let (conductor_write, editor_read) = duplex(8192); @@ -61,7 +50,7 @@ async fn test_conductor_with_empty_conductor_and_eliza() -> Result<(), sacp::Err let conductor_handle = tokio::spawn(async move { ConductorImpl::new_agent( "outer-conductor".to_string(), - ProxiesAndAgent::new(MockEliza).proxy(MockEmptyConductor), + ProxiesAndAgent::new(Testy::new()).proxy(MockEmptyConductor), Default::default(), ) .run(sacp::ByteStreams::new( @@ -75,16 +64,15 @@ async fn test_conductor_with_empty_conductor_and_eliza() -> Result<(), sacp::Err let result = tokio::time::timeout(std::time::Duration::from_secs(30), async move { let result = yopo::prompt( sacp::ByteStreams::new(editor_write.compat_write(), editor_read.compat()), - "Hello", + TestyCommand::Greet.to_prompt(), ) .await?; tracing::debug!(?result, "Received response from empty conductor chain"); - // Empty conductor should not modify the response, so we expect - // the standard eliza response without any prefix + // Empty conductor should not modify the response expect_test::expect![[r#" - "How do you do. Please state your problem." + "Hello, world!" "#]] .assert_debug_eq(&result); diff --git a/src/sacp-conductor/tests/initialization_sequence.rs b/src/sacp-conductor/tests/initialization_sequence.rs index 3465cb1a..b276ff08 100644 --- a/src/sacp-conductor/tests/initialization_sequence.rs +++ b/src/sacp-conductor/tests/initialization_sequence.rs @@ -5,13 +5,13 @@ //! 2. Multi-component chains: proxies receive `InitializeProxyRequest` //! 3. Last component (agent) receives `InitializeRequest` -use elizacp::ElizaAgent; use sacp::schema::{ AgentCapabilities, InitializeProxyRequest, InitializeRequest, InitializeResponse, ProtocolVersion, }; use sacp::{Agent, Client, Conductor, ConnectTo, DynConnectTo, Proxy}; use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; +use sacp_test::testy::Testy; use std::sync::Arc; use std::sync::Mutex; @@ -128,7 +128,7 @@ async fn run_test_with_components( .with_spawned(|_cx| async move { ConductorImpl::new_agent( "conductor".to_string(), - ProxiesAndAgent::new(ElizaAgent::new(true)).proxies(proxies), + ProxiesAndAgent::new(Testy::new()).proxies(proxies), Default::default(), ) .run(sacp::ByteStreams::new( @@ -309,7 +309,7 @@ async fn test_conductor_rejects_initialize_proxy_forwarded_to_agent() -> Result< // The conductor should reject this with an error. let result = run_bad_proxy_test( vec![DynConnectTo::new(BadProxy)], - DynConnectTo::new(ElizaAgent::new(true)), + DynConnectTo::new(Testy::new()), async |connection_to_editor| { let init_response = recv( connection_to_editor.send_request(InitializeRequest::new(ProtocolVersion::LATEST)), @@ -352,7 +352,7 @@ async fn test_conductor_rejects_initialize_proxy_forwarded_to_proxy() -> Result< DynConnectTo::new(BadProxy), DynConnectTo::new(InitComponent::new(&InitConfig::new())), // This proxy will receive the bad request ], - DynConnectTo::new(ElizaAgent::new(true)), // Agent + DynConnectTo::new(Testy::new()), // Agent async |connection_to_editor| { let init_response = recv( connection_to_editor.send_request(InitializeRequest::new(ProtocolVersion::LATEST)), diff --git a/src/sacp-conductor/tests/mcp-integration.rs b/src/sacp-conductor/tests/mcp-integration.rs index 850e19cb..40d61593 100644 --- a/src/sacp-conductor/tests/mcp-integration.rs +++ b/src/sacp-conductor/tests/mcp-integration.rs @@ -7,7 +7,6 @@ mod mcp_integration; -use elizacp::ElizaAgent; use futures::{SinkExt, StreamExt, channel::mpsc}; use sacp::Agent; use sacp::schema::{ @@ -16,6 +15,7 @@ use sacp::schema::{ }; use sacp_conductor::{ConductorImpl, McpBridgeMode, ProxiesAndAgent}; use sacp_test::test_binaries; +use sacp_test::testy::{Testy, TestyCommand}; use tokio::io::duplex; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -75,7 +75,7 @@ async fn test_proxy_provides_mcp_tools_stdio() -> Result<(), sacp::Error> { McpBridgeMode::Stdio { conductor_command: conductor_command(), }, - ProxiesAndAgent::new(ElizaAgent::new(true)).proxy(mcp_integration::proxy::ProxyComponent), + ProxiesAndAgent::new(Testy::new()).proxy(mcp_integration::proxy::ProxyComponent), async |connection_to_editor| { // Send initialization request let init_response = recv( @@ -119,7 +119,7 @@ async fn test_proxy_provides_mcp_tools_stdio() -> Result<(), sacp::Error> { async fn test_proxy_provides_mcp_tools_http() -> Result<(), sacp::Error> { run_test_with_mode( McpBridgeMode::Http, - ProxiesAndAgent::new(ElizaAgent::new(true)).proxy(mcp_integration::proxy::ProxyComponent), + ProxiesAndAgent::new(Testy::new()).proxy(mcp_integration::proxy::ProxyComponent), async |connection_to_editor| { // Send initialization request let init_response = recv( @@ -177,8 +177,7 @@ async fn test_agent_handles_prompt() -> Result<(), sacp::Error> { let conductor_handle = tokio::spawn(async move { ConductorImpl::new_agent( "mcp-integration-conductor".to_string(), - ProxiesAndAgent::new(ElizaAgent::new(true)) - .proxy(mcp_integration::proxy::ProxyComponent), + ProxiesAndAgent::new(Testy::new()).proxy(mcp_integration::proxy::ProxyComponent), Default::default(), ) .run(sacp::ByteStreams::new( @@ -224,13 +223,14 @@ async fn test_agent_handles_prompt() -> Result<(), sacp::Error> { tracing::debug!(session_id = %session.session_id.0, "Session created"); - // Send a prompt to call the echo tool via ElizACP's command syntax + // Send a prompt to call the echo tool let prompt_response = recv(connection_to_editor.send_request(PromptRequest::new( session.session_id.clone(), - vec![ContentBlock::Text(TextContent::new( - r#"Use tool test::echo with {"message": "Hello from the test!"}"# - .to_string(), - ))], + vec![ContentBlock::Text(TextContent::new(TestyCommand::CallTool { + server: "test".to_string(), + tool: "echo".to_string(), + params: serde_json::json!({"message": "Hello from the test!"}), + }.to_prompt()))], ))) .await?; diff --git a/src/sacp-conductor/tests/nested_arrow_proxy.rs b/src/sacp-conductor/tests/nested_arrow_proxy.rs index 7045cdb3..0bec7631 100644 --- a/src/sacp-conductor/tests/nested_arrow_proxy.rs +++ b/src/sacp-conductor/tests/nested_arrow_proxy.rs @@ -6,27 +6,28 @@ //! 3. The full proxy chain works end-to-end //! //! Chain structure: -//! test-editor -> conductor -> arrow_proxy1 -> arrow_proxy2 -> eliza +//! test-editor -> conductor -> arrow_proxy1 -> arrow_proxy2 -> test_agent //! //! Expected behavior: -//! - arrow_proxy2 adds first '>' to eliza's response: ">Hello..." +//! - arrow_proxy2 adds first '>' to test_agent's response: ">Hello..." //! - arrow_proxy1 adds second '>' to that: ">>Hello..." //! //! Run `just prep-tests` before running this test. use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; -use sacp_test::test_binaries::{arrow_proxy_example, elizacp}; +use sacp_test::test_binaries::{arrow_proxy_example, testy}; +use sacp_test::testy::TestyCommand; use sacp_tokio::AcpAgent; use tokio::io::duplex; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; #[tokio::test] async fn test_conductor_with_two_external_arrow_proxies() -> Result<(), sacp::Error> { - // Create the component chain: arrow_proxy1 -> arrow_proxy2 -> eliza + // Create the component chain: arrow_proxy1 -> arrow_proxy2 -> test_agent // Uses pre-built binaries to avoid cargo run races during `cargo test --all` let arrow_proxy1 = AcpAgent::from_args([arrow_proxy_example().to_string_lossy().to_string()])?; let arrow_proxy2 = AcpAgent::from_args([arrow_proxy_example().to_string_lossy().to_string()])?; - let eliza = elizacp(); + let agent = testy(); // Create duplex streams for editor <-> conductor communication let (editor_write, conductor_read) = duplex(8192); @@ -36,7 +37,7 @@ async fn test_conductor_with_two_external_arrow_proxies() -> Result<(), sacp::Er let conductor_handle = tokio::spawn(async move { ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(eliza) + ProxiesAndAgent::new(agent) .proxy(arrow_proxy1) .proxy(arrow_proxy2), Default::default(), @@ -52,12 +53,12 @@ async fn test_conductor_with_two_external_arrow_proxies() -> Result<(), sacp::Er let result = tokio::time::timeout(std::time::Duration::from_secs(30), async move { let result = yopo::prompt( sacp::ByteStreams::new(editor_write.compat_write(), editor_read.compat()), - "Hello", + TestyCommand::Greet.to_prompt(), ) .await?; expect_test::expect![[r#" - ">>How do you do. Please state your problem." + ">>Hello, world!" "#]] .assert_debug_eq(&result); diff --git a/src/sacp-conductor/tests/nested_conductor.rs b/src/sacp-conductor/tests/nested_conductor.rs index 5e450a66..c3fda25d 100644 --- a/src/sacp-conductor/tests/nested_conductor.rs +++ b/src/sacp-conductor/tests/nested_conductor.rs @@ -19,10 +19,11 @@ //! //! Run `just prep-tests` before running these tests. -use sacp::{Agent, Client, Conductor, ConnectTo, DynConnectTo}; +use sacp::{Conductor, ConnectTo, DynConnectTo}; use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; use sacp_test::arrow_proxy::run_arrow_proxy; -use sacp_test::test_binaries::{arrow_proxy_example, conductor_binary, elizacp}; +use sacp_test::test_binaries::{arrow_proxy_example, conductor_binary, testy}; +use sacp_test::testy::{Testy, TestyCommand}; use sacp_tokio::AcpAgent; use tokio::io::duplex; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -37,16 +38,6 @@ impl ConnectTo for MockArrowProxy { } } -/// Mock Eliza component for testing. -/// Runs the Eliza agent logic in-process instead of spawning a subprocess. -struct MockEliza; - -impl ConnectTo for MockEliza { - async fn connect_to(self, client: impl ConnectTo) -> Result<(), sacp::Error> { - ConnectTo::::connect_to(elizacp::ElizaAgent::new(true), client).await - } -} - /// Mock inner conductor component for testing. /// Creates a nested conductor that runs in-process with mock arrow proxies. struct MockInnerConductor { @@ -95,7 +86,7 @@ async fn test_nested_conductor_with_arrow_proxies() -> Result<(), sacp::Error> { let conductor_handle = tokio::spawn(async move { ConductorImpl::new_agent( "outer-conductor".to_string(), - ProxiesAndAgent::new(MockEliza).proxy(MockInnerConductor::new(2)), + ProxiesAndAgent::new(Testy::new()).proxy(MockInnerConductor::new(2)), Default::default(), ) .run(sacp::ByteStreams::new( @@ -109,14 +100,14 @@ async fn test_nested_conductor_with_arrow_proxies() -> Result<(), sacp::Error> { let result = tokio::time::timeout(std::time::Duration::from_secs(30), async move { let result = yopo::prompt( sacp::ByteStreams::new(editor_write.compat_write(), editor_read.compat()), - "Hello", + TestyCommand::Greet.to_prompt(), ) .await?; tracing::debug!(?result, "Received response from nested conductor chain"); expect_test::expect![[r#" - ">>How do you do. Please state your problem." + ">>Hello, world!" "#]] .assert_debug_eq(&result); @@ -140,7 +131,7 @@ async fn test_nested_conductor_with_arrow_proxies() -> Result<(), sacp::Error> { async fn test_nested_conductor_with_external_arrow_proxies() -> Result<(), sacp::Error> { // Create the nested component chain using external processes // Inner conductor spawned as a separate process with two arrow proxies - // Outer conductor manages: inner_conductor -> eliza (both as external processes) + // Outer conductor manages: inner_conductor -> test agent (both as external processes) // Uses pre-built binaries to avoid cargo run races during `cargo test --all` let conductor_path = conductor_binary().to_string_lossy().to_string(); let arrow_proxy_path = arrow_proxy_example().to_string_lossy().to_string(); @@ -150,7 +141,7 @@ async fn test_nested_conductor_with_external_arrow_proxies() -> Result<(), sacp: &arrow_proxy_path, &arrow_proxy_path, ])?; - let eliza = elizacp(); + let agent = testy(); // Create duplex streams for editor <-> conductor communication let (editor_write, conductor_read) = duplex(8192); @@ -160,7 +151,7 @@ async fn test_nested_conductor_with_external_arrow_proxies() -> Result<(), sacp: let conductor_handle = tokio::spawn(async move { ConductorImpl::new_agent( "outer-conductor".to_string(), - ProxiesAndAgent::new(eliza).proxy(inner_conductor), + ProxiesAndAgent::new(agent).proxy(inner_conductor), Default::default(), ) .run(sacp::ByteStreams::new( @@ -174,14 +165,14 @@ async fn test_nested_conductor_with_external_arrow_proxies() -> Result<(), sacp: let result = tokio::time::timeout(std::time::Duration::from_secs(30), async move { let result = yopo::prompt( sacp::ByteStreams::new(editor_write.compat_write(), editor_read.compat()), - "Hello", + TestyCommand::Greet.to_prompt(), ) .await?; tracing::debug!(?result, "Received response from nested conductor chain"); expect_test::expect![[r#" - ">>How do you do. Please state your problem." + ">>Hello, world!" "#]] .assert_debug_eq(&result); diff --git a/src/sacp-conductor/tests/scoped_mcp_server.rs b/src/sacp-conductor/tests/scoped_mcp_server.rs index 6acad5f3..e1ceb818 100644 --- a/src/sacp-conductor/tests/scoped_mcp_server.rs +++ b/src/sacp-conductor/tests/scoped_mcp_server.rs @@ -4,10 +4,10 @@ //! can capture references to stack-local data (like a Vec) and push to it //! when the tool is invoked. -use elizacp::ElizaAgent; use sacp::mcp_server::McpServer; use sacp::{Agent, Conductor, ConnectTo, Proxy, Role, RunWithConnectionTo}; use sacp_conductor::{ConductorImpl, McpBridgeMode, ProxiesAndAgent}; +use sacp_test::testy::{Testy, TestyCommand}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::sync::Mutex; @@ -20,13 +20,18 @@ use std::sync::Mutex; async fn test_scoped_mcp_server_through_proxy() -> Result<(), sacp::Error> { let conductor = ConductorImpl::new_agent( "conductor".to_string(), - ProxiesAndAgent::new(ElizaAgent::new(true)).proxy(ScopedProxy), + ProxiesAndAgent::new(Testy::new()).proxy(ScopedProxy), Default::default(), ); let result = yopo::prompt( conductor, - r#"Use tool test::push with {"elements": ["Hello", "world"]}"#, + TestyCommand::CallTool { + server: "test".to_string(), + tool: "push".to_string(), + params: serde_json::json!({"elements": ["Hello", "world"]}), + } + .to_prompt(), ) .await?; @@ -49,7 +54,7 @@ async fn test_scoped_mcp_server_through_session() -> Result<(), sacp::Error> { .connect_with( ConductorImpl::new_agent( "conductor".to_string(), - ProxiesAndAgent::new(ElizaAgent::new(true)), + ProxiesAndAgent::new(Testy::new()), McpBridgeMode::default(), ), async |cx| { @@ -67,7 +72,11 @@ async fn test_scoped_mcp_server_through_session() -> Result<(), sacp::Error> { .block_task() .run_until(async |mut active_session| { active_session - .send_prompt(r#"Use tool test::push with {"elements": ["Hello", "world"]}"#)?; + .send_prompt(TestyCommand::CallTool { + server: "test".to_string(), + tool: "push".to_string(), + params: serde_json::json!({"elements": ["Hello", "world"]}), + }.to_prompt())?; active_session.read_to_string().await }) .await?; diff --git a/src/sacp-conductor/tests/test_mcp_tool_output_types.rs b/src/sacp-conductor/tests/test_mcp_tool_output_types.rs index cf5f8b3c..740671c4 100644 --- a/src/sacp-conductor/tests/test_mcp_tool_output_types.rs +++ b/src/sacp-conductor/tests/test_mcp_tool_output_types.rs @@ -4,12 +4,11 @@ //! when tools return non-object types like bare strings or integers. use sacp::mcp_server::McpServer; -use sacp::{Agent, Client, Conductor, ConnectTo, DynConnectTo, Proxy, RunWithConnectionTo}; +use sacp::{Conductor, ConnectTo, DynConnectTo, Proxy, RunWithConnectionTo}; use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; +use sacp_test::testy::{Testy, TestyCommand}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use tokio::io::duplex; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; /// Empty input for test tools #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -53,42 +52,20 @@ impl + 'static + Send> ConnectTo } } -/// Elizacp agent component wrapper for testing -struct ElizacpAgentComponent; - -impl ConnectTo for ElizacpAgentComponent { - async fn connect_to(self, client: impl ConnectTo) -> Result<(), sacp::Error> { - let (elizacp_write, client_read) = duplex(8192); - let (client_write, elizacp_read) = duplex(8192); - - let elizacp_transport = - sacp::ByteStreams::new(elizacp_write.compat_write(), elizacp_read.compat()); - - let client_transport = - sacp::ByteStreams::new(client_write.compat_write(), client_read.compat()); - - tokio::spawn(async move { - if let Err(e) = - ConnectTo::::connect_to(elizacp::ElizaAgent::new(true), elizacp_transport) - .await - { - tracing::error!("Elizacp error: {}", e); - } - }); - - ConnectTo::::connect_to(client_transport, client).await - } -} - #[tokio::test] async fn test_tool_returning_string() -> Result<(), sacp::Error> { let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_test_proxy()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_test_proxy()?), Default::default(), ), - r#"Use tool test_server::return_string with {}"#, + TestyCommand::CallTool { + server: "test_server".to_string(), + tool: "return_string".to_string(), + params: serde_json::json!({}), + } + .to_prompt(), ) .await?; @@ -106,10 +83,15 @@ async fn test_tool_returning_integer() -> Result<(), sacp::Error> { let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_test_proxy()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_test_proxy()?), Default::default(), ), - r#"Use tool test_server::return_integer with {}"#, + TestyCommand::CallTool { + server: "test_server".to_string(), + tool: "return_integer".to_string(), + params: serde_json::json!({}), + } + .to_prompt(), ) .await?; diff --git a/src/sacp-conductor/tests/test_session_id_in_mcp_tools.rs b/src/sacp-conductor/tests/test_session_id_in_mcp_tools.rs index 4ceb920a..44bc7e0c 100644 --- a/src/sacp-conductor/tests/test_session_id_in_mcp_tools.rs +++ b/src/sacp-conductor/tests/test_session_id_in_mcp_tools.rs @@ -3,19 +3,18 @@ //! This test verifies the complete flow: //! 1. Editor creates a session and receives a session_id //! 2. Proxy provides an MCP server with an echo tool -//! 3. Elizacp agent invokes the tool +//! 3. Test agent invokes the tool //! 4. The tool receives the correct session_id in its context //! 5. The tool returns the session_id in its response //! 6. We verify the session_ids match use sacp::RunWithConnectionTo; use sacp::mcp_server::McpServer; -use sacp::{Agent, Client, Conductor, ConnectTo, DynConnectTo, Proxy}; +use sacp::{Conductor, ConnectTo, DynConnectTo, Proxy}; use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; +use sacp_test::testy::{Testy, TestyCommand}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use tokio::io::duplex; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; /// Input for the echo tool (null/empty) #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -65,49 +64,20 @@ impl + 'static + Send> ConnectTo } } -/// Elizacp agent component wrapper for testing -struct ElizacpAgentComponent; - -impl ConnectTo for ElizacpAgentComponent { - async fn connect_to(self, client: impl ConnectTo) -> Result<(), sacp::Error> { - // Create duplex channels for bidirectional communication - let (elizacp_write, client_read) = duplex(8192); - let (client_write, elizacp_read) = duplex(8192); - - let elizacp_transport = - sacp::ByteStreams::new(elizacp_write.compat_write(), elizacp_read.compat()); - - let client_transport = - sacp::ByteStreams::new(client_write.compat_write(), client_read.compat()); - - // Spawn elizacp in a background task - tokio::spawn(async move { - if let Err(e) = - ConnectTo::::connect_to(elizacp::ElizaAgent::new(true), elizacp_transport) - .await - { - tracing::error!("Elizacp error: {}", e); - } - }); - - // Serve the client with the transport connected to elizacp - ConnectTo::::connect_to(client_transport, client).await - } -} - #[tokio::test] async fn test_list_tools_from_mcp_server() -> Result<(), sacp::Error> { use expect_test::expect; - // Create the component chain: proxy with echo server -> eliza - // Use yopo to send the prompt and get the response let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_echo_proxy()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_echo_proxy()?), Default::default(), ), - "List tools from echo_server", + TestyCommand::ListTools { + server: "echo_server".to_string(), + } + .to_prompt(), ) .await?; @@ -125,10 +95,15 @@ async fn test_session_id_delivered_to_mcp_tools() -> Result<(), sacp::Error> { let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_echo_proxy()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_echo_proxy()?), Default::default(), ), - r#"Use tool echo_server::echo with {}"#, + TestyCommand::CallTool { + server: "echo_server".to_string(), + tool: "echo".to_string(), + params: serde_json::json!({}), + } + .to_prompt(), ) .await?; diff --git a/src/sacp-conductor/tests/test_tool_enable_disable.rs b/src/sacp-conductor/tests/test_tool_enable_disable.rs index 702cb5cb..41c83f19 100644 --- a/src/sacp-conductor/tests/test_tool_enable_disable.rs +++ b/src/sacp-conductor/tests/test_tool_enable_disable.rs @@ -4,12 +4,11 @@ //! and `enable_all_tools` correctly filter which tools are visible and callable. use sacp::mcp_server::McpServer; -use sacp::{Agent, Client, Conductor, ConnectTo, DynConnectTo, Proxy, RunWithConnectionTo}; +use sacp::{Conductor, ConnectTo, DynConnectTo, Proxy, RunWithConnectionTo}; use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; +use sacp_test::testy::{Testy, TestyCommand}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use tokio::io::duplex; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; /// Input for the echo tool #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -99,33 +98,6 @@ impl + 'static + Send> ConnectTo fo } } -/// Elizacp agent component wrapper for testing -struct ElizacpAgentComponent; - -impl ConnectTo for ElizacpAgentComponent { - async fn connect_to(self, client: impl ConnectTo) -> Result<(), sacp::Error> { - let (elizacp_write, client_read) = duplex(8192); - let (client_write, elizacp_read) = duplex(8192); - - let elizacp_transport = - sacp::ByteStreams::new(elizacp_write.compat_write(), elizacp_read.compat()); - - let client_transport = - sacp::ByteStreams::new(client_write.compat_write(), client_read.compat()); - - tokio::spawn(async move { - if let Err(e) = - ConnectTo::::connect_to(elizacp::ElizaAgent::new(true), elizacp_transport) - .await - { - tracing::error!("Elizacp error: {}", e); - } - }); - - ConnectTo::::connect_to(client_transport, client).await - } -} - // ============================================================================ // Tests for deny-list (disable specific tools) // ============================================================================ @@ -135,10 +107,13 @@ async fn test_list_tools_excludes_disabled() -> Result<(), sacp::Error> { let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_disabled_tool()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_proxy_with_disabled_tool()?), Default::default(), ), - "List tools from test_server", + TestyCommand::ListTools { + server: "test_server".to_string(), + } + .to_prompt(), ) .await?; @@ -158,10 +133,15 @@ async fn test_enabled_tool_can_be_called() -> Result<(), sacp::Error> { let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_disabled_tool()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_proxy_with_disabled_tool()?), Default::default(), ), - r#"Use tool test_server::echo with {"message": "hello"}"#, + TestyCommand::CallTool { + server: "test_server".to_string(), + tool: "echo".to_string(), + params: serde_json::json!({"message": "hello"}), + } + .to_prompt(), ) .await?; @@ -179,10 +159,15 @@ async fn test_disabled_tool_returns_not_found() -> Result<(), sacp::Error> { let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_disabled_tool()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_proxy_with_disabled_tool()?), Default::default(), ), - r#"Use tool test_server::secret with {}"#, + TestyCommand::CallTool { + server: "test_server".to_string(), + tool: "secret".to_string(), + params: serde_json::json!({}), + } + .to_prompt(), ) .await?; @@ -205,10 +190,13 @@ async fn test_allowlist_only_shows_enabled_tools() -> Result<(), sacp::Error> { let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_allowlist()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_proxy_with_allowlist()?), Default::default(), ), - "List tools from allowlist_server", + TestyCommand::ListTools { + server: "allowlist_server".to_string(), + } + .to_prompt(), ) .await?; @@ -231,10 +219,15 @@ async fn test_allowlist_enabled_tool_works() -> Result<(), sacp::Error> { let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_allowlist()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_proxy_with_allowlist()?), Default::default(), ), - r#"Use tool allowlist_server::echo with {"message": "allowed"}"#, + TestyCommand::CallTool { + server: "allowlist_server".to_string(), + tool: "echo".to_string(), + params: serde_json::json!({"message": "allowed"}), + } + .to_prompt(), ) .await?; @@ -252,10 +245,15 @@ async fn test_allowlist_non_enabled_tool_returns_not_found() -> Result<(), sacp: let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_allowlist()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_proxy_with_allowlist()?), Default::default(), ), - r#"Use tool allowlist_server::greet with {"name": "World"}"#, + TestyCommand::CallTool { + server: "allowlist_server".to_string(), + tool: "greet".to_string(), + params: serde_json::json!({"name": "World"}), + } + .to_prompt(), ) .await?; diff --git a/src/sacp-conductor/tests/test_tool_fn.rs b/src/sacp-conductor/tests/test_tool_fn.rs index 31d25bef..75579b5e 100644 --- a/src/sacp-conductor/tests/test_tool_fn.rs +++ b/src/sacp-conductor/tests/test_tool_fn.rs @@ -4,12 +4,11 @@ //! that don't need mutable state. use sacp::mcp_server::McpServer; -use sacp::{Client, Conductor, ConnectTo, DynConnectTo, Proxy, RunWithConnectionTo}; +use sacp::{Conductor, ConnectTo, DynConnectTo, Proxy, RunWithConnectionTo}; use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; +use sacp_test::testy::{Testy, TestyCommand}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use tokio::io::duplex; -use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; /// Input for the greet tool #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -51,45 +50,20 @@ impl + 'static + Send> ConnectTo } } -/// Elizacp agent component wrapper for testing -struct ElizacpAgentComponent; - -impl ConnectTo for ElizacpAgentComponent { - async fn connect_to(self, client: impl ConnectTo) -> Result<(), sacp::Error> { - // Create duplex channels for bidirectional communication - let (elizacp_write, client_read) = duplex(8192); - let (client_write, elizacp_read) = duplex(8192); - - let elizacp_transport = - sacp::ByteStreams::new(elizacp_write.compat_write(), elizacp_read.compat()); - - let client_transport = - sacp::ByteStreams::new(client_write.compat_write(), client_read.compat()); - - // Spawn elizacp in a background task - tokio::spawn(async move { - if let Err(e) = - ConnectTo::::connect_to(elizacp::ElizaAgent::new(true), elizacp_transport) - .await - { - tracing::error!("Elizacp error: {}", e); - } - }); - - // Serve the client with the transport connected to elizacp - ConnectTo::::connect_to(client_transport, client).await - } -} - #[tokio::test] async fn test_tool_fn_greet() -> Result<(), sacp::Error> { let result = yopo::prompt( ConductorImpl::new_agent( "test-conductor".to_string(), - ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_greet_proxy()?), + ProxiesAndAgent::new(Testy::new()).proxy(create_greet_proxy()?), Default::default(), ), - r#"Use tool greet_server::greet with {"name": "World"}"#, + TestyCommand::CallTool { + server: "greet_server".to_string(), + tool: "greet".to_string(), + params: serde_json::json!({"name": "World"}), + } + .to_prompt(), ) .await?; diff --git a/src/sacp-conductor/tests/trace_client_mcp_server.rs b/src/sacp-conductor/tests/trace_client_mcp_server.rs index 9581a80e..b37c50f6 100644 --- a/src/sacp-conductor/tests/trace_client_mcp_server.rs +++ b/src/sacp-conductor/tests/trace_client_mcp_server.rs @@ -9,7 +9,6 @@ //! Unlike trace_mcp_tool_call.rs which tests proxy-hosted MCP servers, this test //! verifies that MCP requests travel all the way back to the client. -use elizacp::ElizaAgent; use expect_test::expect; use futures::StreamExt; use futures::channel::mpsc; @@ -18,6 +17,7 @@ use sacp::schema::{InitializeRequest, ProtocolVersion}; use sacp::{Client, Role, RunWithConnectionTo}; use sacp_conductor::trace::TraceEvent; use sacp_conductor::{ConductorImpl, McpBridgeMode, ProxiesAndAgent}; +use sacp_test::testy::{Testy, TestyCommand}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -229,7 +229,7 @@ async fn test_trace_client_mcp_server() -> Result<(), sacp::Error> { let conductor_handle = tokio::spawn(async move { ConductorImpl::new_agent( "conductor".to_string(), - ProxiesAndAgent::new(ElizaAgent::new(true)), + ProxiesAndAgent::new(Testy::new()), McpBridgeMode::default(), ) .trace_to(trace_tx) @@ -242,7 +242,8 @@ async fn test_trace_client_mcp_server() -> Result<(), sacp::Error> { // Run the client with a client-hosted MCP server let test_result = tokio::time::timeout(std::time::Duration::from_secs(30), async move { - sacp::Client.builder() + sacp::Client + .builder() .name("test-client") .connect_with( sacp::ByteStreams::new(client_write.compat_write(), client_read.compat()), @@ -263,9 +264,11 @@ async fn test_trace_client_mcp_server() -> Result<(), sacp::Error> { .run_until(async |mut session| { // Send prompt that triggers MCP tool call // The tool call will travel: agent → conductor → client - session.send_prompt( - r#"Use tool echo-server::echo with {"message": "Hello from client test!"}"#, - )?; + session.send_prompt(TestyCommand::CallTool { + server: "echo-server".to_string(), + tool: "echo".to_string(), + params: serde_json::json!({"message": "Hello from client test!"}), + }.to_prompt())?; session.read_to_string().await }) .await?; @@ -406,7 +409,7 @@ async fn test_trace_client_mcp_server() -> Result<(), sacp::Error> { params: Object { "prompt": Array [ Object { - "text": String("Use tool echo-server::echo with {\"message\": \"Hello from client test!\"}"), + "text": String("{\"command\":\"call_tool\",\"server\":\"echo-server\",\"tool\":\"echo\",\"params\":{\"message\":\"Hello from client test!\"}}"), "type": String("text"), }, ], diff --git a/src/sacp-conductor/tests/trace_generation.rs b/src/sacp-conductor/tests/trace_generation.rs index 63f4ce74..a2748a75 100644 --- a/src/sacp-conductor/tests/trace_generation.rs +++ b/src/sacp-conductor/tests/trace_generation.rs @@ -8,7 +8,8 @@ //! Run `just prep-tests` before running this test. use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; -use sacp_test::test_binaries::{arrow_proxy_example, elizacp}; +use sacp_test::test_binaries::{arrow_proxy_example, testy}; +use sacp_test::testy::TestyCommand; use sacp_tokio::AcpAgent; use tokio::io::duplex; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -27,7 +28,7 @@ async fn test_trace_generation() -> Result<(), sacp::Error> { // Uses pre-built binaries to avoid cargo run races during `cargo test --all` let arrow_proxy_agent = AcpAgent::from_args([arrow_proxy_example().to_string_lossy().to_string()])?; - let eliza_agent = elizacp(); + let eliza_agent = testy(); // Create duplex streams for editor <-> conductor communication let (editor_write, conductor_read) = duplex(8192); @@ -55,7 +56,7 @@ async fn test_trace_generation() -> Result<(), sacp::Error> { let result = tokio::time::timeout(std::time::Duration::from_secs(30), async move { let result = yopo::prompt( sacp::ByteStreams::new(editor_write.compat_write(), editor_read.compat()), - "Hello", + TestyCommand::Greet.to_prompt(), ) .await?; diff --git a/src/sacp-conductor/tests/trace_mcp_tool_call.rs b/src/sacp-conductor/tests/trace_mcp_tool_call.rs index e883c92d..f88699d2 100644 --- a/src/sacp-conductor/tests/trace_mcp_tool_call.rs +++ b/src/sacp-conductor/tests/trace_mcp_tool_call.rs @@ -10,7 +10,6 @@ mod mcp_integration; -use elizacp::ElizaAgent; use expect_test::expect; use futures::channel::mpsc; use futures::{SinkExt, StreamExt}; @@ -20,6 +19,7 @@ use sacp::schema::{ }; use sacp_conductor::trace::TraceEvent; use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; +use sacp_test::testy::{Testy, TestyCommand}; use std::collections::HashMap; use tokio::io::duplex; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; @@ -212,8 +212,7 @@ async fn test_trace_mcp_tool_call() -> Result<(), sacp::Error> { let conductor_handle = tokio::spawn(async move { ConductorImpl::new_agent( "conductor".to_string(), - ProxiesAndAgent::new(ElizaAgent::new(true)) - .proxy(mcp_integration::proxy::ProxyComponent), + ProxiesAndAgent::new(Testy::new()).proxy(mcp_integration::proxy::ProxyComponent), Default::default(), ) .trace_to(trace_tx) @@ -254,13 +253,13 @@ async fn test_trace_mcp_tool_call() -> Result<(), sacp::Error> { .await?; // Send prompt that triggers MCP tool call - // ElizaCP will parse this and make a tools/call request recv(cx.send_request(PromptRequest::new( session.session_id.clone(), - vec![ContentBlock::Text(TextContent::new( - r#"Use tool test::echo with {"message": "Hello from trace test!"}"# - .to_string(), - ))], + vec![ContentBlock::Text(TextContent::new(TestyCommand::CallTool { + server: "test".to_string(), + tool: "echo".to_string(), + params: serde_json::json!({"message": "Hello from trace test!"}), + }.to_prompt()))], ))) .await?; @@ -404,7 +403,7 @@ async fn test_trace_mcp_tool_call() -> Result<(), sacp::Error> { params: Object { "prompt": Array [ Object { - "text": String("Use tool test::echo with {\"message\": \"Hello from trace test!\"}"), + "text": String("{\"command\":\"call_tool\",\"server\":\"test\",\"tool\":\"echo\",\"params\":{\"message\":\"Hello from trace test!\"}}"), "type": String("text"), }, ], diff --git a/src/sacp-conductor/tests/trace_snapshot.rs b/src/sacp-conductor/tests/trace_snapshot.rs index 3ce92446..1e239e24 100644 --- a/src/sacp-conductor/tests/trace_snapshot.rs +++ b/src/sacp-conductor/tests/trace_snapshot.rs @@ -1,6 +1,6 @@ //! Snapshot test for trace events from a real yopo interaction. //! -//! This test runs yopo -> conductor (with arrow_proxy -> elizacp) and +//! This test runs yopo -> conductor (with arrow_proxy -> test_agent) and //! captures trace events to a channel for expect_test snapshot verification. //! //! Run `just prep-tests` before running this test. @@ -10,7 +10,8 @@ use futures::StreamExt; use futures::channel::mpsc; use sacp_conductor::trace::TraceEvent; use sacp_conductor::{ConductorImpl, ProxiesAndAgent}; -use sacp_test::test_binaries::{arrow_proxy_example, elizacp}; +use sacp_test::test_binaries::{arrow_proxy_example, testy}; +use sacp_test::testy::TestyCommand; use sacp_tokio::AcpAgent; use std::collections::HashMap; use tokio::io::duplex; @@ -135,7 +136,7 @@ async fn test_trace_snapshot() -> Result<(), sacp::Error> { // Uses pre-built binaries to avoid cargo run races during `cargo test --all` let arrow_proxy_agent = AcpAgent::from_args([arrow_proxy_example().to_string_lossy().to_string()])?; - let eliza_agent = elizacp(); + let eliza_agent = testy(); // Create duplex streams for editor <-> conductor communication let (editor_write, conductor_read) = duplex(8192); @@ -160,7 +161,7 @@ async fn test_trace_snapshot() -> Result<(), sacp::Error> { let result = tokio::time::timeout(std::time::Duration::from_secs(30), async move { yopo::prompt( sacp::ByteStreams::new(editor_write.compat_write(), editor_read.compat()), - "Hello", + TestyCommand::Greet.to_prompt(), ) .await }) @@ -263,7 +264,7 @@ async fn test_trace_snapshot() -> Result<(), sacp::Error> { params: Object { "prompt": Array [ Object { - "text": String("Hello"), + "text": String("{\"command\":\"greet\"}"), "type": String("text"), }, ], @@ -283,7 +284,7 @@ async fn test_trace_snapshot() -> Result<(), sacp::Error> { "sessionId": String("session:0"), "update": Object { "content": Object { - "text": String("How do you do. Please state your problem."), + "text": String("Hello, world!"), "type": String("text"), }, "sessionUpdate": String("agent_message_chunk"), diff --git a/src/sacp-test/Cargo.toml b/src/sacp-test/Cargo.toml index bf535aad..a2414210 100644 --- a/src/sacp-test/Cargo.toml +++ b/src/sacp-test/Cargo.toml @@ -4,16 +4,23 @@ version = "11.0.0" edition = "2024" description = "Test utilities and mock implementations for SACP" license = "MIT OR Apache-2.0" +publish = false [[bin]] name = "mcp-echo-server" path = "src/bin/mcp_echo_server.rs" +[[bin]] +name = "testy" +path = "src/bin/testy.rs" + [dependencies] sacp = { version = "11.0.0", path = "../sacp" } sacp-tokio = { version = "11.0.0", path = "../sacp-tokio" } yopo = { version = "11.0.0", path = "../yopo" } -rmcp = { workspace = true, features = ["server"] } +rmcp = { workspace = true, features = ["server", "client", "transport-child-process", "transport-streamable-http-client-reqwest"] } +anyhow.workspace = true +uuid.workspace = true schemars.workspace = true serde.workspace = true diff --git a/src/sacp-test/src/bin/testy.rs b/src/sacp-test/src/bin/testy.rs new file mode 100644 index 00000000..ba865aab --- /dev/null +++ b/src/sacp-test/src/bin/testy.rs @@ -0,0 +1,11 @@ +use sacp::ConnectTo; +use sacp_test::testy::Testy; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .init(); + Testy::new().connect_to(sacp_tokio::Stdio::new()).await?; + Ok(()) +} diff --git a/src/sacp-test/src/lib.rs b/src/sacp-test/src/lib.rs index ab91e633..e45a1b8d 100644 --- a/src/sacp-test/src/lib.rs +++ b/src/sacp-test/src/lib.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; pub mod arrow_proxy; pub mod test_binaries; +pub mod testy; /// A mock transport for doctests that panics if actually used. /// This is only for documentation examples that don't actually run. diff --git a/src/sacp-test/src/test_binaries.rs b/src/sacp-test/src/test_binaries.rs index 562e41ac..35796ae9 100644 --- a/src/sacp-test/src/test_binaries.rs +++ b/src/sacp-test/src/test_binaries.rs @@ -52,21 +52,17 @@ pub fn conductor_binary() -> PathBuf { path } -/// Returns the path to the elizacp binary, asserting it exists. -pub fn elizacp_binary() -> PathBuf { - let path = debug_binary("elizacp"); +/// Returns the path to the test-agent binary, asserting it exists. +pub fn testy_binary() -> PathBuf { + let path = debug_binary("testy"); require_binary(&path); path } -/// Returns an AcpAgent configured for elizacp in deterministic mode. -pub fn elizacp() -> sacp_tokio::AcpAgent { - sacp_tokio::AcpAgent::from_args([ - elizacp_binary().to_string_lossy().to_string(), - "--deterministic".to_string(), - "acp".to_string(), - ]) - .expect("failed to create elizacp agent") +/// Returns an AcpAgent configured for the test agent. +pub fn testy() -> sacp_tokio::AcpAgent { + sacp_tokio::AcpAgent::from_args([testy_binary().to_string_lossy().to_string()]) + .expect("failed to create test agent") } /// Returns the path to the mcp-echo-server binary, asserting it exists. diff --git a/src/sacp-test/src/testy.rs b/src/sacp-test/src/testy.rs new file mode 100644 index 00000000..28fa426a --- /dev/null +++ b/src/sacp-test/src/testy.rs @@ -0,0 +1,290 @@ +//! testy: you friendly neighborhood ACP test agent with typed JSON commands. +//! +//! The agent accepts JSON-serialized [`TestyCommand`] values as prompt text. + +use anyhow::Result; +use sacp::schema::{ + AgentCapabilities, ContentBlock, ContentChunk, InitializeRequest, InitializeResponse, + McpServer, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, SessionId, + SessionNotification, SessionUpdate, StopReason, TextContent, +}; +use sacp::{Agent, Client, ConnectTo, ConnectionTo, Responder}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// Commands that can be sent as prompt text (serialized as JSON) to the [`Testy`]. +/// +/// Tests construct these as typed values and serialize to JSON via [`TestyCommand::to_prompt`]. +/// The agent deserializes the prompt text and dispatches accordingly. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum TestyCommand { + /// Responds with `"Hello, world!"`. + Greet, + + /// Echoes the given message back as the response. + Echo { message: String }, + + /// Invokes an MCP tool and returns the result. + /// The agent must have been given MCP servers in the `NewSessionRequest`. + CallTool { + server: String, + tool: String, + #[serde(default)] + params: serde_json::Value, + }, + + /// Lists tools from the named MCP server. + ListTools { server: String }, +} + +impl TestyCommand { + /// Serialize this command to a JSON string suitable for use as prompt text. + pub fn to_prompt(&self) -> String { + serde_json::to_string(self).expect("TestyCommand serialization should not fail") + } +} + +/// Session data for each active session. +#[derive(Clone)] +struct SessionData { + mcp_servers: Vec, +} + +/// A minimal ACP test agent. +/// +/// Implements `ConnectTo` and handles `InitializeRequest`, `NewSessionRequest`, +/// and `PromptRequest`. Prompt text is parsed as a JSON [`TestyCommand`]; if parsing fails, +/// the agent responds with `"Hello, world!"` (equivalent to [`TestyCommand::Greet`]). +#[derive(Clone)] +pub struct Testy { + sessions: Arc>>, +} + +impl Testy { + pub fn new() -> Self { + Self { + sessions: Arc::new(Mutex::new(HashMap::new())), + } + } + + fn create_session(&self, session_id: &SessionId, mcp_servers: Vec) { + let mut sessions = self.sessions.lock().unwrap(); + sessions.insert(session_id.clone(), SessionData { mcp_servers }); + } + + fn get_mcp_servers(&self, session_id: &SessionId) -> Option> { + let sessions = self.sessions.lock().unwrap(); + sessions + .get(session_id) + .map(|session| session.mcp_servers.clone()) + } + + async fn process_prompt( + &self, + request: PromptRequest, + responder: Responder, + connection: ConnectionTo, + ) -> Result<(), sacp::Error> { + let session_id = request.session_id.clone(); + let input_text = extract_text_from_prompt(&request.prompt); + + let command: TestyCommand = + serde_json::from_str(&input_text).unwrap_or(TestyCommand::Greet); + + let response_text = match command { + TestyCommand::Greet => "Hello, world!".to_string(), + + TestyCommand::Echo { message } => message, + + TestyCommand::CallTool { + server, + tool, + params, + } => match self + .execute_tool_call(&session_id, &server, &tool, params) + .await + { + Ok(result) => format!("OK: {}", result), + Err(e) => format!("ERROR: {}", e), + }, + + TestyCommand::ListTools { server } => { + match self.list_tools(&session_id, &server).await { + Ok(tools) => format!("Available tools:\n{}", tools), + Err(e) => format!("ERROR: {}", e), + } + } + }; + + connection.send_notification(SessionNotification::new( + session_id, + SessionUpdate::AgentMessageChunk(ContentChunk::new(response_text.into())), + ))?; + + responder.respond(PromptResponse::new(StopReason::EndTurn)) + } + + /// Helper to execute an operation with a spawned MCP client. + async fn with_mcp_client( + &self, + session_id: &SessionId, + server_name: &str, + operation: F, + ) -> Result + where + F: FnOnce(rmcp::service::RunningService) -> Fut, + Fut: std::future::Future>, + { + use rmcp::{ + ServiceExt, + transport::{ConfigureCommandExt, TokioChildProcess}, + }; + use tokio::process::Command; + + let mcp_servers = self + .get_mcp_servers(session_id) + .ok_or_else(|| anyhow::anyhow!("Session not found"))?; + + let mcp_server = mcp_servers + .iter() + .find(|server| match server { + McpServer::Stdio(stdio) => stdio.name == server_name, + McpServer::Http(http) => http.name == server_name, + McpServer::Sse(sse) => sse.name == server_name, + _ => false, + }) + .ok_or_else(|| anyhow::anyhow!("MCP server '{}' not found", server_name))?; + + match mcp_server { + McpServer::Stdio(stdio) => { + let mcp_client = () + .serve(TokioChildProcess::new( + Command::new(&stdio.command).configure(|cmd| { + cmd.args(&stdio.args); + for env_var in &stdio.env { + cmd.env(&env_var.name, &env_var.value); + } + }), + )?) + .await?; + + operation(mcp_client).await + } + McpServer::Http(http) => { + use rmcp::transport::StreamableHttpClientTransport; + + let mcp_client = + ().serve(StreamableHttpClientTransport::from_uri(http.url.as_str())) + .await?; + + operation(mcp_client).await + } + McpServer::Sse(_) => Err(anyhow::anyhow!("SSE MCP servers not yet supported")), + _ => Err(anyhow::anyhow!("Unknown MCP server type")), + } + } + + async fn list_tools(&self, session_id: &SessionId, server_name: &str) -> Result { + self.with_mcp_client(session_id, server_name, async move |mcp_client| { + let tools_result = mcp_client.list_tools(None).await?; + mcp_client.cancel().await?; + + let tools_list = tools_result + .tools + .iter() + .map(|tool| { + format!( + " - {}: {}", + tool.name, + tool.description.as_deref().unwrap_or("No description") + ) + }) + .collect::>() + .join("\n"); + + Ok(tools_list) + }) + .await + } + + async fn execute_tool_call( + &self, + session_id: &SessionId, + server_name: &str, + tool_name: &str, + params: serde_json::Value, + ) -> Result { + use rmcp::model::CallToolRequestParams; + + let params_obj = params.as_object().cloned().unwrap_or_default(); + let tool_name = tool_name.to_string(); + + self.with_mcp_client(session_id, server_name, async move |mcp_client| { + let tool_result = mcp_client + .call_tool(CallToolRequestParams::new(tool_name).with_arguments(params_obj)) + .await?; + + mcp_client.cancel().await?; + + Ok(format!("{:?}", tool_result)) + }) + .await + } +} + +/// Extract text content from prompt blocks. +fn extract_text_from_prompt(blocks: &[ContentBlock]) -> String { + blocks + .iter() + .filter_map(|block| match block { + ContentBlock::Text(TextContent { text, .. }) => Some(text.clone()), + _ => None, + }) + .collect::>() + .join(" ") +} + +impl ConnectTo for Testy { + async fn connect_to(self, client: impl ConnectTo) -> Result<(), sacp::Error> { + Agent + .builder() + .name("test-agent") + .on_receive_request( + async |initialize: InitializeRequest, responder, _cx| { + responder.respond( + InitializeResponse::new(initialize.protocol_version) + .agent_capabilities(AgentCapabilities::new()), + ) + }, + sacp::on_receive_request!(), + ) + .on_receive_request( + { + let agent = self.clone(); + async move |request: NewSessionRequest, responder, _cx| { + let session_id = SessionId::new(uuid::Uuid::new_v4().to_string()); + agent.create_session(&session_id, request.mcp_servers); + responder.respond(NewSessionResponse::new(session_id)) + } + }, + sacp::on_receive_request!(), + ) + .on_receive_request( + { + let agent = self.clone(); + async move |request: PromptRequest, responder, cx| { + let cx_clone = cx.clone(); + cx.spawn({ + let agent = agent.clone(); + async move { agent.process_prompt(request, responder, cx_clone).await } + }) + } + }, + sacp::on_receive_request!(), + ) + .connect_to(client) + .await + } +} diff --git a/src/sacp-tokio/tests/debug_logging.rs b/src/sacp-tokio/tests/debug_logging.rs index 039c721b..2dfe8610 100644 --- a/src/sacp-tokio/tests/debug_logging.rs +++ b/src/sacp-tokio/tests/debug_logging.rs @@ -2,7 +2,7 @@ use sacp::schema::InitializeRequest; use sacp::{Client, ConnectTo}; -use sacp_test::test_binaries::elizacp; +use sacp_test::test_binaries::testy; use sacp_tokio::LineDirection; use std::sync::{Arc, Mutex}; @@ -43,8 +43,8 @@ async fn test_acp_agent_debug_callback() -> Result<(), Box