Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ src/
│ ├── gemini.rs # Google Gemini generateContent API
│ ├── human.rs # Human-in-the-loop thinker
│ └── mock.rs # MockThinker for tests
├── tools/ # Tool trait + ToolRegistry + ShellTool
├── tools/ # Tool trait + ToolRegistry + ShellTool + ExitTool
├── memory/ # Memory trait + SqliteMemory (task + session memory)
└── spinner.rs # Thinking spinner during LLM calls
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Everything is a trait. Everything is swappable.
- **`ProviderConfig`** — defines a provider's identity, auth flow, and thinker construction
- **`Engine`** — the outermost boundary (`fn run(task) -> answer`)
- **`Thinker`** — the brain (Anthropic, Gemini, human, mock — picked via `--provider`)
- **`Tool`** — something the agent can do (shell commands, more coming)
- **`Tool`** — something the agent can do (shell commands, exit, more coming)
- **`Command`** — built-in REPL commands (`/help`, `/model`, `/new`, etc.)
- **`Memory`** — what the agent remembers (task iterations + session history, SQLite-backed)
- **`Config`** — persistent key-value settings (model preference, etc.)
Expand Down
10 changes: 10 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use golem::engine::react::{ReactConfig, ReactEngine};
use golem::memory::sqlite::SqliteMemory;
use golem::provider::{LoginProvider, Provider, build_provider, handle_login, handle_logout};
use golem::tools::ToolRegistry;
use golem::tools::exit::ExitTool;
use golem::tools::shell::{ShellConfig, ShellMode, ShellTool};

#[derive(Parser)]
Expand Down Expand Up @@ -147,6 +148,10 @@ async fn main() -> anyhow::Result<()> {

let tools = Arc::new(ToolRegistry::new());
tools.register(Arc::new(ShellTool::new(shell_config))).await;
let exit_tool = Arc::new(ExitTool::new());
tools
.register(Arc::clone(&exit_tool) as Arc<dyn golem::tools::Tool>)
.await;

// Collect tool names for /tools command
let tool_names: Vec<String> = tools
Expand Down Expand Up @@ -257,6 +262,11 @@ async fn main() -> anyhow::Result<()> {
println!("\n\ninterrupted");
}
}

// Check if the agent invoked the exit tool
if exit_tool.triggered() {
break;
}
}

print_session_summary(engine.session_usage());
Expand Down
104 changes: 104 additions & 0 deletions src/tools/exit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use anyhow::Result;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};

use super::Tool;

/// A tool the agent can invoke to exit the REPL.
///
/// When the user says something like "bye" or "quit", the LLM can call this
/// tool to signal a graceful shutdown. The caller checks [`ExitTool::triggered`]
/// after each engine run to decide whether to break the loop.
///
/// Share via `Arc<ExitTool>` — the `AtomicBool` is stored inline; no inner
/// `Arc` is needed.
pub struct ExitTool {
flag: AtomicBool,
}
Comment thread
assapir marked this conversation as resolved.

impl ExitTool {
pub fn new() -> Self {
Self {
flag: AtomicBool::new(false),
}
}

/// Returns `true` if the tool was invoked (the agent wants to exit).
pub fn triggered(&self) -> bool {
self.flag.load(Ordering::Acquire)
}

/// Reset the flag (useful if the caller decides not to exit after all).
pub fn reset(&self) {
self.flag.store(false, Ordering::Release);
}
Comment thread
assapir marked this conversation as resolved.
}

impl Default for ExitTool {
fn default() -> Self {
Self::new()
}
}

#[async_trait]
impl Tool for ExitTool {
fn name(&self) -> &str {
"exit"
}

fn description(&self) -> &str {
"Exit the session. Call this when the user wants to quit, say goodbye, or end the conversation. No arguments required."
}

async fn execute(&self, _args: &HashMap<String, String>) -> Result<String> {
self.flag.store(true, Ordering::Release);
Ok("Goodbye! Exiting session.".to_string())
}
Comment thread
assapir marked this conversation as resolved.
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn not_triggered_initially() {
let tool = ExitTool::new();
assert!(!tool.triggered());
}

#[tokio::test]
async fn triggered_after_execute() {
let tool = ExitTool::new();
let result = tool.execute(&HashMap::new()).await;
assert!(result.is_ok());
assert!(tool.triggered());
}

#[tokio::test]
async fn reset_clears_flag() {
let tool = ExitTool::new();
tool.execute(&HashMap::new()).await.unwrap();
assert!(tool.triggered());
tool.reset();
assert!(!tool.triggered());
}

#[test]
fn name_is_exit() {
let tool = ExitTool::new();
assert_eq!(Tool::name(&tool), "exit");
}

#[test]
fn description_mentions_quit() {
let tool = ExitTool::new();
assert!(Tool::description(&tool).contains("quit"));
}

#[test]
fn default_impl_works() {
let tool = ExitTool::default();
assert!(!tool.triggered());
}
}
1 change: 1 addition & 0 deletions src/tools/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod exit;
pub mod shell;

use anyhow::Result;
Expand Down
41 changes: 40 additions & 1 deletion tests/tools_test.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use std::collections::HashMap;
use std::sync::Arc;

use golem::tools::exit::ExitTool;
use golem::tools::shell::{ShellConfig, ShellMode, ShellTool};
use golem::tools::{Outcome, ToolRegistry};
use golem::tools::{Outcome, Tool, ToolRegistry};

/// Helper: build a shell tool with no confirmation, read-write mode, cwd as work dir.
fn test_shell() -> ShellTool {
Expand Down Expand Up @@ -201,3 +202,41 @@ async fn registry_unregister_removes_tool() {

assert_eq!(registry.descriptions().await.len(), 0);
}

// --- ExitTool integration tests ---

#[tokio::test]
async fn exit_tool_executes_via_registry() {
let registry = ToolRegistry::new();
let exit_tool = Arc::new(ExitTool::new());
registry
.register(Arc::clone(&exit_tool) as Arc<dyn Tool>)
.await;

let result = registry.execute("exit", &HashMap::new()).await;
assert!(matches!(result.outcome, Outcome::Success(ref s) if s.contains("Goodbye")));
assert!(exit_tool.triggered());
}

#[tokio::test]
async fn exit_tool_coexists_with_shell() {
let registry = ToolRegistry::new();
registry.register(Arc::new(test_shell())).await;
let exit_tool = Arc::new(ExitTool::new());
registry
.register(Arc::clone(&exit_tool) as Arc<dyn Tool>)
.await;

assert_eq!(registry.descriptions().await.len(), 2);

// Shell still works
let args = HashMap::from([("command".to_string(), "echo hi".to_string())]);
let result = registry.execute("shell", &args).await;
assert!(matches!(result.outcome, Outcome::Success(_)));
assert!(!exit_tool.triggered());

// Exit works
let result = registry.execute("exit", &HashMap::new()).await;
assert!(matches!(result.outcome, Outcome::Success(_)));
assert!(exit_tool.triggered());
}