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