P1: tools + ReAct + sessions; default model grok-4.6

This commit is contained in:
王性驊 2026-09-13 15:42:59 +08:00
parent 94b8fa1dce
commit 447498df5b
13 changed files with 2546 additions and 36 deletions

1504
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -10,9 +10,11 @@ authors = ["Daniel Wang"]
[workspace.dependencies]
anyhow = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "std", "serde"] }
futures-util = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync"] }
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "fs"] }
uuid = { version = "1", features = ["v4", "serde"] }

View File

@ -1,13 +1,13 @@
# GrokBoy
Minimal local **GrokBot-like** CLI agent. Phase **P0**: streaming chat only.
Minimal local **GrokBot-like** CLI agent. Phase **P1**: tools + ReAct + sessions.
## Status
| Phase | Status |
|-------|--------|
| P0 streaming chat | done |
| P1 shell / files + ReAct | next |
| P1 shell / files + ReAct | done |
| P2 completion / loop guard | planned |
| P3 browser (Playwright) | later |
@ -19,7 +19,7 @@ No Docker desktop, no Codex/LazyBoy fork.
export GROKBOY_API_KEY=your_key # or XAI_API_KEY
# optional:
# export GROKBOY_BASE_URL=https://api.x.ai/v1
# export GROKBOY_MODEL=grok-4
# export GROKBOY_MODEL=grok-4.6
cd ~/GrokBoy
cargo run -p grokboy -- chat
@ -27,23 +27,30 @@ cargo run -p grokboy -- chat
## Commands
- `grokboy chat` — interactive streaming chat
- `grokboy chat` — interactive streaming chat (no tools)
- `grokboy run "<prompt>"` — one-shot agent with tools (`shell`, `list_dir`, `read_file`, `write_file`)
- `grokboy run --session <id> "<prompt>"` — continue a saved session
- `grokboy smoke` — offline tool checks (no API key required)
- `grokboy help`
Sessions are stored under `~/.grokboy/sessions/<id>.json`.
## 繁體中文
本機終端機聊天 bot。P0 只能對話P1 才會加讀寫檔與 shell
本機終端機 coding assistant。P1 已支援讀寫檔、列目錄、shell以及多輪 tool-calling
```bash
export GROKBOY_API_KEY=你的金鑰
cd ~/GrokBoy
cargo run -p grokboy -- smoke
cargo run -p grokboy -- run "列出目前目錄並讀 README.md"
cargo run -p grokboy -- chat
```
## Layout
```
crates/grokboy-core/ # config + streaming client
crates/grokboy-core/ # config, model, tools, agent, session
crates/grokboy/ # CLI binary
docs/ACCEPTANCE.md
```

View File

@ -3,13 +3,15 @@ name = "grokboy-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Minimal GrokBot-like agent core (chat loop)"
description = "Minimal GrokBot-like agent core (chat + tools + ReAct)"
[dependencies]
anyhow.workspace = true
chrono.workspace = true
futures-util.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
uuid.workspace = true

View File

@ -0,0 +1,68 @@
//! Multi-step OpenAI-compatible tool-calling ReAct loop.
use crate::config::Config;
use crate::model::{ChatMessage, chat_completion};
use crate::tools::{ToolContext, execute_tool, tool_definitions};
use anyhow::Result;
pub const DEFAULT_MAX_ROUNDS: usize = 12;
pub const AGENT_SYSTEM: &str = "\
You are GrokBoy, a concise local coding assistant with tools.
Use tools when they help solve the task; otherwise answer directly.
Prefer short, clear answers. Traditional Chinese is welcome when the user writes in Chinese.
Available tools: shell, list_dir, read_file, write_file.
Do not invent tool results call the tools. Stop when you can give a final answer.";
/// Run the agent loop until the model returns text without tool_calls or max rounds.
/// Appends all intermediate messages (assistant tool_calls + tool results + final) to `messages`.
/// Returns the final assistant text (may be empty if stopped on round limit with only tools).
pub async fn run_agent(
config: &Config,
messages: &mut Vec<ChatMessage>,
tool_ctx: &ToolContext,
max_rounds: usize,
) -> Result<String> {
let tools = tool_definitions();
let mut last_text = String::new();
for _round in 0..max_rounds {
let reply = chat_completion(config, messages, Some(&tools)).await?;
let tool_calls = reply.tool_calls.clone().unwrap_or_default();
if tool_calls.is_empty() {
last_text = reply.text().to_string();
messages.push(reply);
return Ok(last_text);
}
// Keep any content the model sent alongside tool_calls.
if let Some(c) = reply.content.as_ref().filter(|s| !s.is_empty()) {
last_text = c.clone();
}
messages.push(reply);
for call in &tool_calls {
let result = execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await;
messages.push(ChatMessage::tool(&call.id, result));
}
}
Ok(last_text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn system_prompt_mentions_tools() {
assert!(AGENT_SYSTEM.contains("shell"));
assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese"));
}
#[test]
fn default_max_rounds_is_12() {
assert_eq!(DEFAULT_MAX_ROUNDS, 12);
}
}

View File

@ -22,7 +22,7 @@ impl Config {
let base_url = env::var("GROKBOY_BASE_URL")
.or_else(|_| env::var("OPENAI_BASE_URL"))
.unwrap_or_else(|_| "https://api.x.ai/v1".into());
let model = env::var("GROKBOY_MODEL").unwrap_or_else(|_| "grok-4".into());
let model = env::var("GROKBOY_MODEL").unwrap_or_else(|_| "grok-4.6".into());
Ok(Self {
api_key,
base_url: base_url.trim_end_matches('/').to_string(),
@ -35,13 +35,22 @@ impl Config {
mod tests {
use super::*;
#[test]
fn default_model_is_grok_4_6() {
let cfg = Config {
api_key: "x".into(),
base_url: "https://api.x.ai/v1".into(),
model: "grok-4.6".into(),
};
assert_eq!(cfg.model, "grok-4.6");
}
#[test]
fn rejects_missing_key() {
// Ensure we don't panic; actual env may have keys in CI — just type-check path.
let _ = Config {
api_key: "x".into(),
base_url: "https://api.x.ai/v1".into(),
model: "grok-4".into(),
model: "grok-4.6".into(),
};
}
}

View File

@ -1,7 +1,13 @@
//! GrokBoy core: config + OpenAI-compatible streaming chat.
//! GrokBoy core: config, streaming chat, tools, ReAct agent, sessions.
mod agent;
mod config;
mod model;
mod session;
mod tools;
pub use agent::{AGENT_SYSTEM, DEFAULT_MAX_ROUNDS, run_agent};
pub use config::Config;
pub use model::{ChatMessage, Role, stream_chat};
pub use model::{ChatMessage, FunctionCall, Role, ToolCall, chat_completion, stream_chat};
pub use session::{Session, load_or_create, load_session, save_session, sessions_dir};
pub use tools::{ToolContext, execute_tool, tool_definitions};

View File

@ -2,7 +2,7 @@ use crate::config::Config;
use anyhow::{Context, Result, anyhow};
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@ -10,33 +10,91 @@ pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionCall {
pub name: String,
/// JSON-encoded argument object as a string (OpenAI-compatible).
pub arguments: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub kind: String,
pub function: FunctionCall,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: Role,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self {
Self {
role: Role::System,
content: content.into(),
content: Some(content.into()),
tool_calls: None,
tool_call_id: None,
name: None,
}
}
pub fn user(content: impl Into<String>) -> Self {
Self {
role: Role::User,
content: content.into(),
content: Some(content.into()),
tool_calls: None,
tool_call_id: None,
name: None,
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: Role::Assistant,
content: content.into(),
content: Some(content.into()),
tool_calls: None,
tool_call_id: None,
name: None,
}
}
pub fn assistant_tool_calls(content: Option<String>, tool_calls: Vec<ToolCall>) -> Self {
Self {
role: Role::Assistant,
content,
tool_calls: Some(tool_calls),
tool_call_id: None,
name: None,
}
}
pub fn tool(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: Role::Tool,
content: Some(content.into()),
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
name: None,
}
}
pub fn text(&self) -> &str {
self.content.as_deref().unwrap_or("")
}
}
#[derive(Debug, Deserialize)]
@ -60,8 +118,20 @@ struct ApiErrorBody {
message: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CompletionResponse {
choices: Option<Vec<CompletionChoice>>,
error: Option<ApiErrorBody>,
}
#[derive(Debug, Deserialize)]
struct CompletionChoice {
message: Option<ChatMessage>,
finish_reason: Option<String>,
}
/// Stream a chat completion; invoke `on_delta` for each text piece.
/// Returns the full assistant text.
/// Returns the full assistant text. (No tools — used by interactive `chat`.)
pub async fn stream_chat(
config: &Config,
messages: &[ChatMessage],
@ -141,6 +211,71 @@ pub async fn stream_chat(
Ok(full)
}
/// Non-streaming chat completion, optionally with tool definitions.
/// Returns the assistant message (may include `tool_calls`).
pub async fn chat_completion(
config: &Config,
messages: &[ChatMessage],
tools: Option<&Value>,
) -> Result<ChatMessage> {
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", config.base_url);
let mut body = json!({
"model": config.model,
"messages": messages,
"stream": false,
});
if let Some(tools) = tools {
body["tools"] = tools.clone();
body["tool_choice"] = json!("auto");
}
let response = client
.post(&url)
.bearer_auth(&config.api_key)
.header("content-type", "application/json")
.json(&body)
.send()
.await
.context("request to chat completions failed")?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
return Err(anyhow!("chat completions HTTP {status}: {text}"));
}
let parsed: CompletionResponse = response
.json()
.await
.context("parsing chat completions JSON")?;
if let Some(err) = parsed.error {
return Err(anyhow!(
"provider error: {}",
err.message.unwrap_or_else(|| "unknown".into())
));
}
let choice = parsed
.choices
.and_then(|mut c| c.pop())
.ok_or_else(|| anyhow!("no choices in completion response"))?;
let mut message = choice
.message
.ok_or_else(|| anyhow!("empty message in completion choice"))?;
// Normalize: ensure assistant role even if provider omits it.
if message.role != Role::Assistant && message.role != Role::Tool {
message.role = Role::Assistant;
}
let _ = choice.finish_reason;
Ok(message)
}
#[cfg(test)]
mod tests {
use super::*;
@ -151,5 +286,34 @@ mod tests {
let v = serde_json::to_value(&msg).unwrap();
assert_eq!(v["role"], "user");
assert_eq!(v["content"], "hi");
assert!(v.get("tool_calls").is_none());
}
#[test]
fn tool_message_serializes() {
let msg = ChatMessage::tool("call_1", r#"{"ok":true}"#);
let v = serde_json::to_value(&msg).unwrap();
assert_eq!(v["role"], "tool");
assert_eq!(v["tool_call_id"], "call_1");
assert_eq!(v["content"], r#"{"ok":true}"#);
}
#[test]
fn assistant_tool_calls_serialize() {
let msg = ChatMessage::assistant_tool_calls(
None,
vec![ToolCall {
id: "c1".into(),
kind: "function".into(),
function: FunctionCall {
name: "list_dir".into(),
arguments: r#"{"path":"."}"#.into(),
},
}],
);
let v = serde_json::to_value(&msg).unwrap();
assert_eq!(v["role"], "assistant");
assert_eq!(v["tool_calls"][0]["function"]["name"], "list_dir");
assert!(v.get("content").is_none() || v["content"].is_null());
}
}

View File

@ -0,0 +1,138 @@
//! Persist agent sessions under ~/.grokboy/sessions/.
use crate::model::ChatMessage;
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub cwd: PathBuf,
pub messages: Vec<ChatMessage>,
}
impl Session {
pub fn new(cwd: impl Into<PathBuf>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
created_at: now,
updated_at: now,
cwd: cwd.into(),
messages: Vec::new(),
}
}
pub fn touch(&mut self) {
self.updated_at = Utc::now();
}
pub fn push(&mut self, msg: ChatMessage) {
self.messages.push(msg);
self.touch();
}
}
pub fn sessions_dir() -> Result<PathBuf> {
let home = dirs_home().ok_or_else(|| anyhow!("cannot resolve home directory"))?;
Ok(home.join(".grokboy").join("sessions"))
}
fn dirs_home() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
pub fn ensure_sessions_dir() -> Result<PathBuf> {
let dir = sessions_dir()?;
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
Ok(dir)
}
pub fn session_path(id: &str) -> Result<PathBuf> {
validate_session_id(id)?;
Ok(ensure_sessions_dir()?.join(format!("{id}.json")))
}
fn validate_session_id(id: &str) -> Result<()> {
if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(anyhow!("invalid session id"));
}
Ok(())
}
pub fn save_session(session: &Session) -> Result<PathBuf> {
let path = session_path(&session.id)?;
save_session_to(session, &path)?;
Ok(path)
}
pub fn save_session_to(session: &Session, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let tmp = path.with_extension("json.tmp");
let data = serde_json::to_vec_pretty(session).context("serialize session")?;
fs::write(&tmp, &data).with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| format!("rename to {}", path.display()))?;
Ok(())
}
pub fn load_session(id: &str) -> Result<Session> {
let path = session_path(id)?;
load_session_from(&path)
}
pub fn load_session_from(path: &Path) -> Result<Session> {
let data = fs::read(path).with_context(|| format!("read session {}", path.display()))?;
let session: Session = serde_json::from_slice(&data).context("parse session JSON")?;
Ok(session)
}
pub fn load_or_create(session_id: Option<&str>, cwd: &Path) -> Result<Session> {
match session_id {
Some(id) => load_session(id),
None => Ok(Session::new(cwd)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::ChatMessage;
#[test]
fn save_and_load_roundtrip() {
let stamp = Uuid::new_v4();
let dir = std::env::temp_dir().join(format!("grokboy-sess-{stamp}"));
fs::create_dir_all(&dir).unwrap();
let path = dir.join("sess.json");
let mut s = Session::new(dir.join("proj"));
s.push(ChatMessage::system("sys"));
s.push(ChatMessage::user("hi"));
save_session_to(&s, &path).unwrap();
assert!(path.exists());
let loaded = load_session_from(&path).unwrap();
assert_eq!(loaded.id, s.id);
assert_eq!(loaded.messages.len(), 2);
assert_eq!(loaded.messages[1].text(), "hi");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rejects_bad_session_id() {
assert!(validate_session_id("../x").is_err());
assert!(validate_session_id("a/b").is_err());
assert!(validate_session_id("ok-id").is_ok());
}
}

View File

@ -0,0 +1,406 @@
//! Built-in tools: shell, list_dir, read_file, write_file.
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
use std::path::{Component, Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
pub const MAX_READ_BYTES: usize = 256 * 1024;
pub const SHELL_TIMEOUT_SECS: u64 = 30;
/// Runtime context for tool execution.
#[derive(Debug, Clone)]
pub struct ToolContext {
/// Default working directory for relative paths / shell.
pub cwd: PathBuf,
/// Optional workspace root; paths outside it are rejected when set.
pub workspace_root: Option<PathBuf>,
}
impl ToolContext {
pub fn new(cwd: impl Into<PathBuf>) -> Self {
let cwd = cwd.into();
Self {
cwd: cwd.clone(),
workspace_root: Some(cwd),
}
}
pub fn with_workspace(mut self, root: Option<PathBuf>) -> Self {
self.workspace_root = root;
self
}
}
/// OpenAI-compatible tool definitions for chat completions.
pub fn tool_definitions() -> Value {
json!([
{
"type": "function",
"function": {
"name": "shell",
"description": "Run a shell command. Captures stdout, stderr, and exit code. Timeout 30s. cwd defaults to the session working directory.",
"parameters": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command to run" },
"cwd": { "type": "string", "description": "Optional working directory" }
},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "List entries in a directory (names only, sorted).",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory path (relative to cwd or absolute)" }
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a text file (max 256KB). Returns contents as UTF-8 (lossy).",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path" }
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write text to a file, creating parent directories as needed.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path" },
"content": { "type": "string", "description": "File contents" }
},
"required": ["path", "content"]
}
}
}
])
}
/// Resolve a user-supplied path against cwd and optionally enforce workspace_root.
pub fn resolve_path(ctx: &ToolContext, path: &str) -> Result<PathBuf> {
let raw = Path::new(path);
let joined = if raw.is_absolute() {
raw.to_path_buf()
} else {
ctx.cwd.join(raw)
};
// Normalize without requiring the path to exist (for write_file parents).
let resolved = normalize_path(&joined);
if let Some(root) = &ctx.workspace_root {
let root_norm = normalize_path(root);
if !resolved.starts_with(&root_norm) {
return Err(anyhow!(
"path {:?} is outside workspace root {:?}",
resolved,
root_norm
));
}
}
Ok(resolved)
}
fn normalize_path(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::Prefix(p) => out.push(p.as_os_str()),
Component::RootDir => out.push(Component::RootDir.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
Component::Normal(c) => out.push(c),
}
}
out
}
pub async fn execute_tool(ctx: &ToolContext, name: &str, arguments_json: &str) -> String {
match execute_tool_inner(ctx, name, arguments_json).await {
Ok(v) => v.to_string(),
Err(e) => json!({ "error": format!("{e:#}") }).to_string(),
}
}
async fn execute_tool_inner(ctx: &ToolContext, name: &str, arguments_json: &str) -> Result<Value> {
let args: Value = serde_json::from_str(arguments_json)
.with_context(|| format!("invalid tool arguments JSON for {name}"))?;
match name {
"shell" => tool_shell(ctx, &args).await,
"list_dir" => tool_list_dir(ctx, &args).await,
"read_file" => tool_read_file(ctx, &args).await,
"write_file" => tool_write_file(ctx, &args).await,
other => Err(anyhow!("unknown tool: {other}")),
}
}
async fn tool_shell(ctx: &ToolContext, args: &Value) -> Result<Value> {
let command = args
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("shell: missing 'command'"))?;
let cwd = if let Some(c) = args.get("cwd").and_then(|v| v.as_str()) {
resolve_path(ctx, c)?
} else {
ctx.cwd.clone()
};
if !cwd.is_dir() {
return Err(anyhow!("shell cwd is not a directory: {}", cwd.display()));
}
let child = Command::new("sh")
.arg("-c")
.arg(command)
.current_dir(&cwd)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.with_context(|| format!("failed to spawn shell for: {command}"))?;
let timeout = Duration::from_secs(SHELL_TIMEOUT_SECS);
let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(out)) => out,
Ok(Err(e)) => return Err(anyhow!("shell wait failed: {e}")),
Err(_) => {
return Ok(json!({
"error": format!("command timed out after {SHELL_TIMEOUT_SECS}s"),
"timed_out": true,
"command": command,
}));
}
};
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
Ok(json!({
"command": command,
"cwd": cwd.display().to_string(),
"exit_code": exit_code,
"stdout": truncate_output(&stdout, 64 * 1024),
"stderr": truncate_output(&stderr, 32 * 1024),
}))
}
async fn tool_list_dir(ctx: &ToolContext, args: &Value) -> Result<Value> {
let path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("list_dir: missing 'path'"))?;
let dir = resolve_path(ctx, path)?;
let mut rd = tokio::fs::read_dir(&dir)
.await
.with_context(|| format!("list_dir: {}", dir.display()))?;
let mut entries = Vec::new();
while let Some(ent) = rd.next_entry().await? {
let name = ent.file_name().to_string_lossy().to_string();
let file_type = ent.file_type().await?;
let kind = if file_type.is_dir() {
"dir"
} else if file_type.is_symlink() {
"symlink"
} else {
"file"
};
entries.push(json!({ "name": name, "kind": kind }));
}
entries.sort_by(|a, b| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(b["name"].as_str().unwrap_or(""))
});
Ok(json!({
"path": dir.display().to_string(),
"entries": entries,
}))
}
async fn tool_read_file(ctx: &ToolContext, args: &Value) -> Result<Value> {
let path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("read_file: missing 'path'"))?;
let file = resolve_path(ctx, path)?;
let meta = tokio::fs::metadata(&file)
.await
.with_context(|| format!("read_file: {}", file.display()))?;
if !meta.is_file() {
return Err(anyhow!("read_file: not a file: {}", file.display()));
}
if meta.len() as usize > MAX_READ_BYTES {
return Err(anyhow!(
"read_file: file too large ({} bytes > {} cap)",
meta.len(),
MAX_READ_BYTES
));
}
let bytes = tokio::fs::read(&file)
.await
.with_context(|| format!("read_file: {}", file.display()))?;
let content = String::from_utf8_lossy(&bytes).to_string();
Ok(json!({
"path": file.display().to_string(),
"bytes": bytes.len(),
"content": content,
}))
}
async fn tool_write_file(ctx: &ToolContext, args: &Value) -> Result<Value> {
let path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("write_file: missing 'path'"))?;
let content = args
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("write_file: missing 'content'"))?;
let file = resolve_path(ctx, path)?;
if let Some(parent) = file.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("write_file create_dir_all: {}", parent.display()))?;
}
tokio::fs::write(&file, content.as_bytes())
.await
.with_context(|| format!("write_file: {}", file.display()))?;
Ok(json!({
"path": file.display().to_string(),
"bytes_written": content.len(),
}))
}
fn truncate_output(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!(
"{}…\n[truncated {} bytes]",
&s[..max],
s.len().saturating_sub(max)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_ctx() -> (ToolContext, PathBuf) {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("grokboy-tools-{stamp}"));
std::fs::create_dir_all(&dir).unwrap();
(ToolContext::new(dir.clone()), dir)
}
#[tokio::test]
async fn write_read_list_shell() {
let (ctx, dir) = temp_ctx();
let w = execute_tool(
&ctx,
"write_file",
&json!({"path": "hello.txt", "content": "你好 GrokBoy"}).to_string(),
)
.await;
let w: Value = serde_json::from_str(&w).unwrap();
assert!(w.get("error").is_none(), "{w}");
assert_eq!(w["bytes_written"], "你好 GrokBoy".len());
let r = execute_tool(
&ctx,
"read_file",
&json!({"path": "hello.txt"}).to_string(),
)
.await;
let r: Value = serde_json::from_str(&r).unwrap();
assert_eq!(r["content"], "你好 GrokBoy");
let l = execute_tool(&ctx, "list_dir", &json!({"path": "."}).to_string()).await;
let l: Value = serde_json::from_str(&l).unwrap();
let names: Vec<&str> = l["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"hello.txt"));
let s = execute_tool(
&ctx,
"shell",
&json!({"command": "echo hi && ls hello.txt"}).to_string(),
)
.await;
let s: Value = serde_json::from_str(&s).unwrap();
assert_eq!(s["exit_code"], 0);
assert!(s["stdout"].as_str().unwrap().contains("hi"));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn rejects_path_traversal() {
let (ctx, dir) = temp_ctx();
let out = execute_tool(
&ctx,
"read_file",
&json!({"path": "../outside.txt"}).to_string(),
)
.await;
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v.get("error").is_some(), "{v}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tool_defs_include_four() {
let defs = tool_definitions();
let arr = defs.as_array().unwrap();
assert_eq!(arr.len(), 4);
let names: Vec<&str> = arr
.iter()
.map(|t| t["function"]["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"shell"));
assert!(names.contains(&"list_dir"));
assert!(names.contains(&"read_file"));
assert!(names.contains(&"write_file"));
}
}

View File

@ -3,7 +3,7 @@ name = "grokboy"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "GrokBoy CLI — local chat agent"
description = "GrokBoy CLI — local chat + tool agent"
[[bin]]
name = "grokboy"
@ -12,4 +12,5 @@ path = "src/main.rs"
[dependencies]
anyhow.workspace = true
grokboy-core = { path = "../grokboy-core" }
serde_json.workspace = true
tokio.workspace = true

View File

@ -1,9 +1,13 @@
use anyhow::{Context, Result};
use grokboy_core::{ChatMessage, Config, stream_chat};
use anyhow::{Context, Result, anyhow};
use grokboy_core::{
AGENT_SYSTEM, ChatMessage, Config, DEFAULT_MAX_ROUNDS, Session, ToolContext, execute_tool,
load_or_create, run_agent, save_session, stream_chat, tool_definitions,
};
use serde_json::json;
use std::io::{self, Write};
use std::process::ExitCode;
const SYSTEM: &str = "You are GrokBoy, a concise local coding assistant. Prefer clear, short answers. Traditional Chinese is welcome when the user writes in Chinese.";
const CHAT_SYSTEM: &str = "You are GrokBoy, a concise local coding assistant. Prefer clear, short answers. Traditional Chinese is welcome when the user writes in Chinese.";
#[tokio::main]
async fn main() -> ExitCode {
@ -15,37 +19,51 @@ async fn main() -> ExitCode {
}
async fn run() -> Result<()> {
let mut args = std::env::args().skip(1);
let cmd = args.next().unwrap_or_else(|| "help".into());
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
if args.is_empty() {
print_help();
return Ok(());
}
let cmd = args.remove(0);
match cmd.as_str() {
"chat" => cmd_chat().await,
"run" => cmd_run(&args).await,
"smoke" => cmd_smoke().await,
"version" | "-V" | "--version" => {
println!("grokboy {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
"help" | "-h" | "--help" | _ => {
"help" | "-h" | "--help" => {
print_help();
Ok(())
}
other => {
eprintln!("unknown command: {other}\n");
print_help();
Err(anyhow!("unknown command"))
}
}
}
fn print_help() {
println!(
"\
GrokBoy minimal local CLI agent (P0: chat)
GrokBoy minimal local CLI agent (P1: tools + ReAct)
USAGE:
grokboy chat Interactive streaming chat
grokboy chat Interactive streaming chat (no tools)
grokboy run \"<prompt>\" One-shot agent with tools
grokboy run --session <id> \"...\" Continue a saved session
grokboy smoke Offline tool checks (no API key required)
grokboy version
grokboy help
ENV:
GROKBOY_API_KEY API key (or XAI_API_KEY / OPENAI_API_KEY)
GROKBOY_BASE_URL default https://api.x.ai/v1
GROKBOY_MODEL default grok-4
GROKBOY_MODEL default grok-4.6
P0 has no tools yet. P1 will add shell/files + ReAct.
Sessions are stored under ~/.grokboy/sessions/<id>.json
"
);
}
@ -58,7 +76,7 @@ async fn cmd_chat() -> Result<()> {
);
println!("Type a message. Empty line or /exit to quit.\n");
let mut history = vec![ChatMessage::system(SYSTEM)];
let mut history = vec![ChatMessage::system(CHAT_SYSTEM)];
let stdin = io::stdin();
let mut stdout = io::stdout();
@ -88,3 +106,187 @@ async fn cmd_chat() -> Result<()> {
}
Ok(())
}
async fn cmd_run(args: &[String]) -> Result<()> {
let mut session_id: Option<String> = None;
let mut prompt_parts: Vec<String> = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--session" | "-s" => {
i += 1;
let id = args
.get(i)
.ok_or_else(|| anyhow!("--session requires an id"))?;
session_id = Some(id.clone());
}
"--help" | "-h" => {
println!(
"Usage: grokboy run [--session <id>] \"<prompt>\"\n\
Runs a one-shot tool-using agent. Creates a session under ~/.grokboy/sessions/."
);
return Ok(());
}
other => prompt_parts.push(other.to_string()),
}
i += 1;
}
let prompt = prompt_parts.join(" ");
if prompt.trim().is_empty() {
return Err(anyhow!("usage: grokboy run [--session <id>] \"<prompt>\""));
}
let config = Config::from_env().map_err(anyhow::Error::msg)?;
let cwd = std::env::current_dir().context("cwd")?;
let mut session = load_or_create(session_id.as_deref(), &cwd)?;
// Ensure system prompt is present once at the start.
if session.messages.is_empty() {
session.push(ChatMessage::system(AGENT_SYSTEM));
}
session.cwd = cwd.clone();
session.push(ChatMessage::user(&prompt));
let tool_ctx = ToolContext::new(session.cwd.clone());
let answer = run_agent(
&config,
&mut session.messages,
&tool_ctx,
DEFAULT_MAX_ROUNDS,
)
.await?;
session.touch();
let path = save_session(&session)?;
println!("{answer}");
eprintln!(
"\n[session {} saved → {}]",
session.id,
path.display()
);
Ok(())
}
async fn cmd_smoke() -> Result<()> {
println!("GrokBoy smoke (offline tools)…");
let stamp = uuid_like();
let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}"));
std::fs::create_dir_all(&dir).context("temp dir")?;
let ctx = ToolContext::new(dir.clone());
// write_file
let w = execute_tool(
&ctx,
"write_file",
&json!({"path": "note.txt", "content": "smoke ok\n第二行"}).to_string(),
)
.await;
let w: serde_json::Value = serde_json::from_str(&w)?;
assert_ok(&w, "write_file")?;
println!(" write_file ok");
// read_file
let r = execute_tool(
&ctx,
"read_file",
&json!({"path": "note.txt"}).to_string(),
)
.await;
let r: serde_json::Value = serde_json::from_str(&r)?;
assert_ok(&r, "read_file")?;
if r["content"].as_str() != Some("smoke ok\n第二行") {
return Err(anyhow!("read_file content mismatch: {r}"));
}
println!(" read_file ok");
// list_dir
let l = execute_tool(&ctx, "list_dir", &json!({"path": "."}).to_string()).await;
let l: serde_json::Value = serde_json::from_str(&l)?;
assert_ok(&l, "list_dir")?;
let names: Vec<&str> = l["entries"]
.as_array()
.context("entries")?
.iter()
.filter_map(|e| e["name"].as_str())
.collect();
if !names.contains(&"note.txt") {
return Err(anyhow!("list_dir missing note.txt: {l}"));
}
println!(" list_dir ok");
// shell
let s = execute_tool(
&ctx,
"shell",
&json!({"command": "printf 'hi'; wc -c < note.txt"}).to_string(),
)
.await;
let s: serde_json::Value = serde_json::from_str(&s)?;
assert_ok(&s, "shell")?;
if s["exit_code"] != 0 {
return Err(anyhow!("shell exit_code != 0: {s}"));
}
println!(" shell ok");
// path traversal should fail
let bad = execute_tool(
&ctx,
"read_file",
&json!({"path": "../outside.txt"}).to_string(),
)
.await;
let bad: serde_json::Value = serde_json::from_str(&bad)?;
if bad.get("error").is_none() {
return Err(anyhow!("expected path traversal error, got {bad}"));
}
println!(" sandbox ok");
// tool definitions present
let defs = tool_definitions();
if defs.as_array().map(|a| a.len()).unwrap_or(0) != 4 {
return Err(anyhow!("expected 4 tool defs"));
}
println!(" tool defs ok");
// session roundtrip in temp (does not require ~/.grokboy for this check —
// we still exercise Session serialize via save into temp using core types)
let mut sess = Session::new(dir.clone());
sess.push(ChatMessage::system(AGENT_SYSTEM));
sess.push(ChatMessage::user("smoke"));
let sess_path = dir.join("session.json");
let data = serde_json::to_vec_pretty(&sess)?;
std::fs::write(&sess_path, data)?;
let loaded: Session = serde_json::from_slice(&std::fs::read(&sess_path)?)?;
if loaded.messages.len() != 2 {
return Err(anyhow!("session roundtrip failed"));
}
println!(" session ok");
let _ = std::fs::remove_dir_all(&dir);
// Optional live ping if API key is present (does not fail smoke).
if Config::from_env().is_ok() {
println!(" (API key present — skipping live call in smoke; use `run` to exercise)");
} else {
println!(" (no API key — live agent not checked; offline smoke passed)");
}
println!("smoke passed");
Ok(())
}
fn assert_ok(v: &serde_json::Value, label: &str) -> Result<()> {
if let Some(err) = v.get("error") {
return Err(anyhow!("{label} failed: {err}"));
}
Ok(())
}
fn uuid_like() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let n = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{n}")
}

View File

@ -7,11 +7,12 @@
- [x] `grokboy chat` streams assistant tokens
- [x] `cargo test` passes without API key
## P1 — tools + ReAct (next)
- [ ] `shell`, `list_dir`, `read_file`, `write_file`
- [ ] Multi-step tool loop
- [ ] Sessions under `~/.grokboy/sessions/`
- [ ] `grokboy run` / `grokboy smoke`
## P1 — tools + ReAct
- [x] `shell`, `list_dir`, `read_file`, `write_file`
- [x] Multi-step tool loop
- [x] Sessions under `~/.grokboy/sessions/`
- [x] `grokboy run` / `grokboy smoke`
- [x] Default model `grok-4.6`
## P2 — completion contract
- [ ] `report_done` / blocked