use crate::error::{NpcError, Result};
use crate::r#gen::{LlmResponse, Message, ToolDef};
use crate::tools::{RegisteredTool, ToolBuilder, ToolRegistry};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use tera::{Context, Tera};
use tokio::process::Command;
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NPC {
pub name: String,
#[serde(default)]
pub primary_directive: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub api_url: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub ascii_art: Option<String>,
#[serde(default)]
pub colors: Option<NpcColors>,
#[serde(default, alias = "jinxes")]
pub jinx_names: Vec<String>,
#[serde(default)]
pub mcp_servers: Vec<McpServerSpec>,
#[serde(default)]
pub use_global_jinxes: bool,
#[serde(default)]
pub plain_system_message: bool,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub auto_execute: bool,
#[serde(default)]
pub databases: Vec<String>,
#[serde(default)]
pub file_context: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(skip)]
pub memory: Option<String>,
#[serde(skip)]
pub memory_length: usize,
#[serde(skip)]
pub memory_strategy: String,
#[serde(skip)]
pub shared_context: HashMap<String, serde_json::Value>,
#[serde(skip)]
pub source_path: Option<String>,
#[serde(skip)]
pub npc_directory: Option<String>,
#[serde(skip)]
pub jinxes_directory: Option<String>,
#[serde(skip)]
pub jinxes_dict: HashMap<String, Jinx>,
#[serde(skip)]
pub jinx_tool_catalog: HashMap<String, serde_json::Value>,
#[serde(skip)]
pub tools: Vec<serde_json::Value>,
#[serde(skip)]
pub tool_map: HashMap<String, String>,
#[serde(skip)]
pub kg_data: Option<serde_json::Value>,
#[serde(skip)]
pub team: Option<Box<Team>>,
#[serde(skip)]
pub tables: Option<Vec<String>>,
#[serde(skip)]
pub command_history_path: Option<String>,
#[serde(skip)]
pub db_conn: Option<String>,
#[serde(skip)]
pub db_type: Option<String>,
#[serde(skip)]
pub jinxes_spec: Vec<String>,
#[serde(skip)]
pub npc_path: Option<String>,
#[serde(skip)]
pub npc_jinxes_directory: Option<String>,
#[serde(skip)]
pub tools_schema: Vec<serde_json::Value>,
}
impl Default for NPC {
fn default() -> Self {
Self {
name: "assistant".to_string(),
primary_directive: None,
model: None,
provider: None,
api_url: None,
api_key: None,
ascii_art: None,
colors: None,
jinx_names: Vec::new(),
mcp_servers: Vec::new(),
use_global_jinxes: false,
plain_system_message: false,
language: None,
auto_execute: false,
databases: Vec::new(),
file_context: Vec::new(),
env: HashMap::new(),
memory: None,
memory_length: 20,
memory_strategy: "recent".to_string(),
shared_context: HashMap::new(),
source_path: None,
npc_directory: None,
jinxes_directory: None,
jinxes_dict: HashMap::new(),
jinx_tool_catalog: HashMap::new(),
tools: Vec::new(),
tool_map: HashMap::new(),
kg_data: None,
team: None,
tables: None,
command_history_path: None,
db_conn: None,
db_type: None,
jinxes_spec: Vec::new(),
npc_path: None,
npc_jinxes_directory: None,
tools_schema: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NpcColors {
pub top: Option<String>,
pub bottom: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpServerSpec {
pub path: String,
pub command: Option<String>,
pub tools: Vec<String>,
}
impl<'de> Deserialize<'de> for McpServerSpec {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum McpSpec {
Path(String),
Full {
path: String,
#[serde(default)]
command: Option<String>,
#[serde(default)]
tools: Vec<String>,
},
}
match McpSpec::deserialize(deserializer)? {
McpSpec::Path(path) => Ok(McpServerSpec {
path,
command: None,
tools: Vec::new(),
}),
McpSpec::Full {
path,
command,
tools,
} => Ok(McpServerSpec {
path,
command,
tools,
}),
}
}
}
#[derive(Debug, Clone)]
pub enum ToolExecutor {
Jinx(String),
Mcp(McpServerSpec),
Native(String),
Python(String),
}
impl NPC {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
load_npc_from_file(path)
}
pub fn new(name: impl Into<String>, primary_directive: impl Into<String>) -> Self {
Self {
name: name.into(),
primary_directive: Some(primary_directive.into()),
..Default::default()
}
}
pub fn system_prompt(&self, team_context: Option<&str>) -> String {
let mut parts = Vec::new();
if let Some(ctx) = team_context {
parts.push(ctx.to_string());
}
if let Some(ref directive) = self.primary_directive {
parts.push(format!("Your name is {}.\n{}", self.name, directive));
} else {
parts.push(format!(
"Your name is {}. You are a helpful assistant.",
self.name
));
}
if let Some(ref memory) = self.memory {
parts.push(format!("## Your Memory\n{}", memory));
}
parts.join("\n\n")
}
pub fn resolve_tools(
&self,
jinxes: &HashMap<String, Jinx>,
) -> (Vec<ToolDef>, HashMap<String, ToolExecutor>) {
let mut defs = Vec::new();
let mut executors = HashMap::new();
for jinx_name in &self.jinx_names {
if let Some(jinx) = jinxes.get(jinx_name) {
if let Some(tool_def) = jinx.to_tool_def() {
executors.insert(jinx.name.clone(), ToolExecutor::Jinx(jinx.name.clone()));
defs.push(tool_def);
}
}
}
for mcp in &self.mcp_servers {
executors.insert(format!("mcp:{}", mcp.path), ToolExecutor::Mcp(mcp.clone()));
}
(defs, executors)
}
pub async fn get_response(
&self,
messages: &[Message],
tools: Option<&[ToolDef]>,
) -> Result<LlmResponse> {
let model = self.resolved_model();
let provider = self.resolved_provider();
crate::r#gen::get_genai_response(
&provider,
&model,
messages,
tools,
self.api_url.as_deref(),
None,
None,
false,
None,
)
.await
}
pub fn resolved_model(&self) -> String {
self.model
.clone()
.or_else(|| std::env::var("NPCSH_CHAT_MODEL").ok())
.unwrap_or_else(|| "qwen3.5:2b".to_string())
}
pub fn resolved_provider(&self) -> String {
self.provider
.clone()
.or_else(|| std::env::var("NPCSH_CHAT_PROVIDER").ok())
.unwrap_or_else(|| "ollama".to_string())
}
pub async fn get_llm_response(
&self,
request: &str,
jinxes: Option<&HashMap<String, Jinx>>,
tools: Option<&[ToolDef]>,
messages: Option<&[Message]>,
auto_process_tool_calls: bool,
use_core_tools: bool,
format: Option<&str>,
context: Option<&str>,
stream: bool,
images: Option<&[String]>,
) -> Result<crate::llm_funcs::LlmResponseResult> {
let _ = (jinxes, auto_process_tool_calls, use_core_tools);
crate::llm_funcs::get_llm_response_ext(
request,
Some(self),
None,
None,
tools,
messages.unwrap_or(&[]),
None,
format,
context,
stream,
images,
)
.await
}
pub async fn check_llm_command(
&self,
command: &str,
messages: &mut Vec<Message>,
jinxes: &HashMap<String, Jinx>,
context: Option<&str>,
) -> Result<HashMap<String, serde_json::Value>> {
crate::llm_funcs::check_llm_command(
command,
Some(&self.resolved_model()).map(|s| s.as_str()),
Some(&self.resolved_provider()).map(|s| s.as_str()),
Some(self),
messages,
context,
jinxes,
5,
)
.await
}
pub async fn execute_jinx(
&self,
jinx_name: &str,
jinxes: &HashMap<String, Jinx>,
command: &str,
messages: &[Message],
context: Option<&str>,
) -> Result<HashMap<String, serde_json::Value>> {
crate::llm_funcs::handle_jinx_call(
command,
jinx_name,
jinxes,
Some(&self.resolved_model()).map(|s| s.as_str()),
Some(&self.resolved_provider()).map(|s| s.as_str()),
Some(self),
messages,
context,
3,
0,
)
.await
}
pub fn to_dict(&self) -> serde_json::Value {
serde_json::json!({
"name": self.name,
"primary_directive": self.primary_directive,
"model": self.model,
"provider": self.provider,
"api_url": self.api_url,
"jinxes": self.jinx_names,
"mcp_servers": self.mcp_servers.iter().map(|m| &m.path).collect::<Vec<_>>(),
})
}
pub fn save(&self, directory: Option<&str>) -> Result<()> {
let dir = directory
.map(|d| std::path::PathBuf::from(d))
.or_else(|| {
self.source_path
.as_ref()
.and_then(|p| Path::new(p).parent().map(|p| p.to_path_buf()))
})
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join("npc_team"));
let _ = std::fs::create_dir_all(&dir);
let filename = format!("{}.npc", self.name);
let path = dir.join(&filename);
let yaml = serde_yaml::to_string(self)
.map_err(|e| crate::error::NpcError::Shell(format!("YAML serialize: {}", e)))?;
std::fs::write(&path, yaml)
.map_err(|e| crate::error::NpcError::Shell(format!("Write NPC: {}", e)))?;
Ok(())
}
pub async fn search_my_conversations(&self, query: &str, limit: usize) -> Result<String> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path)?;
let pattern = format!("%{}%", query);
let mut stmt = conn.prepare(
"SELECT content, role, timestamp FROM conversation_history WHERE npc = ?1 AND content LIKE ?2 ORDER BY timestamp DESC LIMIT ?3"
)?;
let results: Vec<String> = stmt
.query_map(rusqlite::params![self.name, pattern, limit as i64], |row| {
let content: String = row.get(0)?;
let role: String = row.get(1)?;
Ok(format!("[{}] {}", role, content))
})?
.filter_map(|r| r.ok())
.collect();
Ok(results.join("\n"))
}
pub async fn search_my_memories(&self, query: &str, limit: usize) -> Result<String> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path)?;
let pattern = format!("%{}%", query);
let mut stmt = conn.prepare(
"SELECT content FROM memory_lifecycle WHERE npc = ?1 AND content LIKE ?2 AND status IN ('approved', 'human-approved') ORDER BY created_at DESC LIMIT ?3"
)?;
let results: Vec<String> = stmt
.query_map(rusqlite::params![self.name, pattern, limit as i64], |row| {
row.get::<_, String>(0)
})?
.filter_map(|r| r.ok())
.collect();
Ok(results.join("\n"))
}
pub async fn think_step_by_step(&self, problem: &str) -> Result<String> {
let prompt = format!(
"Think through this problem step by step:\n\n{}\n\nProvide a clear, numbered breakdown of your reasoning.",
problem
);
let result = self
.get_llm_response(
&prompt, None, None, None, false, false, None, None, false, None,
)
.await?;
Ok(result.response.unwrap_or_default())
}
pub async fn write_code(&self, task: &str, language: &str) -> Result<String> {
let prompt = format!(
"Write {} code for the following task:\n\n{}\n\nRespond with only the code, no markdown formatting.",
language, task
);
let result = self
.get_llm_response(
&prompt, None, None, None, false, false, None, None, false, None,
)
.await?;
Ok(result.response.unwrap_or_default())
}
pub fn create_memory(&self, content: &str, memory_type: &str) -> Result<Option<i64>> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path)?;
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO memory_lifecycle (npc, memory_text, memory_type, status, created_at) VALUES (?1, ?2, ?3, 'pending', ?4)",
rusqlite::params![self.name, content, memory_type, now],
)?;
Ok(Some(conn.last_insert_rowid()))
}
pub fn read_memory(
&self,
memory_id: i64,
) -> Result<Option<HashMap<String, serde_json::Value>>> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path)?;
let result = conn.query_row(
"SELECT id, memory_text, memory_type, status, created_at FROM memory_lifecycle WHERE id = ?1 AND npc = ?2",
rusqlite::params![memory_id, self.name],
|row| {
let mut m = HashMap::new();
m.insert("id".into(), serde_json::json!(row.get::<_, i64>(0)?));
m.insert("content".into(), serde_json::json!(row.get::<_, String>(1)?));
m.insert("type".into(), serde_json::json!(row.get::<_, String>(2)?));
m.insert("status".into(), serde_json::json!(row.get::<_, String>(3)?));
m.insert("created_at".into(), serde_json::json!(row.get::<_, String>(4)?));
Ok(m)
}
);
match result {
Ok(m) => Ok(Some(m)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn delete_memory(&self, memory_id: i64) -> Result<bool> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path)?;
let rows = conn.execute(
"DELETE FROM memory_lifecycle WHERE id = ?1 AND npc = ?2",
rusqlite::params![memory_id, self.name],
)?;
Ok(rows > 0)
}
pub fn search_memories(
&self,
query: &str,
limit: usize,
status_filter: Option<&str>,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path)?;
let pattern = format!("%{}%", query);
let sql = if let Some(status) = status_filter {
format!(
"SELECT id, memory_text, memory_type, status, created_at FROM memory_lifecycle WHERE npc = ?1 AND memory_text LIKE ?2 AND status = '{}' ORDER BY created_at DESC LIMIT ?3",
status
)
} else {
"SELECT id, memory_text, memory_type, status, created_at FROM memory_lifecycle WHERE npc = ?1 AND memory_text LIKE ?2 ORDER BY created_at DESC LIMIT ?3".to_string()
};
let mut stmt = conn.prepare(&sql)?;
let results = stmt
.query_map(rusqlite::params![self.name, pattern, limit as i64], |row| {
let mut m = HashMap::new();
m.insert("id".into(), serde_json::json!(row.get::<_, i64>(0)?));
m.insert(
"content".into(),
serde_json::json!(row.get::<_, String>(1)?),
);
m.insert("type".into(), serde_json::json!(row.get::<_, String>(2)?));
m.insert("status".into(), serde_json::json!(row.get::<_, String>(3)?));
m.insert(
"created_at".into(),
serde_json::json!(row.get::<_, String>(4)?),
);
Ok(m)
})?
.filter_map(|r| r.ok())
.collect();
Ok(results)
}
pub fn get_all_memories(
&self,
limit: usize,
status_filter: Option<&str>,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
self.search_memories("", limit, status_filter)
}
pub fn get_memory_stats(&self) -> Result<HashMap<String, i64>> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path)?;
let mut stats = HashMap::new();
let total: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memory_lifecycle WHERE npc = ?1",
rusqlite::params![self.name],
|row| row.get(0),
)
.unwrap_or(0);
let pending: i64 = conn
.query_row(
"SELECT COUNT(*) FROM memory_lifecycle WHERE npc = ?1 AND status = 'pending'",
rusqlite::params![self.name],
|row| row.get(0),
)
.unwrap_or(0);
let approved: i64 = conn.query_row(
"SELECT COUNT(*) FROM memory_lifecycle WHERE npc = ?1 AND status IN ('approved', 'human-approved')",
rusqlite::params![self.name], |row| row.get(0)
).unwrap_or(0);
stats.insert("total".into(), total);
stats.insert("pending".into(), pending);
stats.insert("approved".into(), approved);
Ok(stats)
}
pub fn get_memory_context(&self) -> Option<String> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path).ok()?;
let mut stmt = conn.prepare(
"SELECT memory_text FROM memory_lifecycle WHERE npc = ?1 AND status IN ('approved', 'human-approved') ORDER BY created_at DESC LIMIT 20"
).ok()?;
let memories: Vec<String> = stmt
.query_map(rusqlite::params![self.name], |row| row.get::<_, String>(0))
.ok()?
.filter_map(|r| r.ok())
.collect();
if memories.is_empty() {
return None;
}
Some(
memories
.iter()
.map(|m| format!("- {}", m))
.collect::<Vec<_>>()
.join("\n"),
)
}
pub async fn query_database(&self, sql_query: &str) -> Result<String> {
let db_path = self.db_conn.as_deref().unwrap_or("");
if db_path.is_empty() {
return Err(NpcError::Shell("No database connection configured".into()));
}
let conn = rusqlite::Connection::open(db_path)?;
let mut stmt = conn.prepare(sql_query)?;
let col_count = stmt.column_count();
let col_names: Vec<String> = (0..col_count)
.map(|i| stmt.column_name(i).unwrap_or("?").to_string())
.collect();
let mut rows_out = Vec::new();
let mut rows = stmt.query(rusqlite::params![])?;
while let Some(row) = rows.next()? {
let vals: Vec<String> = (0..col_count)
.map(|i| row.get::<_, String>(i).unwrap_or_default())
.collect();
rows_out.push(vals.join("\t"));
}
Ok(format!("{}\n{}", col_names.join("\t"), rows_out.join("\n")))
}
pub fn archive_old_memories(&self, days_old: i64) -> Result<i64> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path)?;
let cutoff = (chrono::Utc::now() - chrono::Duration::days(days_old)).to_rfc3339();
let count = conn.execute(
"UPDATE memory_lifecycle SET status = 'archived' WHERE npc = ?1 AND status = 'approved' AND created_at < ?2",
rusqlite::params![self.name, cutoff],
)?;
Ok(count as i64)
}
pub fn update_memory_by_id(
&self,
memory_id: i64,
new_content: Option<&str>,
status: Option<&str>,
) -> Result<bool> {
let db_path = crate::npc_sysenv::get_history_db_path();
let conn = rusqlite::Connection::open(&db_path)?;
if let Some(content) = new_content {
conn.execute(
"UPDATE memory_lifecycle SET final_memory = ?1 WHERE id = ?2 AND npc = ?3",
rusqlite::params![content, memory_id, self.name],
)?;
}
if let Some(s) = status {
conn.execute(
"UPDATE memory_lifecycle SET status = ?1 WHERE id = ?2 AND npc = ?3",
rusqlite::params![s, memory_id, self.name],
)?;
}
Ok(true)
}
pub fn create_planning_state(&self, goal: &str) -> HashMap<String, serde_json::Value> {
let mut state = HashMap::new();
state.insert("goal".into(), serde_json::json!(goal));
state.insert("todos".into(), serde_json::json!([]));
state.insert("completed".into(), serde_json::json!([]));
state.insert("context".into(), serde_json::json!(""));
state
}
pub async fn generate_todos(
&self,
user_goal: &str,
planning_state: &HashMap<String, serde_json::Value>,
additional_context: &str,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let prompt = format!(
"You are a high-level project planner. Structure tasks logically:\n\
1. Understand current state\n\
2. Make required changes\n\
3. Verify changes work\n\n\
User goal: {}\n\
{}\n\n\
Generate 3-5 todos to accomplish this goal. Use specific actionable language.\n\
Each todo should be independent where possible and focused on a single component.\n\n\
Return JSON:\n\
{{\n\
\"todos\": [\n\
{{\"description\": \"todo description\", \"estimated_complexity\": \"simple|medium|complex\"}},\n\
...\n\
]\n\
}}",
user_goal, additional_context
);
let result = crate::llm_funcs::get_llm_response_ext(
&prompt,
Some(self),
None,
None,
None,
&[],
None,
Some("json"),
None,
false,
None,
)
.await?;
Ok(result
.response_json
.and_then(|j| {
j.get("todos").and_then(|t| t.as_array()).map(|a| {
a.iter()
.map(|v| {
let mut m = HashMap::new();
m.insert(
"description".into(),
v.get("description")
.cloned()
.unwrap_or(serde_json::json!("")),
);
m.insert(
"estimated_complexity".into(),
v.get("estimated_complexity")
.cloned()
.unwrap_or(serde_json::json!("medium")),
);
m
})
.collect()
})
})
.unwrap_or_default())
}
pub async fn should_break_down_todo(
&self,
todo: &HashMap<String, serde_json::Value>,
) -> Result<bool> {
let description = todo
.get("description")
.and_then(|t| t.as_str())
.unwrap_or("");
let complexity = todo
.get("estimated_complexity")
.and_then(|t| t.as_str())
.unwrap_or("unknown");
let prompt = format!(
"Todo: {}\n\
Complexity: {}\n\n\
Should this be broken into smaller steps? Consider:\n\
- Is it complex enough to warrant breakdown?\n\
- Would breakdown make execution clearer?\n\
- Are there multiple distinct steps?\n\n\
Return JSON: {{\"should_break_down\": true/false, \"reason\": \"explanation\"}}",
description, complexity
);
let result = crate::llm_funcs::get_llm_response_ext(
&prompt,
Some(self),
None,
None,
None,
&[],
None,
Some("json"),
None,
false,
None,
)
.await?;
Ok(result
.response_json
.and_then(|j| j.get("should_break_down").and_then(|v| v.as_bool()))
.unwrap_or(false))
}
pub async fn generate_subtodos(
&self,
todo: &HashMap<String, serde_json::Value>,
) -> Result<Vec<HashMap<String, serde_json::Value>>> {
let description = todo
.get("description")
.and_then(|t| t.as_str())
.unwrap_or("");
let prompt = format!(
"Parent todo: {}\n\n\
Break this into atomic, executable subtodos. Each should be:\n\
- A single, concrete action\n\
- Executable in one step\n\
- Clear and unambiguous\n\n\
Return JSON:\n\
{{\n\
\"subtodos\": [\n\
{{\"description\": \"subtodo description\", \"type\": \"action|verification|analysis\"}},\n\
...\n\
]\n\
}}",
description
);
let result = crate::llm_funcs::get_llm_response_ext(
&prompt,
Some(self),
None,
None,
None,
&[],
None,
Some("json"),
None,
false,
None,
)
.await?;
Ok(result
.response_json
.and_then(|j| {
j.get("subtodos").and_then(|t| t.as_array()).map(|a| {
a.iter()
.map(|v| {
let mut m = HashMap::new();
m.insert(
"description".into(),
v.get("description")
.cloned()
.unwrap_or(serde_json::json!("")),
);
m.insert(
"type".into(),
v.get("type")
.cloned()
.unwrap_or(serde_json::json!("action")),
);
m
})
.collect()
})
})
.unwrap_or_default())
}
pub async fn execute_planning_item(
&self,
item: &HashMap<String, serde_json::Value>,
planning_state: &HashMap<String, serde_json::Value>,
context: &str,
) -> Result<HashMap<String, serde_json::Value>> {
let context_summary = self.get_planning_context_summary(planning_state);
let description = item
.get("description")
.and_then(|t| t.as_str())
.unwrap_or("");
let constraints: Vec<String> = planning_state
.get("constraints")
.and_then(|c| c.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| format!("- {}", s)))
.collect()
})
.unwrap_or_default();
let command = format!(
"Current context:\n\
{}\n\
{}\n\n\
Execute this task: {}\n\n\
Constraints to follow:\n\
{}",
context_summary,
context,
description,
constraints.join("\n")
);
let mut messages = Vec::new();
let jinxes = self.jinxes_dict.clone();
let result = crate::llm_funcs::check_llm_command(
&command,
Some(self.resolved_model().as_str()),
Some(self.resolved_provider().as_str()),
Some(self),
&mut messages,
None,
&jinxes,
5,
)
.await?;
Ok(result)
}
pub fn get_planning_context_summary(
&self,
planning_state: &HashMap<String, serde_json::Value>,
) -> String {
let mut context = Vec::new();
if let Some(facts) = planning_state.get("facts").and_then(|f| f.as_array()) {
let strs: Vec<&str> = facts.iter().filter_map(|v| v.as_str()).take(5).collect();
if !strs.is_empty() {
context.push(format!("Facts: {}", strs.join("; ")));
}
}
if let Some(mistakes) = planning_state.get("mistakes").and_then(|m| m.as_array()) {
let strs: Vec<&str> = mistakes
.iter()
.filter_map(|v| v.as_str())
.rev()
.take(3)
.collect();
if !strs.is_empty() {
context.push(format!("Recent mistakes: {}", strs.join("; ")));
}
}
if let Some(successes) = planning_state.get("successes").and_then(|s| s.as_array()) {
let strs: Vec<&str> = successes
.iter()
.filter_map(|v| v.as_str())
.rev()
.take(3)
.collect();
if !strs.is_empty() {
context.push(format!("Recent successes: {}", strs.join("; ")));
}
}
context.join("\n")
}
pub fn setup_db(&mut self) {
let db_path = crate::npc_sysenv::get_history_db_path();
self.command_history_path = Some(db_path.to_string_lossy().to_string());
let _ = crate::npc_sysenv::init_db_tables(Some(&db_path.to_string_lossy()));
}
}
pub fn load_npc_from_file(path: impl AsRef<Path>) -> Result<NPC> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|e| NpcError::FileLoad {
path: path.display().to_string(),
source: e,
})?;
let raw = if raw.starts_with("#!") {
raw.splitn(2, '\n').nth(1).unwrap_or("").to_string()
} else {
raw
};
let processed = preprocess_npc_yaml(&raw);
let mut npc: NPC = serde_yaml::from_str(&processed).map_err(|e| NpcError::YamlParse {
path: path.display().to_string(),
source: e,
})?;
npc.source_path = Some(path.display().to_string());
npc.npc_path = Some(path.display().to_string());
let file_parent = path.parent().map(|p| p.to_string_lossy().to_string());
npc.npc_directory = file_parent.clone();
npc.jinxes_directory = file_parent
.as_ref()
.map(|p| Path::new(p).join("jinxes").to_string_lossy().to_string());
npc.npc_jinxes_directory = npc.jinxes_directory.clone();
npc.db_conn = None;
npc.db_type = None;
npc.command_history_path = None;
npc.kg_data = None;
npc.tables = None;
npc.memory = None;
npc.jinxes_dict = HashMap::new();
npc.jinx_tool_catalog = HashMap::new();
npc.tools = Vec::new();
npc.tool_map = HashMap::new();
npc.tools_schema = Vec::new();
npc.jinxes_spec = npc.jinx_names.clone();
for mcp in &mut npc.mcp_servers {
mcp.path = shellexpand::tilde(&mcp.path).to_string();
}
Ok(npc)
}
fn preprocess_npc_yaml(raw: &str) -> String {
let mut output = String::with_capacity(raw.len());
let mut in_for_block = false;
let mut for_glob_pattern: Option<String> = None;
for line in raw.lines() {
let trimmed = line.trim();
if trimmed.starts_with("{%") && trimmed.contains("jinxes_list") {
if let Some(pattern) = extract_jinxes_list_pattern(trimmed) {
for_glob_pattern = Some(pattern);
in_for_block = true;
}
continue;
}
if trimmed.starts_with("{%") && trimmed.contains("endfor") {
if in_for_block {
if let Some(ref pattern) = for_glob_pattern {
let prefix = pattern.trim_end_matches('*').trim_end_matches('_');
let names = expand_jinx_glob(pattern);
for name in names {
output.push_str(&format!(" - {}\n", name));
}
}
in_for_block = false;
for_glob_pattern = None;
}
continue;
}
if in_for_block {
continue;
}
if trimmed.starts_with("{%") {
continue;
}
if let Some(extracted) = extract_jinx_call(trimmed) {
let indent = &line[..line.len() - line.trim_start().len()];
output.push_str(&format!("{indent}- {extracted}\n"));
} else {
output.push_str(line);
output.push('\n');
}
}
output
}
fn extract_jinxes_list_pattern(line: &str) -> Option<String> {
let start = line.find("jinxes_list(")?;
let rest = &line[start + "jinxes_list(".len()..];
let end = rest.find(')')?;
let pattern = rest[..end].trim().trim_matches('\'').trim_matches('"');
Some(pattern.to_string())
}
fn expand_jinx_glob(pattern: &str) -> Vec<String> {
let locations = [
shellexpand::tilde("~/.npcsh/npc_team/jinxes").to_string(),
"./npc_team/jinxes".to_string(),
];
for base in &locations {
let glob_pattern = format!("{}/{}.jinx", base, pattern);
if let Ok(paths) = glob::glob(&glob_pattern) {
let names: Vec<String> = paths
.filter_map(|p| p.ok())
.filter_map(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
.collect();
if !names.is_empty() {
return names;
}
}
}
Vec::new()
}
fn extract_jinx_call(line: &str) -> Option<String> {
let line = line.trim().trim_start_matches('-').trim();
if !line.starts_with("{{") || !line.ends_with("}}") {
return None;
}
let inner = line.trim_start_matches("{{").trim_end_matches("}}").trim();
if !inner.starts_with("Jinx(") {
return None;
}
let name_part = inner
.trim_start_matches("Jinx(")
.trim_end_matches(')')
.trim()
.trim_matches('\'')
.trim_matches('"');
if name_part.is_empty() {
return None;
}
Some(name_part.to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Jinx {
#[serde(alias = "jinx_name")]
pub name: String,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub description: String,
#[serde(default, deserialize_with = "deserialize_inputs")]
pub inputs: Vec<JinxInput>,
#[serde(default)]
pub steps: Vec<JinxStep>,
#[serde(default)]
pub file_context: Vec<String>,
#[serde(default)]
pub npc: Option<String>,
#[serde(skip)]
pub source_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JinxInput {
pub name: String,
pub default: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JinxStep {
#[serde(default = "default_step_name")]
pub name: String,
#[serde(default = "default_engine")]
pub engine: String,
#[serde(default)]
pub code: String,
}
fn default_engine() -> String {
"bash".to_string()
}
fn default_step_name() -> String {
"step".to_string()
}
#[derive(Debug, Clone, Default)]
pub struct JinxResult {
pub output: String,
pub context: std::collections::HashMap<String, serde_json::Value>,
pub success: bool,
pub error: Option<String>,
}
fn deserialize_inputs<'de, D>(deserializer: D) -> std::result::Result<Vec<JinxInput>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum InputItem {
Simple(String),
Map(std::collections::HashMap<String, serde_yaml::Value>),
}
let items: Vec<InputItem> = Vec::deserialize(deserializer)?;
let mut inputs = Vec::with_capacity(items.len());
for item in items {
match item {
InputItem::Simple(name) => {
inputs.push(JinxInput {
name,
default: None,
description: None,
});
}
InputItem::Map(map) => {
for (name, value) in map {
match value {
serde_yaml::Value::String(s) => {
inputs.push(JinxInput {
name,
default: Some(s),
description: None,
});
}
serde_yaml::Value::Mapping(m) => {
let desc = m
.get(&serde_yaml::Value::String("description".to_string()))
.and_then(|v| v.as_str())
.map(String::from);
let default = m
.get(&serde_yaml::Value::String("default".to_string()))
.and_then(|v| v.as_str())
.map(String::from);
inputs.push(JinxInput {
name,
default,
description: desc,
});
}
serde_yaml::Value::Null => {
inputs.push(JinxInput {
name,
default: None,
description: None,
});
}
other => {
inputs.push(JinxInput {
name,
default: Some(format!("{:?}", other)),
description: None,
});
}
}
}
}
}
}
Ok(inputs)
}
impl Jinx {
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
load_jinx_from_file(path)
}
pub fn to_tool_def(&self) -> Option<ToolDef> {
if self.name.is_empty() || self.description.is_empty() {
return None;
}
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for input in &self.inputs {
let mut prop = serde_json::Map::new();
prop.insert("type".into(), serde_json::Value::String("string".into()));
if let Some(ref desc) = input.description {
prop.insert(
"description".into(),
serde_json::Value::String(desc.clone()),
);
}
properties.insert(input.name.clone(), serde_json::Value::Object(prop));
if input.default.is_none() {
required.push(serde_json::Value::String(input.name.clone()));
}
}
Some(ToolDef {
r#type: "function".to_string(),
function: crate::r#gen::FunctionDef {
name: self.name.clone(),
description: Some(self.description.clone()),
parameters: serde_json::json!({
"type": "object",
"properties": properties,
"required": required,
}),
},
})
}
pub fn to_dict(&self) -> serde_json::Value {
serde_json::json!({
"jinx_name": self.name,
"description": self.description,
"inputs": self.inputs.iter().map(|i| {
if let Some(ref def) = i.default {
serde_json::json!({i.name.clone(): def})
} else {
serde_json::json!(i.name.clone())
}
}).collect::<Vec<_>>(),
"steps": self.steps.iter().map(|s| {
serde_json::json!({
"name": s.name,
"engine": s.engine,
"code": s.code,
})
}).collect::<Vec<_>>(),
})
}
pub fn save(&self, directory: &str) -> Result<()> {
let dir = std::path::Path::new(directory);
let _ = std::fs::create_dir_all(dir);
let filename = format!("{}.jinx", self.name);
let path = dir.join(&filename);
let yaml = serde_yaml::to_string(self)
.map_err(|e| crate::error::NpcError::Shell(format!("YAML serialize: {}", e)))?;
std::fs::write(&path, yaml)
.map_err(|e| crate::error::NpcError::Shell(format!("Write jinx: {}", e)))?;
Ok(())
}
pub fn execute(
&self,
input_values: &std::collections::HashMap<String, String>,
) -> crate::npc_compiler::JinxResult {
let mut output = String::new();
let mut context: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
let mut success = true;
for step in &self.steps {
let mut rendered = step.code.clone();
for (k, v) in input_values {
rendered = rendered.replace(&format!("{{{{ {} }}}}", k), v);
rendered = rendered.replace(&format!("{{{{{}}}}}", k), v);
}
for (k, v) in &context {
if let Some(s) = v.as_str() {
rendered = rendered.replace(&format!("{{{{ {} }}}}", k), s);
}
}
let result = match step.engine.as_str() {
"bash" | "sh" => std::process::Command::new("sh")
.args(["-c", &rendered])
.output(),
"python" | "python3" => std::process::Command::new("python3")
.args(["-c", &rendered])
.output(),
_ => {
output.push_str(&format!("Unknown engine: {}\n", step.engine));
success = false;
continue;
}
};
match result {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
output.push_str(&stdout);
if !stderr.is_empty() {
output.push_str(&stderr);
}
context.insert(step.name.clone(), serde_json::json!(stdout.trim()));
if !o.status.success() {
success = false;
}
}
Err(e) => {
output.push_str(&format!("Error: {}\n", e));
success = false;
}
}
}
crate::npc_compiler::JinxResult {
output,
context,
success,
error: if success {
None
} else {
Some("Step failed".into())
},
}
}
pub fn render_first_pass(
&self,
input_values: &std::collections::HashMap<String, String>,
) -> String {
let mut rendered_steps = Vec::new();
for step in &self.steps {
let mut code = step.code.clone();
for (k, v) in input_values {
code = code.replace(&format!("{{{{ {} }}}}", k), v);
code = code.replace(&format!("{{{{{}}}}}", k), v);
}
rendered_steps.push(format!("[{}:{}] {}", step.name, step.engine, code));
}
rendered_steps.join("\n")
}
}
pub fn load_jinx_from_file(path: impl AsRef<Path>) -> Result<Jinx> {
let path = path.as_ref();
let raw = std::fs::read_to_string(path).map_err(|e| NpcError::FileLoad {
path: path.display().to_string(),
source: e,
})?;
let raw = if raw.starts_with("#!") {
raw.splitn(2, '\n').nth(1).unwrap_or("").to_string()
} else {
raw
};
let cleaned = strip_jinja2_specifics(&raw);
let mut jinx: Jinx = serde_yaml::from_str(&cleaned).map_err(|e| NpcError::YamlParse {
path: path.display().to_string(),
source: e,
})?;
jinx.source_path = Some(path.display().to_string());
Ok(jinx)
}
pub fn load_jinxes_from_directory(dir: impl AsRef<Path>) -> Result<HashMap<String, Jinx>> {
let dir = dir.as_ref();
let mut jinxes = HashMap::new();
if !dir.exists() {
return Ok(jinxes);
}
for entry in WalkDir::new(dir)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "jinx") {
match load_jinx_from_file(path) {
Ok(jinx) => {
let name = if jinx.name.is_empty() {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
} else {
jinx.name.clone()
};
for alias in &jinx.aliases {
jinxes.insert(alias.clone(), jinx.clone());
}
jinxes.insert(name, jinx);
}
Err(e) => {
tracing::warn!("Failed to load jinx {}: {}", path.display(), e);
}
}
}
}
Ok(jinxes)
}
fn strip_jinja2_specifics(raw: &str) -> String {
raw.to_string()
}
pub async fn execute_jinx(
jinx: &Jinx,
input_values: &HashMap<String, String>,
available_jinxes: &HashMap<String, Jinx>,
) -> Result<JinxResult> {
execute_jinx_with_npc(jinx, input_values, available_jinxes, None).await
}
pub async fn execute_jinx_with_npc(
jinx: &Jinx,
input_values: &HashMap<String, String>,
available_jinxes: &HashMap<String, Jinx>,
npc: Option<&NPC>,
) -> Result<JinxResult> {
let mut context: HashMap<String, serde_json::Value> = HashMap::new();
let mut output = String::new();
if let Some(npc) = npc {
context.insert(
"npc".to_string(),
serde_json::json!({
"name": npc.name,
"model": npc.model,
"provider": npc.provider,
"primary_directive": npc.primary_directive,
"api_url": npc.api_url,
"api_key": npc.api_key,
"db_conn": npc.db_conn,
"plain_system_message": npc.plain_system_message,
"npc_directory": npc.npc_directory,
"source_path": npc.source_path,
}),
);
if let Some(ref team) = npc.team {
context.insert("team_path".to_string(), serde_json::json!(team.source_dir));
}
}
for input in &jinx.inputs {
let value = input_values
.get(&input.name)
.cloned()
.or_else(|| input.default.clone())
.unwrap_or_default();
context.insert(input.name.clone(), serde_json::Value::String(value));
}
let needs_tty = jinx_needs_tty(jinx);
for step in &jinx.steps {
let result = if needs_tty {
execute_step_interactive(step, &context, available_jinxes).await
} else {
execute_step(step, &context, available_jinxes).await
};
match result {
Ok(step_output) => {
output = step_output.clone();
context.insert(
step.name.clone(),
serde_json::Value::String(step_output.clone()),
);
context.insert("output".to_string(), serde_json::Value::String(step_output));
}
Err(e) => {
return Ok(JinxResult {
output: format!("Error in step '{}': {}", step.name, e),
context,
success: false,
error: Some(e.to_string()),
});
}
}
}
Ok(JinxResult {
output,
context,
success: true,
error: None,
})
}
fn jinx_needs_tty(jinx: &Jinx) -> bool {
for step in &jinx.steps {
let code = &step.code;
if code.contains("termios")
|| code.contains("tty.setraw")
|| code.contains("curses")
|| code.contains("sys.stdin.isatty")
|| code.contains("select.select")
|| code.contains("getch")
{
return true;
}
}
false
}
async fn execute_step(
step: &JinxStep,
context: &HashMap<String, serde_json::Value>,
available_jinxes: &HashMap<String, Jinx>,
) -> Result<String> {
match step.engine.as_str() {
"rust" => {
let rendered = render_step_template(&step.code, context)?;
execute_rust(&rendered, context).await
}
"python" => execute_python_via_npcpy(&step.code, context).await,
"bash" => {
let rendered = render_step_template(&step.code, context)?;
execute_bash(&rendered).await
}
engine_name => execute_sub_jinx(engine_name, step, context, available_jinxes).await,
}
}
async fn execute_step_interactive(
step: &JinxStep,
context: &HashMap<String, serde_json::Value>,
available_jinxes: &HashMap<String, Jinx>,
) -> Result<String> {
match step.engine.as_str() {
"rust" => {
let rendered = render_step_template(&step.code, context)?;
execute_rust(&rendered, context).await
}
"python" => execute_python_via_npcpy(&step.code, context).await,
"bash" => {
let rendered = render_step_template(&step.code, context)?;
execute_bash_interactive(&rendered).await
}
engine_name => execute_sub_jinx(engine_name, step, context, available_jinxes).await,
}
}
fn render_python_template(code: &str, context: &HashMap<String, serde_json::Value>) -> String {
let re = regex::Regex::new(r"\{\{(.*?)\}\}").unwrap();
re.replace_all(code, |caps: ®ex::Captures| {
let expr = caps[1].trim();
resolve_template_expr(expr, context)
})
.to_string()
}
fn resolve_template_expr(expr: &str, context: &HashMap<String, serde_json::Value>) -> String {
let parts: Vec<&str> = expr.split('|').map(|s| s.trim()).collect();
if parts.is_empty() {
return String::new();
}
let var_name = parts[0];
let mut value = context.get(var_name).cloned();
let mut use_tojson = false;
for filter in &parts[1..] {
if filter.starts_with("default(") {
if value.is_none() || value.as_ref().is_some_and(|v| v.as_str() == Some("")) {
let default_str = filter
.trim_start_matches("default(")
.trim_end_matches(')')
.trim()
.trim_matches('"')
.trim_matches('\'');
value = Some(serde_json::Value::String(default_str.to_string()));
}
} else if *filter == "tojson" {
use_tojson = true;
}
}
match value {
Some(v) => {
if use_tojson {
serde_json::to_string(&v).unwrap_or_else(|_| "null".to_string())
} else {
v.as_str().unwrap_or(&v.to_string()).to_string()
}
}
None => {
if use_tojson {
"null".to_string()
} else {
String::new()
}
}
}
}
async fn execute_sub_jinx(
engine_name: &str,
step: &JinxStep,
context: &HashMap<String, serde_json::Value>,
available_jinxes: &HashMap<String, Jinx>,
) -> Result<String> {
if let Some(sub_jinx) = available_jinxes.get(engine_name) {
let inputs: HashMap<String, String> = context
.iter()
.map(|(k, v)| (k.clone(), v.as_str().unwrap_or(&v.to_string()).to_string()))
.collect();
let result = Box::pin(execute_jinx(sub_jinx, &inputs, available_jinxes)).await?;
if result.success {
Ok(result.output)
} else {
Err(NpcError::JinxExecution {
step: step.name.clone(),
reason: result.error.unwrap_or_default(),
})
}
} else {
Err(NpcError::JinxNotFound {
name: engine_name.to_string(),
})
}
}
fn render_step_template(
template: &str,
context: &HashMap<String, serde_json::Value>,
) -> Result<String> {
let mut tera = Tera::default();
tera.add_raw_template("step", template)?;
let mut ctx = Context::new();
for (key, value) in context {
ctx.insert(key, value);
}
Ok(tera.render("step", &ctx)?)
}
async fn execute_rust(code: &str, context: &HashMap<String, serde_json::Value>) -> Result<String> {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
code.hash(&mut hasher);
let hash = hasher.finish();
let cache_dir = crate::npc_sysenv::get_cache_dir().join("rust_jinxes");
let _ = std::fs::create_dir_all(&cache_dir);
let crate_dir = cache_dir.join(format!("jinx_{:x}", hash));
let bin_path = crate_dir
.join("target")
.join("release")
.join(format!("jinx_{:x}", hash));
let src_path = crate_dir.join("src").join("main.rs");
if !bin_path.exists() {
let _ = std::fs::create_dir_all(crate_dir.join("src"));
let npcrs_version = env!("NPCRS_VERSION");
let npcrs_dep = {
let exe = std::env::current_exe().unwrap_or_default();
let mut dir = exe.parent().unwrap_or(Path::new(".")).to_path_buf();
let mut local_path = None;
loop {
let candidate = dir.join("npcrs").join("Cargo.toml");
if candidate.exists() {
local_path = Some(dir.join("npcrs").to_string_lossy().to_string());
break;
}
if !dir.pop() {
break;
}
}
if let Some(p) = local_path {
format!("npcrs = {{ path = \"{}\" }}", p)
} else {
format!("npcrs = \"{}\"", npcrs_version)
}
};
let cargo_toml = format!(
"[package]\nname = \"jinx_{hash:x}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{npcrs_dep}\nserde_json = \"1\"\ntokio = {{ version = \"1\", features = [\"full\"] }}\n",
);
std::fs::write(crate_dir.join("Cargo.toml"), &cargo_toml).map_err(|e| {
NpcError::JinxExecution {
step: "rust".into(),
reason: format!("Write Cargo.toml: {}", e),
}
})?;
std::fs::write(&src_path, code).map_err(|e| NpcError::JinxExecution {
step: "rust".into(),
reason: format!("Write source: {}", e),
})?;
let compile = Command::new("cargo")
.arg("build")
.arg("--release")
.current_dir(&crate_dir)
.output()
.await
.map_err(|e| NpcError::JinxExecution {
step: "rust".into(),
reason: format!("cargo not found: {}", e),
})?;
if !compile.status.success() {
let stderr = String::from_utf8_lossy(&compile.stderr);
return Err(NpcError::JinxExecution {
step: "rust".into(),
reason: format!("Compilation failed:\n{}", stderr),
});
}
}
let context_json = serde_json::to_string(context).unwrap_or_else(|_| "{}".into());
let output = Command::new(&bin_path)
.env("JINX_CONTEXT", &context_json)
.output()
.await
.map_err(|e| NpcError::JinxExecution {
step: "rust".into(),
reason: format!("Run binary: {}", e),
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
Ok(stdout.to_string())
} else {
Ok(format!("{}{}", stdout, stderr))
}
}
async fn execute_bash(code: &str) -> Result<String> {
let output = Command::new("bash")
.arg("-c")
.arg(code)
.output()
.await
.map_err(|e| NpcError::JinxExecution {
step: "bash".to_string(),
reason: e.to_string(),
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
Ok(stdout.to_string())
} else {
Ok(format!(
"{}{}[exit code: {}]",
stdout,
if stderr.is_empty() {
String::new()
} else {
format!("\nSTDERR: {}", stderr)
},
output.status.code().unwrap_or(-1)
))
}
}
async fn execute_bash_interactive(code: &str) -> Result<String> {
let status = Command::new("bash")
.arg("-c")
.arg(code)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.await
.map_err(|e| NpcError::JinxExecution {
step: "bash".to_string(),
reason: e.to_string(),
})?;
Ok(if status.success() {
String::new()
} else {
format!("[exit code: {}]", status.code().unwrap_or(-1))
})
}
async fn execute_python_via_npcpy(
code: &str,
context: &HashMap<String, serde_json::Value>,
) -> Result<String> {
let ctx_json = serde_json::to_string(context).unwrap_or_else(|_| "{}".into());
let tmp = std::env::temp_dir();
let ctx_file = tmp.join(format!("npcsh_ctx_{}.json", std::process::id()));
let code_file = tmp.join(format!("npcsh_code_{}.py", std::process::id()));
let runner_file = tmp.join(format!("npcsh_run_{}.py", std::process::id()));
std::fs::write(&ctx_file, &ctx_json).map_err(|e| NpcError::JinxExecution {
step: "python".into(),
reason: e.to_string(),
})?;
std::fs::write(&code_file, code).map_err(|e| NpcError::JinxExecution {
step: "python".into(),
reason: e.to_string(),
})?;
let runner = format!(
r#"
import json, sys, os
sys.path.insert(0, os.getcwd())
with open('{}') as f:
context = json.load(f)
os.remove('{}')
code_path = '{}'
with open(code_path) as f:
code = f.read()
os.remove(code_path)
from npcpy.npc_compiler import Jinx
jinx = Jinx.__new__(Jinx)
jinx._execute_step = None
from npcpy.npc_sysenv import get_system_message
npc = None
state = None
try:
from npcsh._state import ShellState
state = ShellState()
_d = context.get('npc', {{}})
if isinstance(_d, dict) and _d.get('name'):
from npcpy.npc_compiler import NPC
from npcpy.npc_sysenv import get_history_db_path
from npcpy.memory.command_history import get_db_connection
_db = _d.get('db_conn') or str(get_history_db_path())
npc = NPC(name=_d['name'], primary_directive=_d.get('primary_directive',''), model=_d.get('model'), provider=_d.get('provider'), api_url=_d.get('api_url'), api_key=_d.get('api_key'), db_conn=get_db_connection(_db))
state.npc = npc
state.chat_model = npc.model or state.chat_model
state.chat_provider = npc.provider or state.chat_provider
_tp = context.get('team_path')
if _tp:
from npcpy.npc_compiler import Team
try:
state.team = Team(team_path=_tp)
except Exception:
pass
except ImportError:
pass
output = ""
try:
exec(code, {{"__builtins__": __builtins__, "context": context, "npc": npc, "state": state, "os": os, "sys": sys, "json": json, "output": output}})
except Exception as e:
context['output'] = f'Error: {{e}}'
output = str(e)
result = context.get('output', output)
if result:
print(result, end='')
"#,
ctx_file.display(),
ctx_file.display(),
code_file.display()
);
std::fs::write(&runner_file, &runner).map_err(|e| NpcError::JinxExecution {
step: "python".into(),
reason: e.to_string(),
})?;
let output = Command::new("python3")
.arg(&runner_file)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.output()
.await
.map_err(|e| NpcError::JinxExecution {
step: "python".into(),
reason: format!("Failed to run Python: {}", e),
})?;
let _ = std::fs::remove_file(&runner_file);
let stdout = String::from_utf8_lossy(&output.stdout);
if output.status.success() {
Ok(stdout.to_string())
} else {
Ok(format!(
"{}[python exit code: {}]",
stdout,
output.status.code().unwrap_or(-1)
))
}
}
#[derive(Debug, Clone)]
pub struct Team {
pub npcs: HashMap<String, NPC>,
pub forenpc: Option<String>,
pub jinxes: HashMap<String, Jinx>,
pub context: Option<String>,
pub model: Option<String>,
pub provider: Option<String>,
pub shared_context: HashMap<String, serde_json::Value>,
pub databases: Vec<String>,
pub mcp_servers: Vec<crate::npc_compiler::McpServerSpec>,
pub source_dir: Option<String>,
}
impl Default for Team {
fn default() -> Self {
Self {
npcs: HashMap::new(),
forenpc: None,
jinxes: HashMap::new(),
context: None,
model: None,
provider: None,
shared_context: HashMap::new(),
databases: Vec::new(),
mcp_servers: Vec::new(),
source_dir: None,
}
}
}
impl Team {
pub fn get_npc(&self, name: &str) -> Option<&NPC> {
self.npcs.get(name)
}
pub fn get_npc_mut(&mut self, name: &str) -> Option<&mut NPC> {
self.npcs.get_mut(name)
}
pub fn lead_npc(&self) -> Option<&NPC> {
self.forenpc
.as_ref()
.and_then(|name| self.npcs.get(name))
.or_else(|| self.npcs.values().next())
}
pub fn npc_names(&self) -> Vec<&str> {
self.npcs.keys().map(|s| s.as_str()).collect()
}
pub fn jinx_names(&self) -> Vec<&str> {
self.jinxes.keys().map(|s| s.as_str()).collect()
}
pub fn get_forenpc(&self) -> Option<&NPC> {
self.lead_npc()
}
pub async fn orchestrate(
&self,
request: &str,
) -> crate::error::Result<HashMap<String, serde_json::Value>> {
let forenpc = self
.get_forenpc()
.ok_or_else(|| crate::error::NpcError::Shell("No forenpc set".into()))?;
let model = forenpc.resolved_model();
let provider = forenpc.resolved_provider();
let team_members: Vec<String> = self
.npcs
.keys()
.filter(|n| Some(n.as_str()) != self.forenpc.as_deref())
.map(|n| format!("@{}", n))
.collect();
let prompt = format!(
"You are the team coordinator. Team members: {}\n\nRequest: {}\n\nDecide how to handle this. Delegate to team members using @name if needed, or answer directly.",
team_members.join(", "),
request
);
let mut messages = Vec::new();
let result = crate::llm_funcs::check_llm_command(
&prompt,
Some(model.as_str()),
Some(provider.as_str()),
Some(forenpc),
&mut messages,
self.context.as_deref(),
&self.jinxes,
3,
)
.await?;
Ok(result)
}
pub fn update_context(&mut self, messages: &[crate::r#gen::Message]) {
let recent: String = messages
.iter()
.rev()
.take(5)
.filter_map(|m| m.content.as_ref().map(|c| format!("{}: {}", m.role, c)))
.collect::<Vec<_>>()
.join("\n");
self.shared_context
.insert("recent_messages".into(), serde_json::json!(recent));
}
pub fn to_dict(&self) -> serde_json::Value {
serde_json::json!({
"forenpc": self.forenpc,
"model": self.model,
"provider": self.provider,
"context": self.context,
"npcs": self.npcs.keys().collect::<Vec<_>>(),
"jinxes": self.jinxes.keys().collect::<Vec<_>>(),
})
}
pub fn save(&self, directory: Option<&str>) -> crate::error::Result<()> {
let dir = directory
.map(std::path::PathBuf::from)
.or_else(|| self.source_dir.as_ref().map(std::path::PathBuf::from))
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join("npc_team"));
let _ = std::fs::create_dir_all(&dir);
for npc in self.npcs.values() {
npc.save(Some(dir.to_str().unwrap_or(".")))?;
}
for jinx in self.jinxes.values() {
let jinx_dir = dir.join("jinxes");
jinx.save(jinx_dir.to_str().unwrap_or("."))?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeamCtx {
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub api_url: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub forenpc: Option<String>,
#[serde(default)]
pub databases: Vec<String>,
#[serde(default)]
pub mcp_servers: Vec<crate::npc_compiler::McpServerSpec>,
#[serde(default)]
pub use_global_jinxes: bool,
#[serde(default)]
pub preferences: Vec<String>,
}
pub fn load_team_from_directory(dir: impl AsRef<Path>) -> Result<Team> {
let dir = dir.as_ref();
let mut team = Team {
source_dir: Some(dir.display().to_string()),
..Default::default()
};
if !dir.exists() {
return Ok(team);
}
if let Some(ctx) = find_and_load_ctx(dir)? {
team.context = ctx.context;
team.model = ctx.model;
team.provider = ctx.provider;
team.forenpc = ctx.forenpc;
team.databases = ctx
.databases
.into_iter()
.map(|d| shellexpand::tilde(&d).to_string())
.collect();
team.mcp_servers = ctx.mcp_servers;
}
for entry in WalkDir::new(dir)
.max_depth(1)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "npc") {
match load_npc_from_file(path) {
Ok(mut loaded_npc) => {
if loaded_npc.model.is_none() {
loaded_npc.model = team.model.clone();
}
if loaded_npc.provider.is_none() {
loaded_npc.provider = team.provider.clone();
}
team.npcs.insert(loaded_npc.name.clone(), loaded_npc);
}
Err(e) => {
tracing::warn!("Failed to load NPC {}: {}", path.display(), e);
}
}
}
}
let jinxes_dir = dir.join("jinxes");
if jinxes_dir.exists() {
team.jinxes = load_jinxes_from_directory(&jinxes_dir)?;
}
let legacy_dir = dir.join("jinxs");
if legacy_dir.exists() && !jinxes_dir.exists() {
team.jinxes = load_jinxes_from_directory(&legacy_dir)?;
}
let project_root = dir.parent().unwrap_or(dir);
let agents_md = project_root.join("agents.md");
if agents_md.exists() {
if let Ok(content) = std::fs::read_to_string(&agents_md) {
load_agents_from_md(&content, &team.model, &team.provider, &mut team.npcs);
}
}
let agents_dir = project_root.join("agents");
if agents_dir.is_dir() {
load_agents_from_dir(&agents_dir, &team.model, &team.provider, &mut team.npcs);
}
for npc in team.npcs.values_mut() {
if npc.jinx_names.iter().any(|n| n == "*") {
npc.jinx_names = team.jinxes.keys().cloned().collect();
}
}
tracing::info!(
"Loaded team from {}: {} NPCs, {} jinxes, forenpc={:?}",
dir.display(),
team.npcs.len(),
team.jinxes.len(),
team.forenpc
);
Ok(team)
}
fn find_and_load_ctx(dir: &Path) -> Result<Option<TeamCtx>> {
let candidates = ["team.ctx", "npcsh.ctx"];
for name in &candidates {
let path = dir.join(name);
if path.exists() {
return load_ctx_file(&path).map(Some);
}
}
for entry in std::fs::read_dir(dir).map_err(|e| NpcError::FileLoad {
path: dir.display().to_string(),
source: e,
})? {
let entry = entry.map_err(|e| NpcError::FileLoad {
path: dir.display().to_string(),
source: e,
})?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "ctx") {
return load_ctx_file(&path).map(Some);
}
}
Ok(None)
}
fn load_agents_from_md(
content: &str,
team_model: &Option<String>,
team_provider: &Option<String>,
npcs: &mut std::collections::HashMap<String, crate::npc_compiler::NPC>,
) {
let mut current_name: Option<String> = None;
let mut current_body: Vec<String> = Vec::new();
for line in content.lines() {
if let Some(name) = line.strip_prefix("## ") {
if let Some(prev_name) = current_name.take() {
if !npcs.contains_key(&prev_name) {
let mut npc =
crate::npc_compiler::NPC::new(&prev_name, current_body.join("\n").trim());
npc.model = team_model.clone();
npc.provider = team_provider.clone();
npcs.insert(prev_name, npc);
}
}
current_name = Some(name.trim().to_string());
current_body.clear();
} else if current_name.is_some() {
current_body.push(line.to_string());
}
}
if let Some(name) = current_name {
if !npcs.contains_key(&name) {
let mut npc = crate::npc_compiler::NPC::new(&name, current_body.join("\n").trim());
npc.model = team_model.clone();
npc.provider = team_provider.clone();
npcs.insert(name, npc);
}
}
}
fn load_agents_from_dir(
dir: &Path,
team_model: &Option<String>,
team_provider: &Option<String>,
npcs: &mut std::collections::HashMap<String, crate::npc_compiler::NPC>,
) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if name.is_empty() || npcs.contains_key(&name) {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let mut model = team_model.clone();
let mut provider = team_provider.clone();
let mut agent_name = name.clone();
let directive;
if content.starts_with("---") {
let parts: Vec<&str> = content.splitn(3, "---").collect();
if parts.len() >= 3 {
if let Ok(fm) = serde_yaml::from_str::<serde_yaml::Value>(parts[1]) {
if let Some(m) = fm.get("model").and_then(|v| v.as_str()) {
model = Some(m.to_string());
}
if let Some(p) = fm.get("provider").and_then(|v| v.as_str()) {
provider = Some(p.to_string());
}
if let Some(n) = fm.get("name").and_then(|v| v.as_str()) {
agent_name = n.to_string();
}
}
directive = parts[2].trim().to_string();
} else {
directive = content;
}
} else {
directive = content;
}
let mut npc = crate::npc_compiler::NPC::new(&agent_name, &directive);
npc.model = model;
npc.provider = provider;
npcs.insert(agent_name, npc);
}
}
fn load_ctx_file(path: &Path) -> Result<TeamCtx> {
let raw = std::fs::read_to_string(path).map_err(|e| NpcError::FileLoad {
path: path.display().to_string(),
source: e,
})?;
serde_yaml::from_str(&raw).map_err(|e| NpcError::YamlParse {
path: path.display().to_string(),
source: e,
})
}
pub struct Agent {
pub npc: NPC,
pub messages: Vec<Message>,
pub tool_registry: ToolRegistry,
}
impl Agent {
pub fn new(npc: NPC) -> Self {
let mut registry = ToolRegistry::new();
register_default_tools(&mut registry);
Self {
npc,
messages: Vec::new(),
tool_registry: registry,
}
}
pub fn with_name_and_directive(name: &str, directive: &str) -> Self {
Self::new(NPC::new(name, directive))
}
pub async fn run(&mut self, input: &str) -> Result<String> {
let system = self.npc.system_prompt(None);
let mut msgs = vec![Message::system(system)];
msgs.extend(self.messages.clone());
msgs.push(Message::user(input));
let tool_defs = self.tool_registry.tool_defs();
let tools = if tool_defs.is_empty() {
None
} else {
Some(tool_defs.as_slice())
};
let model = self.npc.resolved_model();
let provider = self.npc.resolved_provider();
let mut final_output = String::new();
for _ in 0..10 {
let response = crate::r#gen::get_genai_response(
&provider,
&model,
&msgs,
tools,
self.npc.api_url.as_deref(),
None,
None,
false,
None,
)
.await?;
if let Some(ref tool_calls) = response.message.tool_calls {
msgs.push(response.message.clone());
let results = self.tool_registry.process_tool_calls(tool_calls).await;
msgs.extend(results);
} else {
final_output = response.message.content.clone().unwrap_or_default();
break;
}
}
self.messages.push(Message::user(input));
self.messages.push(Message::assistant(&final_output));
Ok(final_output)
}
}
pub struct ToolAgent {
pub agent: Agent,
}
impl ToolAgent {
pub fn new(npc: NPC, extra_tools: Vec<RegisteredTool>) -> Self {
let mut agent = Agent::new(npc);
for tool in extra_tools {
agent.tool_registry.register(tool);
}
Self { agent }
}
pub async fn run(&mut self, input: &str) -> Result<String> {
self.agent.run(input).await
}
}
pub struct CodingAgent {
pub agent: Agent,
pub language: String,
pub auto_execute: bool,
}
impl CodingAgent {
pub fn new(npc: NPC, language: impl Into<String>) -> Self {
Self {
agent: Agent::new(npc),
language: language.into(),
auto_execute: true,
}
}
pub fn extract_code_blocks(&self, text: &str) -> Vec<String> {
let pattern = format!(
r"```(?i:{})\s*\n([\s\S]*?)```",
regex::escape(&self.language)
);
let re = regex::Regex::new(&pattern)
.unwrap_or_else(|_| regex::Regex::new(r"```\w*\s*\n([\s\S]*?)```").unwrap());
re.captures_iter(text)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().trim().to_string()))
.collect()
}
pub async fn execute_code(&self, code: &str) -> String {
let (cmd, args): (&str, Vec<&str>) = match self.language.as_str() {
"python" => ("python3", vec!["-c", code]),
"bash" | "sh" => ("bash", vec!["-c", code]),
"javascript" | "js" => ("node", vec!["-e", code]),
_ => return format!("Execution not supported for: {}", self.language),
};
match tokio::process::Command::new(cmd).args(&args).output().await {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if out.status.success() {
stdout.to_string()
} else {
format!("{}\nSTDERR: {}", stdout, stderr)
}
}
Err(e) => format!("Execution error: {}", e),
}
}
pub async fn run(&mut self, input: &str) -> Result<String> {
let mut current_input = input.to_string();
let mut last_response = String::new();
for _ in 0..5 {
last_response = self.agent.run(¤t_input).await?;
if !self.auto_execute {
return Ok(last_response);
}
let blocks = self.extract_code_blocks(&last_response);
if blocks.is_empty() {
return Ok(last_response);
}
let mut results = Vec::new();
for (i, code) in blocks.iter().enumerate() {
let output = self.execute_code(code).await;
results.push(format!("[Block {} output]:\n{}", i + 1, output));
}
current_input = format!("Code execution results:\n{}", results.join("\n\n"));
}
Ok(last_response)
}
}
fn register_default_tools(registry: &mut ToolRegistry) {
registry.register(
ToolBuilder::new("sh")
.description("Execute a bash/shell command and return output")
.param("bash_command", "string", "The command to execute", true)
.build(Box::new(|args| {
Box::pin(async move {
let cmd = args
.get("bash_command")
.and_then(|v| v.as_str())
.unwrap_or("");
if cmd.is_empty() {
return Ok("(no command provided)".to_string());
}
match tokio::process::Command::new("bash")
.arg("-c")
.arg(cmd)
.output()
.await
{
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if !out.status.success() && !stderr.is_empty() {
Ok(format!(
"Error (exit {}):\n{}",
out.status.code().unwrap_or(-1),
stderr
))
} else if stdout.trim().is_empty() {
Ok("(no output)".to_string())
} else {
Ok(stdout.to_string())
}
}
Err(e) => Ok(format!("Failed: {}", e)),
}
})
})),
);
registry.register(
ToolBuilder::new("python")
.description("Execute Python code and return output")
.param("code", "string", "Python code to execute", true)
.build(Box::new(|args| {
Box::pin(async move {
let code = args.get("code").and_then(|v| v.as_str()).unwrap_or("");
if code.is_empty() {
return Ok("(no code provided)".to_string());
}
match tokio::process::Command::new("python3")
.arg("-c")
.arg(code)
.output()
.await
{
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
Ok(if stdout.trim().is_empty() && !stderr.is_empty() {
format!("Python error:\n{}", stderr)
} else {
stdout.to_string()
})
}
Err(e) => Ok(format!("Failed: {}", e)),
}
})
})),
);
registry.register(
ToolBuilder::new("edit_file")
.description("Edit a file: create, append, or replace text")
.param("path", "string", "File path", true)
.param(
"action",
"string",
"Action: create, write, append, replace",
false,
)
.param(
"new_text",
"string",
"Text to write/append/replace with",
false,
)
.param("old_text", "string", "Text to find (for replace)", false)
.build(Box::new(|args| {
Box::pin(async move {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
let path = shellexpand::tilde(path).to_string();
let action = args
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("create");
let new_text = args
.get("new_text")
.or(args.get("content"))
.and_then(|v| v.as_str())
.unwrap_or("");
let old_text = args.get("old_text").and_then(|v| v.as_str()).unwrap_or("");
match action {
"create" | "write" => match std::fs::write(&path, new_text) {
Ok(_) => Ok(format!("Created {} ({} bytes)", path, new_text.len())),
Err(e) => Ok(format!("Error: {}", e)),
},
"append" => {
match std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&path)
{
Ok(mut f) => {
let _ = f.write_all(new_text.as_bytes());
Ok(format!("Appended to {}", path))
}
Err(e) => Ok(format!("Error: {}", e)),
}
}
"replace" => match std::fs::read_to_string(&path) {
Ok(content) => {
let updated = content.replace(old_text, new_text);
match std::fs::write(&path, &updated) {
Ok(_) => Ok(format!("Replaced in {}", path)),
Err(e) => Ok(format!("Error: {}", e)),
}
}
Err(e) => Ok(format!("Error: {}", e)),
},
_ => Ok(format!("Unknown action: {}", action)),
}
})
})),
);
registry.register(
ToolBuilder::new("load_file")
.description("Read and return file contents")
.param("path", "string", "File path to read", true)
.build(Box::new(|args| {
Box::pin(async move {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
let path = shellexpand::tilde(path).to_string();
match std::fs::read_to_string(&path) {
Ok(content) => {
let lines = content.lines().count();
if content.len() > 10000 {
Ok(format!(
"File: {} ({} lines)\n---\n{}...[truncated]",
path,
lines,
&content[..10000]
))
} else {
Ok(format!(
"File: {} ({} lines)\n---\n{}",
path, lines, content
))
}
}
Err(e) => Ok(format!("Error: {}", e)),
}
})
})),
);
registry.register(
ToolBuilder::new("web_search")
.description("Search the web")
.param("query", "string", "Search query", true)
.build(Box::new(|args| {
Box::pin(async move {
let query = args
.get("query")
.or(args.get("search_query"))
.and_then(|v| v.as_str())
.unwrap_or("");
let cmd = format!(
"curl -sL 'https://lite.duckduckgo.com/lite/?q={}' | head -100",
query.replace(' ', "+")
);
match tokio::process::Command::new("bash")
.arg("-c")
.arg(&cmd)
.output()
.await
{
Ok(out) => Ok(String::from_utf8_lossy(&out.stdout).to_string()),
Err(e) => Ok(format!("Search failed: {}", e)),
}
})
})),
);
registry.register(
ToolBuilder::new("file_search")
.description("Search for files containing a pattern")
.param("query", "string", "Text to search for", true)
.param("path", "string", "Directory to search in", false)
.build(Box::new(|args| {
Box::pin(async move {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let cmd = format!(
"grep -rn --include='*.{{py,rs,js,ts,md,txt,yaml,yml,toml,json}}' -l '{}' '{}' | head -20",
query.replace('\'', ""), path
);
match tokio::process::Command::new("bash")
.arg("-c")
.arg(&cmd)
.output()
.await
{
Ok(out) => Ok(String::from_utf8_lossy(&out.stdout).to_string()),
Err(e) => Ok(format!("Error: {}", e)),
}
})
})),
);
registry.register(
ToolBuilder::new("stop")
.description("Signal that the task is complete")
.param("reason", "string", "Reason for stopping", false)
.build(Box::new(|args| {
Box::pin(async move {
let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or("");
Ok(if reason.is_empty() {
"STOP".to_string()
} else {
format!("STOP: {}", reason)
})
})
})),
);
registry.register(
ToolBuilder::new("chat")
.description("Respond directly to the user")
.param("message", "string", "Message to send", true)
.build(Box::new(|args| {
Box::pin(async move {
Ok(args
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string())
})
})),
);
}