P1: tools + ReAct + sessions; default model grok-4.6
This commit is contained in:
parent
94b8fa1dce
commit
447498df5b
File diff suppressed because it is too large
Load Diff
|
|
@ -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"] }
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue