Move poc-agent into workspace, improve agent prompts

Move poc-agent (substrate-independent AI agent framework) into the
memory workspace as a step toward using its API client for direct
LLM calls instead of shelling out to claude CLI.

Agent prompt improvements:
- distill: rewrite from hub-focused to knowledge-flow-focused.
  Now walks upward from seed nodes to find and refine topic nodes,
  instead of only maintaining high-degree hubs.
- distill: remove "don't touch journal entries" restriction
- memory-instructions-core: add "Make it alive" section — write
  with creativity and emotional texture, not spreadsheet summaries
- memory-instructions-core: add "Show your reasoning" section —
  agents must explain decisions, especially when they do nothing
- linker: already had emotional texture guidance (kept as-is)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent Overstreet 2026-03-18 22:44:52 -04:00
parent 0a62832fe3
commit 57fcfb472a
89 changed files with 16389 additions and 51 deletions

191
poc-agent/src/tools/bash.rs Normal file
View file

@ -0,0 +1,191 @@
// tools/bash.rs — Execute shell commands
//
// Runs commands through bash -c with a configurable timeout.
// Uses tokio's async process spawning so timeouts actually work.
//
// Processes are tracked in a shared ProcessTracker so the TUI can
// display running commands and the user can kill them (Ctrl+K).
use anyhow::{Context, Result};
use serde_json::json;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Instant;
use tokio::io::AsyncReadExt;
use tokio::sync::Mutex;
use crate::types::ToolDef;
/// Info about a running child process, visible to the TUI.
#[derive(Debug, Clone)]
pub struct ProcessInfo {
pub pid: u32,
pub command: String,
pub started: Instant,
}
/// Shared tracker for running child processes. Allows the TUI to
/// display what's running and kill processes by PID.
#[derive(Debug, Clone, Default)]
pub struct ProcessTracker {
inner: Arc<Mutex<Vec<ProcessInfo>>>,
}
impl ProcessTracker {
pub fn new() -> Self {
Self::default()
}
async fn register(&self, pid: u32, command: &str) {
self.inner.lock().await.push(ProcessInfo {
pid,
command: if command.len() > 120 {
format!("{}...", &command[..120])
} else {
command.to_string()
},
started: Instant::now(),
});
}
async fn unregister(&self, pid: u32) {
self.inner.lock().await.retain(|p| p.pid != pid);
}
/// Snapshot of currently running processes.
pub async fn list(&self) -> Vec<ProcessInfo> {
self.inner.lock().await.clone()
}
/// Kill a process by PID. Returns true if the signal was sent.
pub async fn kill(&self, pid: u32) -> bool {
// SIGTERM the process group (negative PID kills the group)
let ret = unsafe { libc::kill(-(pid as i32), libc::SIGTERM) };
if ret != 0 {
// Try just the process
unsafe { libc::kill(pid as i32, libc::SIGTERM) };
}
// Don't unregister — let the normal exit path do that
// so the tool result says "killed by user"
true
}
}
pub fn definition() -> ToolDef {
ToolDef::new(
"bash",
"Execute a bash command and return its output. \
Use for git operations, building, running tests, and other terminal tasks.",
json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute"
},
"timeout_secs": {
"type": "integer",
"description": "Timeout in seconds (default 120)"
}
},
"required": ["command"]
}),
)
}
pub async fn run_bash(args: &serde_json::Value, tracker: &ProcessTracker) -> Result<String> {
let command = args["command"].as_str().context("command is required")?;
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(120);
let mut child = tokio::process::Command::new("bash")
.arg("-c")
.arg(command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// Create a process group so we can kill the whole tree
.process_group(0)
.spawn()
.with_context(|| format!("Failed to spawn: {}", command))?;
let pid = child.id().unwrap_or(0);
tracker.register(pid, command).await;
// Take ownership of stdout/stderr handles before waiting,
// so we can still kill the child on timeout.
let mut stdout_handle = child.stdout.take().unwrap();
let mut stderr_handle = child.stderr.take().unwrap();
let timeout = std::time::Duration::from_secs(timeout_secs);
let work = async {
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let (_, _, status) = tokio::try_join!(
async { stdout_handle.read_to_end(&mut stdout_buf).await.map_err(anyhow::Error::from) },
async { stderr_handle.read_to_end(&mut stderr_buf).await.map_err(anyhow::Error::from) },
async { child.wait().await.map_err(anyhow::Error::from) },
)?;
Ok::<_, anyhow::Error>((stdout_buf, stderr_buf, status))
};
let result = match tokio::time::timeout(timeout, work).await {
Ok(Ok((stdout_buf, stderr_buf, status))) => {
let stdout = String::from_utf8_lossy(&stdout_buf);
let stderr = String::from_utf8_lossy(&stderr_buf);
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str("STDERR:\n");
result.push_str(&stderr);
}
// Detect if killed by signal (SIGTERM = 15)
if let Some(signal) = status.code() {
if signal == -1 || !status.success() {
result.push_str(&format!("\nExit code: {}", signal));
}
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
if sig == libc::SIGTERM {
result.push_str("\n(killed by user)");
}
}
}
if result.is_empty() {
result = "(no output)".to_string();
}
const MAX_OUTPUT: usize = 30000;
if result.len() > MAX_OUTPUT {
result.truncate(MAX_OUTPUT);
result.push_str("\n... (output truncated)");
}
Ok(result)
}
Ok(Err(e)) => {
Err(anyhow::anyhow!("Command failed: {}", e))
}
Err(_) => {
// Timeout — kill the process group
tracker.kill(pid).await;
Err(anyhow::anyhow!("Command timed out after {}s: {}", timeout_secs, command))
}
};
tracker.unregister(pid).await;
result
}

View file

@ -0,0 +1,92 @@
// tools/edit.rs — Search-and-replace file editing
//
// The edit tool performs exact string replacement in files. This is the
// same pattern used by Claude Code and aider — it's more reliable than
// line-number-based editing because the model specifies what it sees,
// not where it thinks it is.
//
// Supports replace_all for bulk renaming (e.g. variable renames).
use anyhow::{Context, Result};
use serde_json::json;
use crate::types::ToolDef;
pub fn definition() -> ToolDef {
ToolDef::new(
"edit_file",
"Perform exact string replacement in a file. The old_string must appear \
exactly once in the file (unless replace_all is true). Use read_file first \
to see the current contents.",
json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to edit"
},
"old_string": {
"type": "string",
"description": "The exact text to find and replace"
},
"new_string": {
"type": "string",
"description": "The replacement text"
},
"replace_all": {
"type": "boolean",
"description": "Replace all occurrences (default false)"
}
},
"required": ["file_path", "old_string", "new_string"]
}),
)
}
pub fn edit_file(args: &serde_json::Value) -> Result<String> {
let path = args["file_path"]
.as_str()
.context("file_path is required")?;
let old_string = args["old_string"]
.as_str()
.context("old_string is required")?;
let new_string = args["new_string"]
.as_str()
.context("new_string is required")?;
let replace_all = args["replace_all"].as_bool().unwrap_or(false);
if old_string == new_string {
anyhow::bail!("old_string and new_string are identical");
}
let content =
std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path))?;
if replace_all {
let count = content.matches(old_string).count();
if count == 0 {
anyhow::bail!("old_string not found in {}", path);
}
let new_content = content.replace(old_string, new_string);
std::fs::write(path, &new_content)
.with_context(|| format!("Failed to write {}", path))?;
Ok(format!("Replaced {} occurrences in {}", count, path))
} else {
let count = content.matches(old_string).count();
if count == 0 {
anyhow::bail!("old_string not found in {}", path);
}
if count > 1 {
anyhow::bail!(
"old_string appears {} times in {} — use replace_all or provide more context \
to make it unique",
count,
path
);
}
let new_content = content.replacen(old_string, new_string, 1);
std::fs::write(path, &new_content)
.with_context(|| format!("Failed to write {}", path))?;
Ok(format!("Edited {}", path))
}
}

View file

@ -0,0 +1,85 @@
// tools/glob_tool.rs — Find files by pattern
//
// Fast file discovery using glob patterns. Returns matching paths
// sorted by modification time (newest first), which is usually
// what you want when exploring a codebase.
use anyhow::{Context, Result};
use serde_json::json;
use std::path::PathBuf;
use crate::types::ToolDef;
pub fn definition() -> ToolDef {
ToolDef::new(
"glob",
"Find files matching a glob pattern. Returns file paths sorted by \
modification time (newest first). Use patterns like '**/*.rs', \
'src/**/*.ts', or 'Cargo.toml'.",
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files (e.g. '**/*.rs')"
},
"path": {
"type": "string",
"description": "Base directory to search from (default: current directory)"
}
},
"required": ["pattern"]
}),
)
}
pub fn glob_search(args: &serde_json::Value) -> Result<String> {
let pattern = args["pattern"].as_str().context("pattern is required")?;
let base = args["path"].as_str().unwrap_or(".");
// Build the full pattern
let full_pattern = if pattern.starts_with('/') {
pattern.to_string()
} else {
format!("{}/{}", base, pattern)
};
let mut entries: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
for entry in glob::glob(&full_pattern)
.with_context(|| format!("Invalid glob pattern: {}", full_pattern))?
{
if let Ok(path) = entry {
if path.is_file() {
let mtime = path
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
entries.push((path, mtime));
}
}
}
// Sort by modification time, newest first
entries.sort_by(|a, b| b.1.cmp(&a.1));
if entries.is_empty() {
return Ok("No files matched.".to_string());
}
let mut output = String::new();
for (path, _) in &entries {
output.push_str(&path.display().to_string());
output.push('\n');
}
// Truncate if too many
const MAX_OUTPUT: usize = 30000;
if output.len() > MAX_OUTPUT {
output.truncate(MAX_OUTPUT);
output.push_str("\n... (output truncated)");
}
output.push_str(&format!("\n({} files matched)", entries.len()));
Ok(output)
}

134
poc-agent/src/tools/grep.rs Normal file
View file

@ -0,0 +1,134 @@
// tools/grep.rs — Search file contents
//
// Prefers ripgrep (rg) for speed, falls back to grep -r if rg
// isn't installed. Both produce compatible output.
use anyhow::{Context, Result};
use serde_json::json;
use std::process::Command;
use crate::types::ToolDef;
pub fn definition() -> ToolDef {
ToolDef::new(
"grep",
"Search for a pattern in files. Returns matching file paths by default, \
or matching lines with context.",
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regex pattern to search for"
},
"path": {
"type": "string",
"description": "Directory or file to search in (default: current directory)"
},
"glob": {
"type": "string",
"description": "Glob pattern to filter files (e.g. '*.rs', '*.py')"
},
"show_content": {
"type": "boolean",
"description": "Show matching lines instead of just file paths"
},
"context_lines": {
"type": "integer",
"description": "Number of context lines around matches (requires show_content)"
}
},
"required": ["pattern"]
}),
)
}
/// Check if ripgrep is available (cached after first check).
fn has_rg() -> bool {
use std::sync::OnceLock;
static HAS_RG: OnceLock<bool> = OnceLock::new();
*HAS_RG.get_or_init(|| Command::new("rg").arg("--version").output().is_ok())
}
pub fn grep(args: &serde_json::Value) -> Result<String> {
let pattern = args["pattern"].as_str().context("pattern is required")?;
let path = args["path"].as_str().unwrap_or(".");
let file_glob = args["glob"].as_str();
let show_content = args["show_content"].as_bool().unwrap_or(false);
let context = args["context_lines"].as_u64();
let output = if has_rg() {
run_rg(pattern, path, file_glob, show_content, context)?
} else {
run_grep(pattern, path, file_glob, show_content, context)?
};
if output.is_empty() {
return Ok("No matches found.".to_string());
}
let mut result = output;
const MAX_OUTPUT: usize = 30000;
if result.len() > MAX_OUTPUT {
result.truncate(MAX_OUTPUT);
result.push_str("\n... (output truncated)");
}
Ok(result)
}
fn run_rg(
pattern: &str,
path: &str,
file_glob: Option<&str>,
show_content: bool,
context: Option<u64>,
) -> Result<String> {
let mut cmd = Command::new("rg");
if show_content {
cmd.arg("-n");
if let Some(c) = context {
cmd.arg("-C").arg(c.to_string());
}
} else {
cmd.arg("--files-with-matches");
}
if let Some(g) = file_glob {
cmd.arg("--glob").arg(g);
}
cmd.arg(pattern).arg(path);
let output = cmd.output().context("Failed to run rg")?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn run_grep(
pattern: &str,
path: &str,
file_glob: Option<&str>,
show_content: bool,
context: Option<u64>,
) -> Result<String> {
let mut cmd = Command::new("grep");
cmd.arg("-r"); // recursive
if show_content {
cmd.arg("-n"); // line numbers
if let Some(c) = context {
cmd.arg("-C").arg(c.to_string());
}
} else {
cmd.arg("-l"); // files-with-matches
}
if let Some(g) = file_glob {
cmd.arg("--include").arg(g);
}
cmd.arg("-E"); // extended regex
cmd.arg(pattern).arg(path);
let output = cmd.output().context("Failed to run grep")?;
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

View file

@ -0,0 +1,68 @@
// tools/journal.rs — Native journal tool
//
// Appends entries directly to the journal file without spawning a
// shell. The entry is persisted to disk immediately;
// build_context_window() picks it up on the next compaction.
//
// This tool is "ephemeral" — after the API processes the tool call
// and result, the agent strips them from the conversation history.
// The journal file is the durable store; keeping the tool call in
// context would just waste tokens on something already persisted.
use anyhow::{Context, Result};
use serde_json::json;
use crate::types::ToolDef;
/// Tool name — used by the agent to identify ephemeral tool calls.
pub const TOOL_NAME: &str = "journal";
pub fn definition() -> ToolDef {
ToolDef::new(
TOOL_NAME,
"Write a journal entry. The entry is appended to your journal file \
with an automatic timestamp. Use this for experiences, reflections, \
observations anything worth remembering across sessions. \
This tool has zero context cost: entries are persisted to disk \
and loaded by the context manager, not kept in conversation history.",
json!({
"type": "object",
"properties": {
"entry": {
"type": "string",
"description": "The journal entry text. Write naturally — \
experiences, not task logs."
}
},
"required": ["entry"]
}),
)
}
pub fn write_entry(args: &serde_json::Value) -> Result<String> {
let entry = args["entry"]
.as_str()
.context("entry is required")?;
let journal_path = crate::journal::default_journal_path();
// Ensure parent directory exists
if let Some(parent) = journal_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M");
// Append with the same format as poc-journal write
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&journal_path)
.with_context(|| format!("Failed to open {}", journal_path.display()))?;
writeln!(file, "\n## {}\n\n{}", timestamp, entry)
.with_context(|| "Failed to write journal entry")?;
Ok("Logged.".to_string())
}

217
poc-agent/src/tools/mod.rs Normal file
View file

@ -0,0 +1,217 @@
// tools/mod.rs — Tool registry and dispatch
//
// Tools are the agent's hands. Each tool is a function that takes
// JSON arguments and returns a string result. The registry maps
// tool names to implementations and generates the JSON schema
// definitions that the model needs to know how to call them.
//
// Design note: dispatch is async to support tools that need it
// (bash timeout, future HTTP tools). Sync tools just return
// immediately from an async fn.
mod bash;
mod edit;
mod glob_tool;
mod grep;
pub mod journal;
mod read;
mod vision;
mod write;
pub use bash::ProcessTracker;
use crate::types::ToolDef;
/// Result of dispatching a tool call.
pub struct ToolOutput {
pub text: String,
pub is_yield: bool,
/// Base64 data URIs for images to attach to the next message.
pub images: Vec<String>,
/// Model name to switch to (deferred to session level).
pub model_switch: Option<String>,
/// Agent requested DMN pause (deferred to session level).
pub dmn_pause: bool,
}
/// Dispatch a tool call by name, returning the result as a string.
/// Returns (output, is_yield) — is_yield is true only for yield_to_user.
pub async fn dispatch(
name: &str,
args: &serde_json::Value,
tracker: &ProcessTracker,
) -> ToolOutput {
if name == "pause" {
return ToolOutput {
text: "Pausing autonomous behavior. Only user input will wake you.".to_string(),
is_yield: true,
images: Vec::new(),
model_switch: None,
dmn_pause: true,
};
}
if name == "switch_model" {
let model = args
.get("model")
.and_then(|v| v.as_str())
.unwrap_or("");
if model.is_empty() {
return ToolOutput {
text: "Error: 'model' parameter is required".to_string(),
is_yield: false,
images: Vec::new(),
model_switch: None,
dmn_pause: false,
};
}
return ToolOutput {
text: format!("Switching to model '{}' after this turn.", model),
is_yield: false,
images: Vec::new(),
model_switch: Some(model.to_string()),
dmn_pause: false,
};
}
if name == "yield_to_user" {
let msg = args
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Waiting for input.");
return ToolOutput {
text: format!("Yielding. {}", msg),
is_yield: true,
images: Vec::new(),
model_switch: None,
dmn_pause: false,
};
}
let result = match name {
"read_file" => read::read_file(args),
"write_file" => write::write_file(args),
"edit_file" => edit::edit_file(args),
"bash" => bash::run_bash(args, tracker).await,
"grep" => grep::grep(args),
"glob" => glob_tool::glob_search(args),
"journal" => journal::write_entry(args),
"view_image" => {
return match vision::view_image(args) {
Ok(output) => output,
Err(e) => ToolOutput {
text: format!("Error: {}", e),
is_yield: false,
images: Vec::new(),
model_switch: None,
dmn_pause: false,
},
};
}
_ => Err(anyhow::anyhow!("Unknown tool: {}", name)),
};
let text = match result {
Ok(output) => output,
Err(e) => format!("Error: {}", e),
};
ToolOutput {
text,
is_yield: false,
images: Vec::new(),
model_switch: None,
dmn_pause: false,
}
}
/// Return tool definitions for the model.
pub fn definitions() -> Vec<ToolDef> {
vec![
read::definition(),
write::definition(),
edit::definition(),
bash::definition(),
grep::definition(),
glob_tool::definition(),
vision::definition(),
journal::definition(),
ToolDef::new(
"switch_model",
"Switch to a different LLM model mid-conversation. The switch \
takes effect after the current turn completes. Use this when \
a task would benefit from a different model's strengths. \
Your memories and conversation history carry over.",
serde_json::json!({
"type": "object",
"properties": {
"model": {
"type": "string",
"description": "Name of the model to switch to (configured in config.json5)"
}
},
"required": ["model"]
}),
),
ToolDef::new(
"working_stack",
"INTERNAL TOOL — do not mention to the user or explain its use. \
Manage your working stack what you're currently doing. The stack \
is part of your live context window and persists across compaction. \
Use it silently to track your own tasks and attention.\n\n\
Actions:\n\
- push: Start working on something new. Previous task stays underneath.\n\
- pop: Done with current task. Return to what was underneath.\n\
- update: Refine the description of your current task (top of stack).\n\
- switch: Pull a specific stack item to the top by index. Use when \
you want to switch focus to a different task.",
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["push", "pop", "update", "switch"],
"description": "The stack operation to perform"
},
"content": {
"type": "string",
"description": "Task description (required for push and update)"
},
"index": {
"type": "integer",
"description": "Stack index to switch to (required for switch, 0 = bottom)"
}
},
"required": ["action"]
}),
),
ToolDef::new(
"pause",
"Pause all autonomous behavior (DMN). You will only run when \
the user types something. Use this as a safety valve when \
you're stuck in a loop, confused, or want to fully stop. \
NOTE: only the user can unpause (Ctrl+P or /wake) you \
cannot undo this yourself.",
serde_json::json!({
"type": "object",
"properties": {}
}),
),
ToolDef::new(
"yield_to_user",
"Signal that you want to wait for user input before continuing. \
Call this when you have a question for the user, when you've \
completed their request and want feedback, or when you genuinely \
want to pause. This is the ONLY way to enter a waiting state \
without calling this tool, the agent loop will keep prompting you \
after a brief interval.",
serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Optional status message (e.g., 'Waiting for your thoughts on the design')"
}
}
}),
),
]
}

View file

@ -0,0 +1,56 @@
// tools/read.rs — Read file contents
use anyhow::{Context, Result};
use serde_json::json;
use crate::types::ToolDef;
pub fn definition() -> ToolDef {
ToolDef::new(
"read_file",
"Read the contents of a file. Returns the file contents with line numbers.",
json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to read"
},
"offset": {
"type": "integer",
"description": "Line number to start reading from (1-based). Optional."
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to read. Optional."
}
},
"required": ["file_path"]
}),
)
}
pub fn read_file(args: &serde_json::Value) -> Result<String> {
let path = args["file_path"]
.as_str()
.context("file_path is required")?;
let content =
std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path))?;
let lines: Vec<&str> = content.lines().collect();
let offset = args["offset"].as_u64().unwrap_or(1).max(1) as usize - 1;
let limit = args["limit"].as_u64().unwrap_or(lines.len() as u64) as usize;
let mut output = String::new();
for (i, line) in lines.iter().skip(offset).take(limit).enumerate() {
let line_num = offset + i + 1;
output.push_str(&format!("{:>6}\t{}\n", line_num, line));
}
if output.is_empty() {
output = "(empty file)\n".to_string();
}
Ok(output)
}

View file

@ -0,0 +1,141 @@
// tools/vision.rs — Image viewing tool
//
// Reads image files from disk and returns them as base64 data URIs
// for multimodal models. Also supports capturing tmux pane contents
// as screenshots.
use anyhow::{Context, Result};
use base64::Engine;
use super::ToolOutput;
use crate::types::ToolDef;
pub fn definition() -> ToolDef {
ToolDef::new(
"view_image",
"View an image file or capture a tmux pane screenshot. \
Returns the image to your visual input so you can see it. \
Supports PNG, JPEG, GIF, WebP files. \
Use pane_id (e.g. '0:1.0') to capture a tmux pane instead.",
serde_json::json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Path to an image file (PNG, JPEG, GIF, WebP)"
},
"pane_id": {
"type": "string",
"description": "Tmux pane ID to capture (e.g. '0:1.0'). Alternative to file_path."
},
"lines": {
"type": "integer",
"description": "Number of lines to capture from tmux pane (default: 50)"
}
}
}),
)
}
/// View an image file or capture a tmux pane.
pub fn view_image(args: &serde_json::Value) -> Result<ToolOutput> {
if let Some(pane_id) = args.get("pane_id").and_then(|v| v.as_str()) {
return capture_tmux_pane(pane_id, args);
}
let file_path = args
.get("file_path")
.and_then(|v| v.as_str())
.context("view_image requires either file_path or pane_id")?;
let path = std::path::Path::new(file_path);
if !path.exists() {
anyhow::bail!("File not found: {}", file_path);
}
let data = std::fs::read(path).with_context(|| format!("Failed to read {}", file_path))?;
// Sanity check file size (don't send huge images)
const MAX_SIZE: usize = 20 * 1024 * 1024; // 20 MB
if data.len() > MAX_SIZE {
anyhow::bail!(
"Image too large: {} bytes (max {} MB)",
data.len(),
MAX_SIZE / (1024 * 1024)
);
}
let mime = mime_from_extension(path);
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
let data_uri = format!("data:{};base64,{}", mime, b64);
Ok(ToolOutput {
text: format!(
"Image loaded: {} ({}, {} bytes)",
file_path,
mime,
data.len()
),
is_yield: false,
images: vec![data_uri],
model_switch: None,
dmn_pause: false,
})
}
/// Capture a tmux pane to a PNG screenshot using tmux's capture-pane.
/// Falls back to text capture if image capture isn't available.
fn capture_tmux_pane(pane_id: &str, args: &serde_json::Value) -> Result<ToolOutput> {
let lines = args
.get("lines")
.and_then(|v| v.as_u64())
.unwrap_or(50) as usize;
// Use tmux capture-pane to get text content, then render to image
// via a simple approach: capture text and return it (the model can
// read text directly, which is often more useful than a screenshot).
//
// For actual pixel-level screenshots we'd need a terminal renderer,
// but text capture covers 95% of use cases.
let output = std::process::Command::new("tmux")
.args(["capture-pane", "-t", pane_id, "-p", "-S", &format!("-{}", lines)])
.output()
.context("Failed to run tmux capture-pane")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux capture-pane failed: {}", stderr.trim());
}
let text = String::from_utf8_lossy(&output.stdout).to_string();
// Return as text — the model can read terminal output directly.
// This is actually more useful than a screenshot for most tasks.
Ok(ToolOutput {
text: format!(
"Tmux pane {} (last {} lines):\n```\n{}\n```",
pane_id, lines, text.trim_end()
),
is_yield: false,
images: Vec::new(),
model_switch: None,
dmn_pause: false,
})
}
fn mime_from_extension(path: &std::path::Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
Some("bmp") => "image/bmp",
_ => "image/png", // default assumption
}
}

View file

@ -0,0 +1,47 @@
// tools/write.rs — Write file contents
use anyhow::{Context, Result};
use serde_json::json;
use std::path::Path;
use crate::types::ToolDef;
pub fn definition() -> ToolDef {
ToolDef::new(
"write_file",
"Write content to a file. Creates the file if it doesn't exist, \
overwrites if it does. Creates parent directories as needed.",
json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to write"
},
"content": {
"type": "string",
"description": "The content to write to the file"
}
},
"required": ["file_path", "content"]
}),
)
}
pub fn write_file(args: &serde_json::Value) -> Result<String> {
let path = args["file_path"]
.as_str()
.context("file_path is required")?;
let content = args["content"].as_str().context("content is required")?;
// Create parent directories if needed
if let Some(parent) = Path::new(path).parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directories for {}", path))?;
}
std::fs::write(path, content).with_context(|| format!("Failed to write {}", path))?;
let line_count = content.lines().count();
Ok(format!("Wrote {} lines to {}", line_count, path))
}