1use anyhow::{bail, Context, Result};
54use async_trait::async_trait;
55use mlua::prelude::*;
56use std::path::{Path, PathBuf};
57use std::sync::Arc;
58use std::time::{Duration, Instant};
59
60use crate::agents::{Agent, AgentArgument, AgentPrompt, PromptMessage};
61use crate::config::{Config, ScriptAgentConfig};
62use crate::get::get_document;
63use crate::lua_runtime::{json_value_to_lua, register_all_host_apis, toml_table_to_lua};
64use crate::search::search_documents;
65use crate::sources::get_sources;
66use crate::traits::ToolContext;
67
68#[derive(Debug, Clone)]
77pub struct AgentDefinition {
78 pub name: String,
80 pub description: String,
82 pub tools: Vec<String>,
84 pub arguments: Vec<AgentArgument>,
86 pub script_path: PathBuf,
88 pub script_source: String,
90 pub config: toml::Table,
92 pub timeout: u64,
94}
95
96pub struct LuaAgentAdapter {
105 definition: AgentDefinition,
107 config: Arc<Config>,
109}
110
111impl LuaAgentAdapter {
112 pub fn new(definition: AgentDefinition, config: Arc<Config>) -> Self {
114 Self { definition, config }
115 }
116}
117
118#[async_trait]
119impl Agent for LuaAgentAdapter {
120 fn name(&self) -> &str {
121 &self.definition.name
122 }
123
124 fn description(&self) -> &str {
125 &self.definition.description
126 }
127
128 fn tools(&self) -> Vec<String> {
129 self.definition.tools.clone()
130 }
131
132 fn source(&self) -> &str {
133 "lua"
134 }
135
136 fn arguments(&self) -> Vec<AgentArgument> {
137 self.definition.arguments.clone()
138 }
139
140 async fn resolve(&self, args: serde_json::Value, _ctx: &ToolContext) -> Result<AgentPrompt> {
141 resolve_agent(&self.definition, args, &self.config).await
142 }
143}
144
145pub fn load_agent_definitions(config: &Config) -> Result<Vec<AgentDefinition>> {
157 let mut agents = Vec::new();
158
159 for (name, agent_config) in &config.agents.script {
160 let agent_def = load_single_agent(name, agent_config)
161 .with_context(|| format!("Failed to load agent script '{}'", name))?;
162 agents.push(agent_def);
163 }
164
165 Ok(agents)
166}
167
168pub fn load_single_agent(name: &str, agent_config: &ScriptAgentConfig) -> Result<AgentDefinition> {
170 let script_src = std::fs::read_to_string(&agent_config.path).with_context(|| {
171 format!(
172 "Failed to read agent script: {}",
173 agent_config.path.display()
174 )
175 })?;
176
177 let lua = Lua::new();
179 lua.load(&script_src)
180 .set_name(agent_config.path.to_string_lossy())
181 .exec()
182 .map_err(|e| {
183 anyhow::anyhow!(
184 "Failed to execute agent script {}: {}",
185 agent_config.path.display(),
186 e
187 )
188 })?;
189
190 let agent_table: LuaTable = lua
191 .globals()
192 .get::<LuaTable>("agent")
193 .map_err(|e| anyhow::anyhow!("Script must define a global 'agent' table: {}", e))?;
194
195 let description: String = agent_table
196 .get::<String>("description")
197 .unwrap_or_else(|_| format!("Lua agent: {}", name));
198
199 let tools = extract_string_list(&agent_table, "tools")?;
201
202 let arguments = extract_arguments(&agent_table)?;
204
205 Ok(AgentDefinition {
206 name: name.to_string(),
207 description,
208 tools,
209 arguments,
210 script_path: agent_config.path.clone(),
211 script_source: script_src,
212 config: agent_config.extra.clone(),
213 timeout: agent_config.timeout,
214 })
215}
216
217fn extract_string_list(table: &LuaTable, key: &str) -> Result<Vec<String>> {
219 let mut result = Vec::new();
220 if let Ok(list) = table.get::<LuaTable>(key) {
221 let len = list.raw_len();
222 for i in 1..=len {
223 if let Ok(s) = list.raw_get::<String>(i) {
224 result.push(s);
225 }
226 }
227 }
228 Ok(result)
229}
230
231fn extract_arguments(table: &LuaTable) -> Result<Vec<AgentArgument>> {
233 let mut result = Vec::new();
234 if let Ok(args_table) = table.get::<LuaTable>("arguments") {
235 let len = args_table.raw_len();
236 for i in 1..=len {
237 if let Ok(arg) = args_table.raw_get::<LuaTable>(i) {
238 let name: String = arg.get::<String>("name").map_err(|e| {
239 anyhow::anyhow!("Argument at index {} missing 'name': {}", i, e)
240 })?;
241 let description: String = arg.get::<String>("description").unwrap_or_default();
242 let required: bool = arg.get::<bool>("required").unwrap_or(false);
243 result.push(AgentArgument {
244 name,
245 description,
246 required,
247 });
248 }
249 }
250 }
251 Ok(result)
252}
253
254pub async fn resolve_agent(
263 agent: &AgentDefinition,
264 args: serde_json::Value,
265 app_config: &Config,
266) -> Result<AgentPrompt> {
267 let agent = agent.clone();
268 let config = app_config.clone();
269
270 tokio::task::spawn_blocking(move || run_lua_agent(&agent, args, &config))
271 .await
272 .context("Lua agent task panicked")?
273}
274
275fn run_lua_agent(
277 agent: &AgentDefinition,
278 args: serde_json::Value,
279 config: &Config,
280) -> Result<AgentPrompt> {
281 let script_dir = agent
282 .script_path
283 .parent()
284 .unwrap_or(Path::new("."))
285 .to_path_buf();
286
287 let lua = Lua::new();
288
289 let timeout_secs = agent.timeout;
291 let deadline = Instant::now() + Duration::from_secs(timeout_secs);
292 lua.set_hook(
293 mlua::HookTriggers::new().every_nth_instruction(10_000),
294 move |_lua, _debug| {
295 if Instant::now() > deadline {
296 Err(mlua::Error::RuntimeError(format!(
297 "agent timed out after {} seconds",
298 timeout_secs
299 )))
300 } else {
301 Ok(mlua::VmState::Continue)
302 }
303 },
304 );
305
306 let log_name = format!("agent:{}", agent.name);
308 register_all_host_apis(&lua, &log_name, &script_dir)?;
309
310 register_agent_context_bridge(&lua, config)?;
312
313 lua.load(&agent.script_source)
315 .set_name(agent.script_path.to_string_lossy())
316 .exec()
317 .map_err(|e| {
318 anyhow::anyhow!(
319 "Failed to execute agent script {}: {}",
320 agent.script_path.display(),
321 e
322 )
323 })?;
324
325 let agent_table: LuaTable = lua
327 .globals()
328 .get::<LuaTable>("agent")
329 .map_err(|e| anyhow::anyhow!("Script must define a global 'agent' table: {}", e))?;
330
331 let resolve_fn: LuaFunction = agent_table
332 .get::<LuaFunction>("resolve")
333 .map_err(|e| anyhow::anyhow!("agent.resolve function not defined: {}", e))?;
334
335 let args_lua = json_value_to_lua(&lua, &args)?;
337
338 let config_lua = toml_table_to_lua(&lua, &agent.config)?;
340
341 let context: LuaTable = lua
343 .globals()
344 .get::<LuaTable>("context")
345 .map_err(|e| anyhow::anyhow!("context table missing: {}", e))?;
346
347 let result: LuaValue = resolve_fn
349 .call::<LuaValue>((args_lua, config_lua, context))
350 .map_err(|e| {
351 anyhow::anyhow!(
352 "agent.resolve() failed in '{}': {}",
353 agent.script_path.display(),
354 e
355 )
356 })?;
357
358 lua_result_to_agent_prompt(result)
360}
361
362fn lua_result_to_agent_prompt(value: LuaValue) -> Result<AgentPrompt> {
374 match value {
375 LuaValue::Table(table) => {
376 let system: String = table.get::<String>("system").map_err(|_| {
377 anyhow::anyhow!("agent.resolve() must return a table with 'system' field")
378 })?;
379
380 let tools = if let Ok(tools_table) = table.get::<LuaTable>("tools") {
382 let mut result = Vec::new();
383 let len = tools_table.raw_len();
384 for i in 1..=len {
385 if let Ok(s) = tools_table.raw_get::<String>(i) {
386 result.push(s);
387 }
388 }
389 result
390 } else {
391 vec![]
392 };
393
394 let messages = if let Ok(msgs_table) = table.get::<LuaTable>("messages") {
396 let mut result = Vec::new();
397 let len = msgs_table.raw_len();
398 for i in 1..=len {
399 if let Ok(msg) = msgs_table.raw_get::<LuaTable>(i) {
400 let role: String = msg
401 .get::<String>("role")
402 .unwrap_or_else(|_| "assistant".to_string());
403 let content: String = msg.get::<String>("content").unwrap_or_default();
404 result.push(PromptMessage { role, content });
405 }
406 }
407 result
408 } else {
409 vec![]
410 };
411
412 Ok(AgentPrompt {
413 system,
414 tools,
415 messages,
416 })
417 }
418 _ => {
419 anyhow::bail!(
420 "agent.resolve() must return a table, got {:?}",
421 value.type_name()
422 );
423 }
424 }
425}
426
427fn register_agent_context_bridge(lua: &Lua, config: &Config) -> LuaResult<()> {
436 let ctx = lua.create_table()?;
437
438 let cfg = config.clone();
440 ctx.set(
441 "search",
442 lua.create_function(move |lua, (query, opts): (String, Option<LuaTable>)| {
443 let mode = opts
444 .as_ref()
445 .and_then(|o| o.get::<String>("mode").ok())
446 .unwrap_or_else(|| "keyword".to_string());
447 let limit = opts
448 .as_ref()
449 .and_then(|o| o.get::<i64>("limit").ok())
450 .unwrap_or(12);
451 let source = opts.as_ref().and_then(|o| o.get::<String>("source").ok());
452
453 let handle = tokio::runtime::Handle::current();
454 let results = handle
455 .block_on(async {
456 search_documents(
457 &cfg,
458 &query,
459 &mode,
460 source.as_deref(),
461 None,
462 Some(limit),
463 false,
464 )
465 .await
466 })
467 .map_err(mlua::Error::external)?;
468
469 search_results_to_lua(lua, &results)
470 })?,
471 )?;
472
473 let cfg = config.clone();
475 ctx.set(
476 "get",
477 lua.create_function(move |lua, id: String| {
478 let handle = tokio::runtime::Handle::current();
479 let doc = handle
480 .block_on(async { get_document(&cfg, &id).await })
481 .map_err(mlua::Error::external)?;
482
483 doc_response_to_lua(lua, &doc)
484 })?,
485 )?;
486
487 let cfg = config.clone();
489 ctx.set(
490 "sources",
491 lua.create_function(move |lua, ()| {
492 let sources = get_sources(&cfg);
493 sources_to_lua(lua, &sources)
494 })?,
495 )?;
496
497 lua.globals().set("context", ctx)?;
498 Ok(())
499}
500
501fn search_results_to_lua(
503 lua: &Lua,
504 results: &[crate::search::SearchResultItem],
505) -> LuaResult<LuaTable> {
506 let table = lua.create_table()?;
507 for (i, item) in results.iter().enumerate() {
508 let row = lua.create_table()?;
509 row.set("id", item.id.as_str())?;
510 row.set("score", item.score)?;
511 row.set("source", item.source.as_str())?;
512 row.set("source_id", item.source_id.as_str())?;
513 row.set("updated_at", item.updated_at.as_str())?;
514 row.set("snippet", item.snippet.as_str())?;
515 if let Some(ref title) = item.title {
516 row.set("title", title.as_str())?;
517 }
518 if let Some(ref url) = item.source_url {
519 row.set("source_url", url.as_str())?;
520 }
521 table.set(i as i64 + 1, row)?;
522 }
523 Ok(table)
524}
525
526fn doc_response_to_lua(lua: &Lua, doc: &crate::get::DocumentResponse) -> LuaResult<LuaTable> {
528 let table = lua.create_table()?;
529 table.set("id", doc.id.as_str())?;
530 table.set("source", doc.source.as_str())?;
531 table.set("source_id", doc.source_id.as_str())?;
532 table.set("content_type", doc.content_type.as_str())?;
533 table.set("body", doc.body.as_str())?;
534 table.set("created_at", doc.created_at.as_str())?;
535 table.set("updated_at", doc.updated_at.as_str())?;
536 if let Some(ref title) = doc.title {
537 table.set("title", title.as_str())?;
538 }
539 if let Some(ref author) = doc.author {
540 table.set("author", author.as_str())?;
541 }
542 if let Some(ref url) = doc.source_url {
543 table.set("source_url", url.as_str())?;
544 }
545
546 let chunks_table = lua.create_table()?;
547 for (i, chunk) in doc.chunks.iter().enumerate() {
548 let c = lua.create_table()?;
549 c.set("index", chunk.index)?;
550 c.set("text", chunk.text.as_str())?;
551 chunks_table.set(i as i64 + 1, c)?;
552 }
553 table.set("chunks", chunks_table)?;
554
555 Ok(table)
556}
557
558fn sources_to_lua(lua: &Lua, sources: &[crate::sources::SourceStatus]) -> LuaResult<LuaTable> {
560 let table = lua.create_table()?;
561 for (i, s) in sources.iter().enumerate() {
562 let row = lua.create_table()?;
563 row.set("name", s.name.as_str())?;
564 row.set("configured", s.configured)?;
565 row.set("healthy", s.healthy)?;
566 if let Some(ref notes) = s.notes {
567 row.set("notes", notes.as_str())?;
568 }
569 table.set(i as i64 + 1, row)?;
570 }
571 Ok(table)
572}
573
574pub fn scaffold_agent(name: &str) -> Result<()> {
583 let dir = Path::new("agents");
584 std::fs::create_dir_all(dir)?;
585
586 let filename = format!("{}.lua", name.replace('_', "-"));
587 let path = dir.join(&filename);
588
589 if path.exists() {
590 bail!("Agent script already exists: {}", path.display());
591 }
592
593 let template = format!(
594 r#"--[[
595 Context Harness Agent: {name}
596
597 Configuration (add to ctx.toml):
598
599 [agents.script.{name}]
600 path = "agents/{filename}"
601 timeout = 30
602 # search_limit = 5
603
604 Test:
605 ctx agent test {name} --arg key=value
606]]
607
608agent = {{
609 name = "{name}",
610 description = "TODO: describe what this agent does",
611 tools = {{ "search", "get" }},
612 arguments = {{
613 {{
614 name = "topic",
615 description = "Focus area for this session",
616 required = false,
617 }},
618 }},
619}}
620
621--- Resolve the agent's prompt for a conversation.
622--- @param args table User-provided argument values
623--- @param config table Agent-specific config from ctx.toml
624--- @param context table Bridge to Context Harness (search, get, sources)
625--- @return table Resolved prompt with system, tools, and messages
626function agent.resolve(args, config, context)
627 local topic = args.topic or "general"
628
629 -- Example: pre-search for relevant context
630 -- local results = context.search(topic, {{ mode = "keyword", limit = 5 }})
631 -- local context_text = ""
632 -- for _, r in ipairs(results) do
633 -- local doc = context.get(r.id)
634 -- context_text = context_text .. "\n---\n" .. doc.body
635 -- end
636
637 return {{
638 system = string.format([[
639You are a helpful assistant focused on %s.
640
641Use the search tool to find relevant documentation and ground
642your responses in the indexed knowledge base.
643 ]], topic),
644 messages = {{
645 {{
646 role = "assistant",
647 content = string.format(
648 "I'm ready to help with %s. What would you like to know?",
649 topic
650 ),
651 }},
652 }},
653 }}
654end
655
656return agent
657"#,
658 name = name,
659 filename = filename,
660 );
661
662 std::fs::write(&path, template)?;
663 println!("Created agent: {}", path.display());
664 println!();
665 println!("Add to your ctx.toml:");
666 println!();
667 println!(" [agents.script.{}]", name);
668 println!(" path = \"agents/{}\"", filename);
669 println!();
670 println!("Then test:");
671 println!();
672 println!(" ctx agent test {} --arg topic=\"deployment\"", name);
673
674 Ok(())
675}
676
677pub async fn test_agent(name: &str, args: Vec<(String, String)>, config: &Config) -> Result<()> {
682 let agent_config = config
683 .agents
684 .script
685 .get(name)
686 .ok_or_else(|| anyhow::anyhow!("Agent '{}' not found in config", name))?;
687
688 let agent_def = load_single_agent(name, agent_config)?;
689
690 println!("Agent: {}", agent_def.name);
691 println!("Source: lua ({})", agent_def.script_path.display());
692 println!(
693 "Tools: {}",
694 if agent_def.tools.is_empty() {
695 "(none defined)".to_string()
696 } else {
697 agent_def.tools.join(", ")
698 }
699 );
700 println!();
701
702 let mut args_json = serde_json::Map::new();
704 for (k, v) in &args {
705 let json_val = serde_json::from_str::<serde_json::Value>(v)
706 .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
707 args_json.insert(k.clone(), json_val);
708 }
709 let args_value = serde_json::Value::Object(args_json);
710
711 let start = Instant::now();
712 let prompt = resolve_agent(&agent_def, args_value, config).await?;
713 let elapsed = start.elapsed();
714
715 println!("System prompt ({} chars):", prompt.system.len());
716 for line in prompt.system.lines() {
717 println!(" {}", line);
718 }
719
720 if !prompt.tools.is_empty() {
721 println!();
722 println!("Tools override: {}", prompt.tools.join(", "));
723 }
724
725 if !prompt.messages.is_empty() {
726 println!();
727 println!("Messages ({}):", prompt.messages.len());
728 for msg in &prompt.messages {
729 println!(" [{}] {}", msg.role, msg.content);
730 }
731 }
732
733 println!();
734 println!("Resolved in {:.0?}", elapsed);
735
736 Ok(())
737}
738
739pub fn list_agents(config: &Config) -> Result<()> {
741 let mut count = 0;
742
743 println!("{:<24} {:<8} {:<44} TOOLS", "AGENT", "TYPE", "DESCRIPTION");
744
745 for (name, cfg) in &config.agents.inline {
747 println!(
748 "{:<24} {:<8} {:<44} {}",
749 name,
750 "toml",
751 truncate(&cfg.description, 44),
752 cfg.tools.join(", ")
753 );
754 count += 1;
755 }
756
757 let lua_defs = load_agent_definitions(config)?;
759 for def in &lua_defs {
760 println!(
761 "{:<24} {:<8} {:<44} {}",
762 def.name,
763 "lua",
764 truncate(&def.description, 44),
765 def.tools.join(", ")
766 );
767 count += 1;
768 }
769
770 if count == 0 {
771 println!("No agents configured.");
772 }
773
774 Ok(())
775}
776
777fn truncate(s: &str, max: usize) -> String {
779 if s.len() <= max {
780 s.to_string()
781 } else {
782 format!("{}...", &s[..max.saturating_sub(3)])
783 }
784}