P2: completion contract, loop guard, context truncation
This commit is contained in:
parent
447498df5b
commit
815b840f30
15
README.md
15
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# GrokBoy
|
# GrokBoy
|
||||||
|
|
||||||
Minimal local **GrokBot-like** CLI agent. Phase **P1**: tools + ReAct + sessions.
|
Minimal local **GrokBot-like** CLI agent. Phase **P2**: completion contract, loop guard, context truncation.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P1**: tools + ReAct + sessions
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
| P0 streaming chat | done |
|
| P0 streaming chat | done |
|
||||||
| P1 shell / files + ReAct | done |
|
| P1 shell / files + ReAct | done |
|
||||||
| P2 completion / loop guard | planned |
|
| P2 completion / loop guard / truncation | done |
|
||||||
| P3 browser (Playwright) | later |
|
| P3 browser (Playwright) | later |
|
||||||
|
|
||||||
No Docker desktop, no Codex/LazyBoy fork.
|
No Docker desktop, no Codex/LazyBoy fork.
|
||||||
|
|
@ -20,6 +20,7 @@ export GROKBOY_API_KEY=your_key # or XAI_API_KEY
|
||||||
# optional:
|
# optional:
|
||||||
# export GROKBOY_BASE_URL=https://api.x.ai/v1
|
# export GROKBOY_BASE_URL=https://api.x.ai/v1
|
||||||
# export GROKBOY_MODEL=grok-4.6
|
# export GROKBOY_MODEL=grok-4.6
|
||||||
|
# export GROKBOY_CONTEXT_CHARS=100000
|
||||||
|
|
||||||
cd ~/GrokBoy
|
cd ~/GrokBoy
|
||||||
cargo run -p grokboy -- chat
|
cargo run -p grokboy -- chat
|
||||||
|
|
@ -28,16 +29,20 @@ cargo run -p grokboy -- chat
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `grokboy chat` — interactive streaming chat (no tools)
|
- `grokboy chat` — interactive streaming chat (no tools)
|
||||||
- `grokboy run "<prompt>"` — one-shot agent with tools (`shell`, `list_dir`, `read_file`, `write_file`)
|
- `grokboy run "<prompt>"` — one-shot agent with tools
|
||||||
- `grokboy run --session <id> "<prompt>"` — continue a saved session
|
- `grokboy run --session <id> "<prompt>"` — continue a saved session
|
||||||
- `grokboy smoke` — offline tool checks (no API key required)
|
- `grokboy smoke` — offline checks (no API key required)
|
||||||
- `grokboy help`
|
- `grokboy help`
|
||||||
|
|
||||||
|
Tools: `shell`, `list_dir`, `read_file`, `write_file`, `report_done`, `report_blocked`.
|
||||||
|
|
||||||
Sessions are stored under `~/.grokboy/sessions/<id>.json`.
|
Sessions are stored under `~/.grokboy/sessions/<id>.json`.
|
||||||
|
|
||||||
|
The agent stops on `report_done` / `report_blocked`, blocks identical tool rounds (×3), and truncates old context when over budget.
|
||||||
|
|
||||||
## 繁體中文
|
## 繁體中文
|
||||||
|
|
||||||
本機終端機 coding assistant。P1 已支援讀寫檔、列目錄、shell,以及多輪 tool-calling。
|
本機終端機 coding assistant。P2 已支援完成合約(`report_done` / `report_blocked`)、迴圈守衛與上下文截斷。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export GROKBOY_API_KEY=你的金鑰
|
export GROKBOY_API_KEY=你的金鑰
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,317 @@
|
||||||
//! Multi-step OpenAI-compatible tool-calling ReAct loop.
|
//! Multi-step OpenAI-compatible tool-calling ReAct loop (P2: completion, loop guard, truncation).
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::model::{ChatMessage, chat_completion};
|
use crate::model::{ChatMessage, Role, ToolCall, chat_completion};
|
||||||
use crate::tools::{ToolContext, execute_tool, tool_definitions};
|
use crate::tools::{ToolContext, execute_tool, is_completion_tool, tool_definitions};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
pub const DEFAULT_MAX_ROUNDS: usize = 12;
|
pub const DEFAULT_MAX_ROUNDS: usize = 12;
|
||||||
|
pub const DEFAULT_CONTEXT_CHARS: usize = 100_000;
|
||||||
|
pub const LOOP_GUARD_REPEAT: usize = 3;
|
||||||
|
|
||||||
pub const AGENT_SYSTEM: &str = "\
|
pub const AGENT_SYSTEM: &str = "\
|
||||||
You are GrokBoy, a concise local coding assistant with tools.
|
You are GrokBoy, a concise local coding assistant with tools.
|
||||||
Use tools when they help solve the task; otherwise answer directly.
|
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.
|
Prefer short, clear answers. Traditional Chinese is welcome when the user writes in Chinese.
|
||||||
Available tools: shell, list_dir, read_file, write_file.
|
Available tools: shell, list_dir, read_file, write_file, report_done, report_blocked.
|
||||||
Do not invent tool results — call the tools. Stop when you can give a final answer.";
|
When the task is finished, call report_done with a short summary.
|
||||||
|
If you are stuck or cannot proceed, call report_blocked with the reason — do not invent results or loop.
|
||||||
|
Do not invent tool results — call the tools.";
|
||||||
|
|
||||||
/// Run the agent loop until the model returns text without tool_calls or max rounds.
|
/// Final verdict from the agent loop (fail-closed when stuck).
|
||||||
/// Appends all intermediate messages (assistant tool_calls + tool results + final) to `messages`.
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// Returns the final assistant text (may be empty if stopped on round limit with only tools).
|
pub enum AgentVerdict {
|
||||||
|
/// Model called `report_done`.
|
||||||
|
Done(String),
|
||||||
|
/// Model called `report_blocked`, loop guard, or max rounds.
|
||||||
|
Blocked(String),
|
||||||
|
/// Model returned final text without a completion tool.
|
||||||
|
Answer(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentVerdict {
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Done(s) | Self::Blocked(s) | Self::Answer(s) => s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Done(_) => "done",
|
||||||
|
Self::Blocked(_) => "blocked",
|
||||||
|
Self::Answer(_) => "answer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve context budget from `GROKBOY_CONTEXT_CHARS` or default.
|
||||||
|
pub fn context_char_budget() -> usize {
|
||||||
|
std::env::var("GROKBOY_CONTEXT_CHARS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.filter(|&n| n > 0)
|
||||||
|
.unwrap_or(DEFAULT_CONTEXT_CHARS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable signature for a single tool call (name + args).
|
||||||
|
pub fn tool_call_signature(call: &ToolCall) -> String {
|
||||||
|
format!("{}:{}", call.function.name, call.function.arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signature for a whole round of tool calls (order-preserving).
|
||||||
|
pub fn round_signature(calls: &[ToolCall]) -> String {
|
||||||
|
calls
|
||||||
|
.iter()
|
||||||
|
.map(tool_call_signature)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Approximate serialized size of one message (chars).
|
||||||
|
pub fn message_char_len(msg: &ChatMessage) -> usize {
|
||||||
|
let mut n = 8; // role overhead
|
||||||
|
if let Some(c) = &msg.content {
|
||||||
|
n += c.len();
|
||||||
|
}
|
||||||
|
if let Some(id) = &msg.tool_call_id {
|
||||||
|
n += id.len();
|
||||||
|
}
|
||||||
|
if let Some(calls) = &msg.tool_calls {
|
||||||
|
for c in calls {
|
||||||
|
n += c.id.len() + c.function.name.len() + c.function.arguments.len() + 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn messages_char_len(messages: &[ChatMessage]) -> usize {
|
||||||
|
messages.iter().map(message_char_len).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Truncate older tool results / middle turns while keeping system + recent tail + last user goal.
|
||||||
|
/// Mutates in place. Keeps it simple: shrink old tool contents first, then drop middle messages.
|
||||||
|
pub fn truncate_messages(messages: &mut Vec<ChatMessage>, budget: usize) {
|
||||||
|
if budget == 0 || messages_char_len(messages) <= budget {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Shrink older tool message contents (keep last few tool results intact).
|
||||||
|
let tool_indices: Vec<usize> = messages
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, m)| m.role == Role::Tool)
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
let keep_recent_tools = 4usize;
|
||||||
|
let shrink_until = tool_indices.len().saturating_sub(keep_recent_tools);
|
||||||
|
for &idx in tool_indices.iter().take(shrink_until) {
|
||||||
|
if let Some(content) = messages[idx].content.as_mut() {
|
||||||
|
if content.len() > 120 {
|
||||||
|
let omitted = content.len();
|
||||||
|
*content = format!("[truncated tool result; was {omitted} chars]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if messages_char_len(messages) <= budget {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Drop middle messages while preserving: leading system*, last user, and a recent tail.
|
||||||
|
while messages_char_len(messages) > budget && messages.len() > 4 {
|
||||||
|
let first_drop = messages
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(i, m)| *i > 0 && m.role != Role::System)
|
||||||
|
.map(|(i, _)| i);
|
||||||
|
let Some(i) = first_drop else { break };
|
||||||
|
|
||||||
|
// Never drop the last user message or the last two messages.
|
||||||
|
let last_user = messages
|
||||||
|
.iter()
|
||||||
|
.rposition(|m| m.role == Role::User)
|
||||||
|
.unwrap_or(messages.len());
|
||||||
|
if i >= last_user || i + 2 >= messages.len() {
|
||||||
|
// Shrink remaining large contents instead.
|
||||||
|
for msg in messages.iter_mut() {
|
||||||
|
if let Some(content) = msg.content.as_mut() {
|
||||||
|
if content.len() > 200 {
|
||||||
|
let keep = 200.min(content.len());
|
||||||
|
let omitted = content.len().saturating_sub(keep);
|
||||||
|
*content = format!("{}…\n[truncated {omitted} chars]", &content[..keep]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dropping an assistant with tool_calls, also drop following tool messages for those ids.
|
||||||
|
let drop_ids: Vec<String> = messages
|
||||||
|
.get(i)
|
||||||
|
.and_then(|m| m.tool_calls.as_ref())
|
||||||
|
.map(|calls| calls.iter().map(|c| c.id.clone()).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
messages.remove(i);
|
||||||
|
let j = i;
|
||||||
|
while j < messages.len() {
|
||||||
|
let is_orphan_tool = messages[j].role == Role::Tool
|
||||||
|
&& messages[j]
|
||||||
|
.tool_call_id
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|id| drop_ids.contains(id));
|
||||||
|
if is_orphan_tool {
|
||||||
|
messages.remove(j);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the agent with a live HTTP model client.
|
||||||
pub async fn run_agent(
|
pub async fn run_agent(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
messages: &mut Vec<ChatMessage>,
|
messages: &mut Vec<ChatMessage>,
|
||||||
tool_ctx: &ToolContext,
|
tool_ctx: &ToolContext,
|
||||||
max_rounds: usize,
|
max_rounds: usize,
|
||||||
) -> Result<String> {
|
) -> Result<AgentVerdict> {
|
||||||
let tools = tool_definitions();
|
let budget = context_char_budget();
|
||||||
let mut last_text = String::new();
|
let config = config.clone();
|
||||||
|
run_agent_with(messages, tool_ctx, max_rounds, budget, move |msgs, tools| {
|
||||||
|
let config = config.clone();
|
||||||
|
async move { chat_completion(&config, &msgs, Some(&tools)).await }
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
for _round in 0..max_rounds {
|
/// Core loop with injectable completer (for offline tests).
|
||||||
let reply = chat_completion(config, messages, Some(&tools)).await?;
|
/// `complete` receives a snapshot of messages and tool defs each round.
|
||||||
|
pub async fn run_agent_with<F, Fut>(
|
||||||
|
messages: &mut Vec<ChatMessage>,
|
||||||
|
tool_ctx: &ToolContext,
|
||||||
|
max_rounds: usize,
|
||||||
|
context_budget: usize,
|
||||||
|
mut complete: F,
|
||||||
|
) -> Result<AgentVerdict>
|
||||||
|
where
|
||||||
|
F: FnMut(Vec<ChatMessage>, Value) -> Fut,
|
||||||
|
Fut: Future<Output = Result<ChatMessage>>,
|
||||||
|
{
|
||||||
|
let tools = tool_definitions();
|
||||||
|
let mut prev_round_sig: Option<String> = None;
|
||||||
|
let mut same_sig_streak: usize = 0;
|
||||||
|
|
||||||
|
for round in 0..max_rounds {
|
||||||
|
truncate_messages(messages, context_budget);
|
||||||
|
|
||||||
|
let reply = complete(messages.clone(), tools.clone()).await?;
|
||||||
let tool_calls = reply.tool_calls.clone().unwrap_or_default();
|
let tool_calls = reply.tool_calls.clone().unwrap_or_default();
|
||||||
|
|
||||||
if tool_calls.is_empty() {
|
if tool_calls.is_empty() {
|
||||||
last_text = reply.text().to_string();
|
let last_text = reply.text().to_string();
|
||||||
messages.push(reply);
|
messages.push(reply);
|
||||||
return Ok(last_text);
|
if last_text.trim().is_empty() {
|
||||||
|
return Ok(AgentVerdict::Blocked(
|
||||||
|
"model returned empty final answer".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(AgentVerdict::Answer(last_text));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep any content the model sent alongside tool_calls.
|
// Loop guard: identical tool-call round repeated N times → fail closed.
|
||||||
if let Some(c) = reply.content.as_ref().filter(|s| !s.is_empty()) {
|
let sig = round_signature(&tool_calls);
|
||||||
last_text = c.clone();
|
if prev_round_sig.as_deref() == Some(sig.as_str()) {
|
||||||
|
same_sig_streak += 1;
|
||||||
|
} else {
|
||||||
|
same_sig_streak = 1;
|
||||||
|
prev_round_sig = Some(sig);
|
||||||
}
|
}
|
||||||
|
if same_sig_streak >= LOOP_GUARD_REPEAT {
|
||||||
|
messages.push(reply);
|
||||||
|
let reason = format!(
|
||||||
|
"loop guard: identical tool calls repeated {LOOP_GUARD_REPEAT} times (round {})",
|
||||||
|
round + 1
|
||||||
|
);
|
||||||
|
return Ok(AgentVerdict::Blocked(reason));
|
||||||
|
}
|
||||||
|
|
||||||
messages.push(reply);
|
messages.push(reply);
|
||||||
|
|
||||||
|
let mut completion: Option<AgentVerdict> = None;
|
||||||
for call in &tool_calls {
|
for call in &tool_calls {
|
||||||
let result = execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await;
|
let result =
|
||||||
|
execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await;
|
||||||
|
if completion.is_none() && is_completion_tool(&call.function.name) {
|
||||||
|
completion = parse_completion_verdict(&call.function.name, &result);
|
||||||
|
}
|
||||||
messages.push(ChatMessage::tool(&call.id, result));
|
messages.push(ChatMessage::tool(&call.id, result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(verdict) = completion {
|
||||||
|
return Ok(verdict);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(last_text)
|
Ok(AgentVerdict::Blocked(format!(
|
||||||
|
"blocked: reached max rounds ({max_rounds}) without completion"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_completion_verdict(name: &str, result_json: &str) -> Option<AgentVerdict> {
|
||||||
|
let v: Value = serde_json::from_str(result_json).ok()?;
|
||||||
|
if v.get("error").is_some() {
|
||||||
|
return Some(AgentVerdict::Blocked(format!(
|
||||||
|
"completion tool {name} failed: {}",
|
||||||
|
v["error"]
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
match name {
|
||||||
|
"report_done" => {
|
||||||
|
let msg = v
|
||||||
|
.get("message")
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.unwrap_or("done")
|
||||||
|
.to_string();
|
||||||
|
Some(AgentVerdict::Done(msg))
|
||||||
|
}
|
||||||
|
"report_blocked" => {
|
||||||
|
let reason = v
|
||||||
|
.get("reason")
|
||||||
|
.and_then(|x| x.as_str())
|
||||||
|
.unwrap_or("blocked")
|
||||||
|
.to_string();
|
||||||
|
Some(AgentVerdict::Blocked(reason))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::model::{FunctionCall, ToolCall};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn tc(id: &str, name: &str, args: &str) -> ToolCall {
|
||||||
|
ToolCall {
|
||||||
|
id: id.into(),
|
||||||
|
kind: "function".into(),
|
||||||
|
function: FunctionCall {
|
||||||
|
name: name.into(),
|
||||||
|
arguments: args.into(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn system_prompt_mentions_tools() {
|
fn system_prompt_mentions_tools_and_chinese() {
|
||||||
assert!(AGENT_SYSTEM.contains("shell"));
|
assert!(AGENT_SYSTEM.contains("shell"));
|
||||||
|
assert!(AGENT_SYSTEM.contains("report_done"));
|
||||||
|
assert!(AGENT_SYSTEM.contains("report_blocked"));
|
||||||
assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese"));
|
assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,4 +319,202 @@ mod tests {
|
||||||
fn default_max_rounds_is_12() {
|
fn default_max_rounds_is_12() {
|
||||||
assert_eq!(DEFAULT_MAX_ROUNDS, 12);
|
assert_eq!(DEFAULT_MAX_ROUNDS, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_signature_stable() {
|
||||||
|
let a = vec![tc("1", "list_dir", r#"{"path":"."}"#)];
|
||||||
|
let b = vec![tc("2", "list_dir", r#"{"path":"."}"#)];
|
||||||
|
assert_eq!(round_signature(&a), round_signature(&b));
|
||||||
|
let c = vec![tc("1", "list_dir", r#"{"path":"src"}"#)];
|
||||||
|
assert_ne!(round_signature(&a), round_signature(&c));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn truncate_keeps_system_and_shrinks_old_tools() {
|
||||||
|
let mut msgs = vec![
|
||||||
|
ChatMessage::system("sys"),
|
||||||
|
ChatMessage::user("goal"),
|
||||||
|
ChatMessage::assistant_tool_calls(None, vec![tc("c1", "read_file", r#"{"path":"a"}"#)]),
|
||||||
|
ChatMessage::tool("c1", "X".repeat(5_000)),
|
||||||
|
ChatMessage::assistant_tool_calls(None, vec![tc("c2", "read_file", r#"{"path":"b"}"#)]),
|
||||||
|
ChatMessage::tool("c2", "Y".repeat(5_000)),
|
||||||
|
ChatMessage::assistant_tool_calls(None, vec![tc("c3", "read_file", r#"{"path":"c"}"#)]),
|
||||||
|
ChatMessage::tool("c3", "Z".repeat(5_000)),
|
||||||
|
ChatMessage::assistant_tool_calls(None, vec![tc("c4", "read_file", r#"{"path":"d"}"#)]),
|
||||||
|
ChatMessage::tool("c4", "W".repeat(5_000)),
|
||||||
|
ChatMessage::assistant_tool_calls(None, vec![tc("c5", "read_file", r#"{"path":"e"}"#)]),
|
||||||
|
ChatMessage::tool("c5", "V".repeat(5_000)),
|
||||||
|
ChatMessage::user("still the goal"),
|
||||||
|
ChatMessage::assistant("recent"),
|
||||||
|
];
|
||||||
|
let before = messages_char_len(&msgs);
|
||||||
|
truncate_messages(&mut msgs, 8_000);
|
||||||
|
let after = messages_char_len(&msgs);
|
||||||
|
assert!(after < before);
|
||||||
|
assert_eq!(msgs[0].text(), "sys");
|
||||||
|
assert!(msgs.iter().any(|m| m.role == Role::User));
|
||||||
|
let old_tool = msgs
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.role == Role::Tool && m.tool_call_id.as_deref() == Some("c1"));
|
||||||
|
if let Some(t) = old_tool {
|
||||||
|
assert!(
|
||||||
|
t.text().contains("truncated") || t.text().len() < 5_000,
|
||||||
|
"{}",
|
||||||
|
t.text()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn loop_stops_on_report_done() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("grokboy-agent-done-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let ctx = ToolContext::new(dir.clone());
|
||||||
|
let mut messages = vec![
|
||||||
|
ChatMessage::system(AGENT_SYSTEM),
|
||||||
|
ChatMessage::user("finish"),
|
||||||
|
];
|
||||||
|
let calls = Arc::new(Mutex::new(0usize));
|
||||||
|
let calls2 = calls.clone();
|
||||||
|
|
||||||
|
let verdict = run_agent_with(&mut messages, &ctx, 5, 100_000, move |_msgs, _tools| {
|
||||||
|
let n = {
|
||||||
|
let mut g = calls2.lock().unwrap();
|
||||||
|
*g += 1;
|
||||||
|
*g
|
||||||
|
};
|
||||||
|
async move {
|
||||||
|
if n == 1 {
|
||||||
|
Ok(ChatMessage::assistant_tool_calls(
|
||||||
|
None,
|
||||||
|
vec![tc(
|
||||||
|
"done1",
|
||||||
|
"report_done",
|
||||||
|
&json!({"message": "任務完成"}).to_string(),
|
||||||
|
)],
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(ChatMessage::assistant("should not reach"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(verdict, AgentVerdict::Done("任務完成".into()));
|
||||||
|
assert_eq!(*calls.lock().unwrap(), 1);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn loop_stops_on_report_blocked() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("grokboy-agent-blk-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let ctx = ToolContext::new(dir.clone());
|
||||||
|
let mut messages = vec![ChatMessage::user("x")];
|
||||||
|
|
||||||
|
let verdict = run_agent_with(&mut messages, &ctx, 5, 100_000, move |_msgs, _tools| async move {
|
||||||
|
Ok(ChatMessage::assistant_tool_calls(
|
||||||
|
None,
|
||||||
|
vec![tc(
|
||||||
|
"b1",
|
||||||
|
"report_blocked",
|
||||||
|
&json!({"reason": "permission denied"}).to_string(),
|
||||||
|
)],
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(verdict, AgentVerdict::Blocked("permission denied".into()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn loop_guard_blocks_repeated_calls() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("grokboy-agent-loop-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let ctx = ToolContext::new(dir.clone());
|
||||||
|
std::fs::write(dir.join("f.txt"), "hi").unwrap();
|
||||||
|
let mut messages = vec![ChatMessage::user("loop")];
|
||||||
|
let args = json!({"path": "f.txt"}).to_string();
|
||||||
|
|
||||||
|
let verdict = run_agent_with(&mut messages, &ctx, 12, 100_000, move |_msgs, _tools| {
|
||||||
|
let args = args.clone();
|
||||||
|
async move {
|
||||||
|
Ok(ChatMessage::assistant_tool_calls(
|
||||||
|
None,
|
||||||
|
vec![tc("r1", "read_file", &args)],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match verdict {
|
||||||
|
AgentVerdict::Blocked(reason) => {
|
||||||
|
assert!(reason.contains("loop guard"), "{reason}");
|
||||||
|
}
|
||||||
|
other => panic!("expected blocked, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn max_rounds_fail_closed() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("grokboy-agent-max-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let ctx = ToolContext::new(dir.clone());
|
||||||
|
std::fs::write(dir.join("a.txt"), "1").unwrap();
|
||||||
|
std::fs::write(dir.join("b.txt"), "2").unwrap();
|
||||||
|
let mut messages = vec![ChatMessage::user("spin")];
|
||||||
|
let n = Arc::new(Mutex::new(0usize));
|
||||||
|
let n2 = n.clone();
|
||||||
|
|
||||||
|
let verdict = run_agent_with(&mut messages, &ctx, 2, 100_000, move |_msgs, _tools| {
|
||||||
|
let i = {
|
||||||
|
let mut g = n2.lock().unwrap();
|
||||||
|
*g += 1;
|
||||||
|
*g
|
||||||
|
};
|
||||||
|
async move {
|
||||||
|
let path = if i == 1 { "a.txt" } else { "b.txt" };
|
||||||
|
Ok(ChatMessage::assistant_tool_calls(
|
||||||
|
None,
|
||||||
|
vec![tc(
|
||||||
|
&format!("c{i}"),
|
||||||
|
"read_file",
|
||||||
|
&json!({"path": path}).to_string(),
|
||||||
|
)],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match verdict {
|
||||||
|
AgentVerdict::Blocked(reason) => {
|
||||||
|
assert!(reason.contains("max rounds"), "{reason}");
|
||||||
|
}
|
||||||
|
other => panic!("expected blocked, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plain_answer_without_tools() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("grokboy-agent-ans-{}", std::process::id()));
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let ctx = ToolContext::new(dir.clone());
|
||||||
|
let mut messages = vec![ChatMessage::user("hi")];
|
||||||
|
|
||||||
|
let verdict = run_agent_with(&mut messages, &ctx, 5, 100_000, move |_msgs, _tools| async move {
|
||||||
|
Ok(ChatMessage::assistant("hello there"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(verdict, AgentVerdict::Answer("hello there".into()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,12 @@ mod model;
|
||||||
mod session;
|
mod session;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
pub use agent::{AGENT_SYSTEM, DEFAULT_MAX_ROUNDS, run_agent};
|
pub use agent::{
|
||||||
|
AGENT_SYSTEM, AgentVerdict, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS, LOOP_GUARD_REPEAT,
|
||||||
|
context_char_budget, message_char_len, messages_char_len, round_signature, run_agent,
|
||||||
|
run_agent_with, tool_call_signature, truncate_messages,
|
||||||
|
};
|
||||||
pub use config::Config;
|
pub use config::Config;
|
||||||
pub use model::{ChatMessage, FunctionCall, Role, ToolCall, chat_completion, 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 session::{Session, load_or_create, load_session, save_session, sessions_dir};
|
||||||
pub use tools::{ToolContext, execute_tool, tool_definitions};
|
pub use tools::{ToolContext, execute_tool, is_completion_tool, tool_definitions};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
//! Built-in tools: shell, list_dir, read_file, write_file.
|
//! Built-in tools: shell, list_dir, read_file, write_file, report_done, report_blocked.
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
@ -94,10 +94,43 @@ pub fn tool_definitions() -> Value {
|
||||||
"required": ["path", "content"]
|
"required": ["path", "content"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "report_done",
|
||||||
|
"description": "Signal that the task is complete. Stops the agent loop with a done verdict. Call once when finished; include a short summary in message.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": { "type": "string", "description": "Final summary for the user" }
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "report_blocked",
|
||||||
|
"description": "Signal that the task cannot proceed. Stops the agent loop with a blocked verdict and reason. Prefer this over spinning or inventing results.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"reason": { "type": "string", "description": "Why the agent is blocked" }
|
||||||
|
},
|
||||||
|
"required": ["reason"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the tool name ends the ReAct loop (completion contract).
|
||||||
|
pub fn is_completion_tool(name: &str) -> bool {
|
||||||
|
matches!(name, "report_done" | "report_blocked")
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a user-supplied path against cwd and optionally enforce workspace_root.
|
/// Resolve a user-supplied path against cwd and optionally enforce workspace_root.
|
||||||
pub fn resolve_path(ctx: &ToolContext, path: &str) -> Result<PathBuf> {
|
pub fn resolve_path(ctx: &ToolContext, path: &str) -> Result<PathBuf> {
|
||||||
let raw = Path::new(path);
|
let raw = Path::new(path);
|
||||||
|
|
@ -156,10 +189,34 @@ async fn execute_tool_inner(ctx: &ToolContext, name: &str, arguments_json: &str)
|
||||||
"list_dir" => tool_list_dir(ctx, &args).await,
|
"list_dir" => tool_list_dir(ctx, &args).await,
|
||||||
"read_file" => tool_read_file(ctx, &args).await,
|
"read_file" => tool_read_file(ctx, &args).await,
|
||||||
"write_file" => tool_write_file(ctx, &args).await,
|
"write_file" => tool_write_file(ctx, &args).await,
|
||||||
|
"report_done" => tool_report_done(&args),
|
||||||
|
"report_blocked" => tool_report_blocked(&args),
|
||||||
other => Err(anyhow!("unknown tool: {other}")),
|
other => Err(anyhow!("unknown tool: {other}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_report_done(args: &Value) -> Result<Value> {
|
||||||
|
let message = args
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow!("report_done: missing 'message'"))?;
|
||||||
|
Ok(json!({
|
||||||
|
"status": "done",
|
||||||
|
"message": message,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_report_blocked(args: &Value) -> Result<Value> {
|
||||||
|
let reason = args
|
||||||
|
.get("reason")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| anyhow!("report_blocked: missing 'reason'"))?;
|
||||||
|
Ok(json!({
|
||||||
|
"status": "blocked",
|
||||||
|
"reason": reason,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn tool_shell(ctx: &ToolContext, args: &Value) -> Result<Value> {
|
async fn tool_shell(ctx: &ToolContext, args: &Value) -> Result<Value> {
|
||||||
let command = args
|
let command = args
|
||||||
.get("command")
|
.get("command")
|
||||||
|
|
@ -389,11 +446,40 @@ mod tests {
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn report_done_and_blocked() {
|
||||||
|
let (ctx, dir) = temp_ctx();
|
||||||
|
let done = execute_tool(
|
||||||
|
&ctx,
|
||||||
|
"report_done",
|
||||||
|
&json!({"message": "all good"}).to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let done: Value = serde_json::from_str(&done).unwrap();
|
||||||
|
assert_eq!(done["status"], "done");
|
||||||
|
assert_eq!(done["message"], "all good");
|
||||||
|
|
||||||
|
let blocked = execute_tool(
|
||||||
|
&ctx,
|
||||||
|
"report_blocked",
|
||||||
|
&json!({"reason": "no access"}).to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let blocked: Value = serde_json::from_str(&blocked).unwrap();
|
||||||
|
assert_eq!(blocked["status"], "blocked");
|
||||||
|
assert_eq!(blocked["reason"], "no access");
|
||||||
|
|
||||||
|
assert!(is_completion_tool("report_done"));
|
||||||
|
assert!(is_completion_tool("report_blocked"));
|
||||||
|
assert!(!is_completion_tool("shell"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_defs_include_four() {
|
fn tool_defs_include_six() {
|
||||||
let defs = tool_definitions();
|
let defs = tool_definitions();
|
||||||
let arr = defs.as_array().unwrap();
|
let arr = defs.as_array().unwrap();
|
||||||
assert_eq!(arr.len(), 4);
|
assert_eq!(arr.len(), 6);
|
||||||
let names: Vec<&str> = arr
|
let names: Vec<&str> = arr
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| t["function"]["name"].as_str().unwrap())
|
.map(|t| t["function"]["name"].as_str().unwrap())
|
||||||
|
|
@ -402,5 +488,7 @@ mod tests {
|
||||||
assert!(names.contains(&"list_dir"));
|
assert!(names.contains(&"list_dir"));
|
||||||
assert!(names.contains(&"read_file"));
|
assert!(names.contains(&"read_file"));
|
||||||
assert!(names.contains(&"write_file"));
|
assert!(names.contains(&"write_file"));
|
||||||
|
assert!(names.contains(&"report_done"));
|
||||||
|
assert!(names.contains(&"report_blocked"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use grokboy_core::{
|
use grokboy_core::{
|
||||||
AGENT_SYSTEM, ChatMessage, Config, DEFAULT_MAX_ROUNDS, Session, ToolContext, execute_tool,
|
AGENT_SYSTEM, AgentVerdict, ChatMessage, Config, DEFAULT_MAX_ROUNDS, Session, ToolContext,
|
||||||
load_or_create, run_agent, save_session, stream_chat, tool_definitions,
|
execute_tool, is_completion_tool, load_or_create, messages_char_len, run_agent, save_session,
|
||||||
|
stream_chat, tool_definitions, truncate_messages,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
@ -48,13 +49,13 @@ async fn run() -> Result<()> {
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"\
|
"\
|
||||||
GrokBoy — minimal local CLI agent (P1: tools + ReAct)
|
GrokBoy — minimal local CLI agent (P2: completion + loop guard + truncation)
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
grokboy chat Interactive streaming chat (no tools)
|
grokboy chat Interactive streaming chat (no tools)
|
||||||
grokboy run \"<prompt>\" One-shot agent with tools
|
grokboy run \"<prompt>\" One-shot agent with tools
|
||||||
grokboy run --session <id> \"...\" Continue a saved session
|
grokboy run --session <id> \"...\" Continue a saved session
|
||||||
grokboy smoke Offline tool checks (no API key required)
|
grokboy smoke Offline checks (no API key required)
|
||||||
grokboy version
|
grokboy version
|
||||||
grokboy help
|
grokboy help
|
||||||
|
|
||||||
|
|
@ -62,8 +63,10 @@ ENV:
|
||||||
GROKBOY_API_KEY API key (or XAI_API_KEY / OPENAI_API_KEY)
|
GROKBOY_API_KEY API key (or XAI_API_KEY / OPENAI_API_KEY)
|
||||||
GROKBOY_BASE_URL default https://api.x.ai/v1
|
GROKBOY_BASE_URL default https://api.x.ai/v1
|
||||||
GROKBOY_MODEL default grok-4.6
|
GROKBOY_MODEL default grok-4.6
|
||||||
|
GROKBOY_CONTEXT_CHARS context budget (default 100000)
|
||||||
|
|
||||||
Sessions are stored under ~/.grokboy/sessions/<id>.json
|
Tools: shell, list_dir, read_file, write_file, report_done, report_blocked
|
||||||
|
Sessions: ~/.grokboy/sessions/<id>.json
|
||||||
"
|
"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +151,7 @@ async fn cmd_run(args: &[String]) -> Result<()> {
|
||||||
session.push(ChatMessage::user(&prompt));
|
session.push(ChatMessage::user(&prompt));
|
||||||
|
|
||||||
let tool_ctx = ToolContext::new(session.cwd.clone());
|
let tool_ctx = ToolContext::new(session.cwd.clone());
|
||||||
let answer = run_agent(
|
let verdict = run_agent(
|
||||||
&config,
|
&config,
|
||||||
&mut session.messages,
|
&mut session.messages,
|
||||||
&tool_ctx,
|
&tool_ctx,
|
||||||
|
|
@ -158,17 +161,22 @@ async fn cmd_run(args: &[String]) -> Result<()> {
|
||||||
|
|
||||||
session.touch();
|
session.touch();
|
||||||
let path = save_session(&session)?;
|
let path = save_session(&session)?;
|
||||||
println!("{answer}");
|
println!("{}", verdict.message());
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"\n[session {} saved → {}]",
|
"\n[verdict: {} | session {} → {}]",
|
||||||
|
verdict.kind(),
|
||||||
session.id,
|
session.id,
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
|
// Non-zero exit on blocked so scripts can detect fail-closed.
|
||||||
|
if matches!(verdict, AgentVerdict::Blocked(_)) {
|
||||||
|
return Err(anyhow!("agent blocked: {}", verdict.message()));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_smoke() -> Result<()> {
|
async fn cmd_smoke() -> Result<()> {
|
||||||
println!("GrokBoy smoke (offline tools)…");
|
println!("GrokBoy smoke (offline P2)…");
|
||||||
let stamp = uuid_like();
|
let stamp = uuid_like();
|
||||||
let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}"));
|
let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}"));
|
||||||
std::fs::create_dir_all(&dir).context("temp dir")?;
|
std::fs::create_dir_all(&dir).context("temp dir")?;
|
||||||
|
|
@ -241,15 +249,63 @@ async fn cmd_smoke() -> Result<()> {
|
||||||
}
|
}
|
||||||
println!(" sandbox ok");
|
println!(" sandbox ok");
|
||||||
|
|
||||||
// tool definitions present
|
// completion tools
|
||||||
|
let done = execute_tool(
|
||||||
|
&ctx,
|
||||||
|
"report_done",
|
||||||
|
&json!({"message": "smoke done"}).to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let done: serde_json::Value = serde_json::from_str(&done)?;
|
||||||
|
if done["status"] != "done" || done["message"] != "smoke done" {
|
||||||
|
return Err(anyhow!("report_done mismatch: {done}"));
|
||||||
|
}
|
||||||
|
let blocked = execute_tool(
|
||||||
|
&ctx,
|
||||||
|
"report_blocked",
|
||||||
|
&json!({"reason": "smoke blocked"}).to_string(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let blocked: serde_json::Value = serde_json::from_str(&blocked)?;
|
||||||
|
if blocked["status"] != "blocked" || blocked["reason"] != "smoke blocked" {
|
||||||
|
return Err(anyhow!("report_blocked mismatch: {blocked}"));
|
||||||
|
}
|
||||||
|
if !is_completion_tool("report_done") || !is_completion_tool("report_blocked") {
|
||||||
|
return Err(anyhow!("is_completion_tool failed"));
|
||||||
|
}
|
||||||
|
println!(" completion ok");
|
||||||
|
|
||||||
|
// tool definitions present (6)
|
||||||
let defs = tool_definitions();
|
let defs = tool_definitions();
|
||||||
if defs.as_array().map(|a| a.len()).unwrap_or(0) != 4 {
|
let n_tools = defs.as_array().map(|a| a.len()).unwrap_or(0);
|
||||||
return Err(anyhow!("expected 4 tool defs"));
|
if n_tools != 6 {
|
||||||
|
return Err(anyhow!("expected 6 tool defs, got {n_tools}"));
|
||||||
}
|
}
|
||||||
println!(" tool defs ok");
|
println!(" tool defs ok");
|
||||||
|
|
||||||
// session roundtrip in temp (does not require ~/.grokboy for this check —
|
// context truncation
|
||||||
// we still exercise Session serialize via save into temp using core types)
|
let mut msgs = vec![
|
||||||
|
ChatMessage::system(AGENT_SYSTEM),
|
||||||
|
ChatMessage::user("goal"),
|
||||||
|
ChatMessage::tool("t1", "X".repeat(50_000)),
|
||||||
|
ChatMessage::tool("t2", "Y".repeat(50_000)),
|
||||||
|
ChatMessage::tool("t3", "Z".repeat(50_000)),
|
||||||
|
ChatMessage::tool("t4", "W".repeat(50_000)),
|
||||||
|
ChatMessage::tool("t5", "V".repeat(50_000)),
|
||||||
|
ChatMessage::user("goal again"),
|
||||||
|
];
|
||||||
|
let before = messages_char_len(&msgs);
|
||||||
|
truncate_messages(&mut msgs, 10_000);
|
||||||
|
let after = messages_char_len(&msgs);
|
||||||
|
if after >= before {
|
||||||
|
return Err(anyhow!("truncate did not shrink ({before} -> {after})"));
|
||||||
|
}
|
||||||
|
if msgs[0].text() != AGENT_SYSTEM {
|
||||||
|
return Err(anyhow!("truncate dropped system prompt"));
|
||||||
|
}
|
||||||
|
println!(" truncation ok");
|
||||||
|
|
||||||
|
// session roundtrip
|
||||||
let mut sess = Session::new(dir.clone());
|
let mut sess = Session::new(dir.clone());
|
||||||
sess.push(ChatMessage::system(AGENT_SYSTEM));
|
sess.push(ChatMessage::system(AGENT_SYSTEM));
|
||||||
sess.push(ChatMessage::user("smoke"));
|
sess.push(ChatMessage::user("smoke"));
|
||||||
|
|
@ -264,7 +320,6 @@ async fn cmd_smoke() -> Result<()> {
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
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() {
|
if Config::from_env().is_ok() {
|
||||||
println!(" (API key present — skipping live call in smoke; use `run` to exercise)");
|
println!(" (API key present — skipping live call in smoke; use `run` to exercise)");
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@
|
||||||
- [x] Default model `grok-4.6`
|
- [x] Default model `grok-4.6`
|
||||||
|
|
||||||
## P2 — completion contract
|
## P2 — completion contract
|
||||||
- [ ] `report_done` / blocked
|
- [x] `report_done` / `report_blocked` stop the loop with a clear verdict
|
||||||
- [ ] Loop guard
|
- [x] Loop guard (identical tool rounds ×3 → blocked)
|
||||||
- [ ] Context truncation
|
- [x] Context truncation (`GROKBOY_CONTEXT_CHARS`, default ~100k)
|
||||||
|
- [x] Offline smoke/tests cover P2 without API key
|
||||||
|
|
||||||
## P3 — browser
|
## P3 — browser
|
||||||
- [ ] Playwright DOM path (no screenshot-first)
|
- [ ] Playwright DOM path (no screenshot-first)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue