refactor: unify BASIC compilation into BasicCompiler only, runtime uses ScriptService::run() on pre-compiled .ast
Some checks failed
BotServer CI/CD / build (push) Has been cancelled

- Move all preprocessing transforms (convert_multiword_keywords, preprocess_llm_keyword,
  convert_while_wend_syntax, predeclare_variables) into BasicCompiler::preprocess_basic
  so .ast files are fully preprocessed by Drive Monitor
- Replace ScriptService compile/compile_preprocessed/compile_tool_script with
  single run(ast_content) that does engine.compile() + eval_ast_with_scope()
- Remove .bas fallback in tool_executor and start.bas paths - .ast only
- Remove dead code: preprocess_basic_script, normalize_variables_to_lowercase,
  convert_save_for_tools, parse_save_parts, normalize_word
- Fix: USE KB 'cartas' in tool .ast now correctly converted to USE_KB('cartas')
  during compilation, ensuring KB context injection works after tool execution
- Fix: add trace import in llm/mod.rs
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-04-13 14:05:55 -03:00
parent 723407cfd6
commit f8b47d1ac2
8 changed files with 152 additions and 931 deletions

View file

@ -721,8 +721,8 @@ END ON
context_vars.insert("original_intent".to_string(), compiled.original_intent.clone());
script_service.inject_config_variables(context_vars);
// Compile and execute the BASIC program
let ast = match script_service.compile(basic_program) {
// Compile and execute dynamically generated BASIC program
let ast = match script_service.engine.compile(basic_program) {
Ok(ast) => ast,
Err(e) => {
let error_msg = format!("Failed to compile BASIC program: {}", e);
@ -746,7 +746,7 @@ END ON
}
};
let execution_result = script_service.run(&ast);
let execution_result: Result<rhai::Dynamic, Box<rhai::EvalAltResult>> = script_service.engine.eval_ast_with_scope(&mut script_service.scope, &ast);
match execution_result {
Ok(result) => {

View file

@ -448,6 +448,9 @@ impl BasicCompiler {
source.to_string()
};
let source = source.as_str();
// Preprocess LLM keyword to add WITH OPTIMIZE FOR "speed" syntax
let source = crate::basic::ScriptService::preprocess_llm_keyword(source);
let mut has_schedule = false;
let script_name = Path::new(source_path)
.file_stem()
@ -615,6 +618,12 @@ impl BasicCompiler {
};
// Convert BEGIN TALK and BEGIN MAIL blocks to Rhai code
let result = crate::basic::compiler::blocks::convert_begin_blocks(&result);
// Convert ALL multi-word keywords to underscore versions (e.g., "USE KB" → "USE_KB")
let result = crate::basic::ScriptService::convert_multiword_keywords(&result);
// Convert WHILE...WEND to Rhai while { } blocks BEFORE if/then conversion
let result = crate::basic::ScriptService::convert_while_wend_syntax(&result);
// Pre-declare all variables at outer scope so assignments inside blocks work correctly
let result = crate::basic::ScriptService::predeclare_variables(&result);
// Convert IF ... THEN / END IF to if ... { }
let result = crate::basic::ScriptService::convert_if_then_syntax(&result);
// Convert SELECT ... CASE / END SELECT to match expressions

View file

@ -6,7 +6,6 @@ use crate::basic::keywords::switch_case::switch_keyword;
use crate::core::shared::models::UserSession;
use crate::core::shared::state::AppState;
use diesel::prelude::*;
use log::trace;
use rhai::{Dynamic, Engine, EvalAltResult, Scope};
use std::collections::HashMap;
use std::sync::Arc;
@ -47,7 +46,6 @@ use self::keywords::http_operations::register_http_operations;
use self::keywords::last::last_keyword;
#[cfg(feature = "automation")]
use self::keywords::on_form_submit::on_form_submit_keyword;
use self::keywords::switch_case::preprocess_switch;
use self::keywords::use_tool::use_tool_keyword;
use self::keywords::use_website::{clear_websites_keyword, register_use_website_function};
use self::keywords::web_data::register_web_data_keywords;
@ -321,334 +319,20 @@ impl ScriptService {
}
}
}
fn preprocess_basic_script(&self, script: &str) -> Result<String, String> {
let _ = self; // silence unused self warning - kept for API consistency
let script = preprocess_switch(script);
// Preprocess LLM keyword to add WITH OPTIMIZE FOR "speed" syntax
// This is needed because Rhai's custom syntax requires the full syntax
let script = Self::preprocess_llm_keyword(&script);
// Convert ALL multi-word keywords to underscore versions (e.g., "USE WEBSITE" → "USE_WEBSITE")
// This avoids Rhai custom syntax conflicts and makes the system more secure
let script = Self::convert_multiword_keywords(&script);
let script = Self::normalize_variables_to_lowercase(&script);
let mut result = String::new();
let mut for_stack: Vec<usize> = Vec::new();
let mut current_indent = 0;
for line in script.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with('\'') {
continue;
}
if trimmed.starts_with("FOR EACH") {
for_stack.push(current_indent);
result.push_str(&" ".repeat(current_indent));
result.push_str(trimmed);
result.push_str("{\n");
current_indent += 4;
result.push_str(&" ".repeat(current_indent));
result.push('\n');
continue;
}
if trimmed.starts_with("NEXT") {
if let Some(expected_indent) = for_stack.pop() {
if (current_indent - 4) != expected_indent {
return Err("NEXT without matching FOR EACH (indentation mismatch)".to_string());
}
current_indent -= 4;
result.push_str(&" ".repeat(current_indent));
result.push_str("}\n");
result.push_str(&" ".repeat(current_indent));
result.push_str(trimmed);
result.push(';');
result.push('\n');
continue;
}
log::error!("NEXT without matching FOR EACH");
return Err("NEXT without matching FOR EACH".to_string());
}
if trimmed == "EXIT FOR" {
result.push_str(&" ".repeat(current_indent));
result.push_str(trimmed);
result.push('\n');
continue;
}
result.push_str(&" ".repeat(current_indent));
let basic_commands = [
"SET",
"CREATE",
"PRINT",
"FOR",
"FIND",
"GET",
"EXIT",
"IF",
"THEN",
"ELSE",
"END IF",
"WHILE",
"WEND",
"DO",
"LOOP",
"HEAR",
"TALK",
"SET CONTEXT",
"SET USER",
"GET BOT MEMORY",
"SET BOT MEMORY",
"IMAGE",
"VIDEO",
"AUDIO",
"SEE",
"SEND FILE",
"SWITCH",
"CASE",
"DEFAULT",
"END SWITCH",
"USE KB",
"CLEAR KB",
"USE TOOL",
"CLEAR TOOLS",
"ADD SUGGESTION",
"CLEAR SUGGESTIONS",
"INSTR",
"IS_NUMERIC",
"IS NUMERIC",
"POST",
"PUT",
"PATCH",
"DELETE",
"SET HEADER",
"CLEAR HEADERS",
"GRAPHQL",
"SOAP",
"SAVE",
"INSERT",
"UPDATE",
"DELETE",
"MERGE",
"FILL",
"MAP",
"FILTER",
"AGGREGATE",
"JOIN",
"PIVOT",
"GROUP BY",
"READ",
"WRITE",
"COPY",
"MOVE",
"LIST",
"COMPRESS",
"EXTRACT",
"UPLOAD",
"DOWNLOAD",
"GENERATE PDF",
"MERGE PDF",
"WEBHOOK",
"POST TO",
"POST TO INSTAGRAM",
"POST TO FACEBOOK",
"POST TO LINKEDIN",
"POST TO TWITTER",
"GET INSTAGRAM METRICS",
"GET FACEBOOK METRICS",
"GET LINKEDIN METRICS",
"GET TWITTER METRICS",
"DELETE POST",
"SEND MAIL",
"SEND TEMPLATE",
"CREATE TEMPLATE",
"GET TEMPLATE",
"ON ERROR RESUME NEXT",
"ON ERROR GOTO",
"CLEAR ERROR",
"ERROR MESSAGE",
"ON FORM SUBMIT",
"SCORE LEAD",
"GET LEAD SCORE",
"QUALIFY LEAD",
"UPDATE LEAD SCORE",
"AI SCORE LEAD",
"ABS",
"ROUND",
"INT",
"FIX",
"FLOOR",
"CEIL",
"MAX",
"MIN",
"MOD",
"RANDOM",
"RND",
"SGN",
"SQR",
"SQRT",
"LOG",
"EXP",
"POW",
"SIN",
"COS",
"TAN",
"SUM",
"AVG",
"NOW",
"TODAY",
"DATE",
"TIME",
"YEAR",
"MONTH",
"DAY",
"HOUR",
"MINUTE",
"SECOND",
"WEEKDAY",
"DATEADD",
"DATEDIFF",
"FORMAT_DATE",
"ISDATE",
"VAL",
"STR",
"CINT",
"CDBL",
"CSTR",
"ISNULL",
"ISEMPTY",
"TYPEOF",
"ISARRAY",
"ISOBJECT",
"ISSTRING",
"ISNUMBER",
"NVL",
"IIF",
"ARRAY",
"UBOUND",
"LBOUND",
"COUNT",
"SORT",
"UNIQUE",
"CONTAINS",
"INDEX_OF",
"PUSH",
"POP",
"SHIFT",
"REVERSE",
"SLICE",
"SPLIT",
"CONCAT",
"FLATTEN",
"RANGE",
"THROW",
"ERROR",
"IS_ERROR",
"ASSERT",
"LOG_ERROR",
"LOG_WARN",
"LOG_INFO",
];
let is_basic_command = basic_commands.iter().any(|&cmd| trimmed.starts_with(cmd));
let is_control_flow = trimmed.starts_with("IF")
|| trimmed.starts_with("ELSE")
|| trimmed.starts_with("END IF");
result.push_str(trimmed);
let needs_semicolon = is_basic_command
|| !for_stack.is_empty()
|| is_control_flow
|| (!trimmed.ends_with(';') && !trimmed.ends_with('{') && !trimmed.ends_with('}'));
if needs_semicolon {
result.push(';');
}
result.push('\n');
}
if !for_stack.is_empty() {
return Err("Unclosed FOR EACH loop".to_string());
}
Ok(result)
}
pub fn compile(&self, script: &str) -> Result<rhai::AST, Box<EvalAltResult>> {
let processed_script = match self.preprocess_basic_script(script) {
Ok(s) => s,
Err(e) => return Err(Box::new(EvalAltResult::ErrorRuntime(Dynamic::from(e), rhai::Position::NONE))),
/// Run a pre-compiled .ast script (loaded from Drive).
/// Compilation happens only in BasicCompiler (Drive Monitor).
/// Runtime only compiles the already-preprocessed Rhai source and executes it.
pub fn run(&mut self, ast_content: &str) -> Result<Dynamic, Box<EvalAltResult>> {
let ast = match self.engine.compile(ast_content) {
Ok(ast) => ast,
Err(e) => return Err(Box::new(e.into())),
};
trace!("Processed Script:\n{}", processed_script);
match self.engine.compile(&processed_script) {
Ok(ast) => Ok(ast),
Err(parse_error) => Err(Box::new(parse_error.into())),
}
}
/// Compile preprocessed script content (from .ast file) - skips preprocessing
pub fn compile_preprocessed(&self, script: &str) -> Result<rhai::AST, Box<EvalAltResult>> {
trace!("Compiling preprocessed script directly");
match self.engine.compile(script) {
Ok(ast) => Ok(ast),
Err(parse_error) => Err(Box::new(parse_error.into())),
}
}
/// Compile a tool script (.bas file with PARAM/DESCRIPTION metadata lines)
/// Filters out tool metadata before compiling
pub fn compile_tool_script(&self, script: &str) -> Result<rhai::AST, Box<EvalAltResult>> {
// Filter out PARAM, DESCRIPTION, comment, and empty lines (tool metadata)
let executable_script: String = script
.lines()
.filter(|line| {
let trimmed = line.trim();
// Keep lines that are NOT PARAM, DESCRIPTION, comments, or empty
!(trimmed.starts_with("PARAM ") ||
trimmed.starts_with("PARAM\t") ||
trimmed.starts_with("DESCRIPTION ") ||
trimmed.starts_with("DESCRIPTION\t") ||
trimmed.starts_with("REM ") ||
trimmed.starts_with("REM\t") ||
trimmed == "REM" || // bare REM line
trimmed.starts_with('\'') || // BASIC comment lines
trimmed.starts_with('#') || // Hash comment lines
trimmed.is_empty())
})
.collect::<Vec<&str>>()
.join("\n");
trace!("Filtered tool metadata: {} -> {} chars", script.len(), executable_script.len());
// Apply minimal preprocessing for tools (skip variable normalization to avoid breaking multi-line strings)
let script = preprocess_switch(&executable_script);
let script = Self::convert_multiword_keywords(&script);
// Skip normalize_variables_to_lowercase for tools - it breaks multi-line strings
trace!("Preprocessed tool script for Rhai compilation");
// Convert SAVE statements with field lists to map-based SAVE (simplified version for tools)
let script = Self::convert_save_for_tools(&script);
// Convert BEGIN TALK and BEGIN MAIL blocks to single calls
let script = crate::basic::compiler::blocks::convert_begin_blocks(&script);
// Convert WHILE...WEND to Rhai while { } blocks BEFORE if/then conversion
let script = Self::convert_while_wend_syntax(&script);
// Pre-declare all variables at outer scope so assignments inside blocks work correctly.
// In Rhai, a plain `x = val` inside a block updates the outer variable -
// but only if `x` was declared outside with `let`.
let script = Self::predeclare_variables(&script);
// Convert IF ... THEN / END IF to if ... { }
let script = Self::convert_if_then_syntax(&script);
// Convert SELECT ... CASE / END SELECT to match expressions
let script = Self::convert_select_case_syntax(&script);
// Convert BASIC keywords to lowercase (but preserve variable casing)
let script = Self::convert_keywords_to_lowercase(&script);
// Save to file for debugging
if let Err(e) = std::fs::write("/tmp/tool_preprocessed.bas", &script) {
log::warn!("Failed to write preprocessed script: {}", e);
}
match self.engine.compile(&script) {
Ok(ast) => Ok(ast),
Err(parse_error) => Err(Box::new(parse_error.into())),
}
self.engine.eval_ast_with_scope(&mut self.scope, &ast)
}
/// Pre-declare all BASIC variables at the top of the script with `let var = ();`.
/// This allows assignments inside loops/if-blocks to update outer-scope variables in Rhai.
fn predeclare_variables(script: &str) -> String {
pub(crate) fn predeclare_variables(script: &str) -> String {
use std::collections::BTreeSet;
let reserved: std::collections::HashSet<&str> = [
"if", "else", "while", "for", "loop", "return", "break", "continue",
@ -701,114 +385,23 @@ impl ScriptService {
declarations.push_str(script);
declarations
}
pub fn run(&mut self, ast: &rhai::AST) -> Result<Dynamic, Box<EvalAltResult>> {
self.engine.eval_ast_with_scope(&mut self.scope, ast)
}
/// Execute a BASIC script asynchronously
/// Execute a pre-compiled .ast script asynchronously
pub async fn execute_script(
state: Arc<AppState>,
user: UserSession,
script: &str,
ast_content: &str,
) -> Result<String, String> {
let mut script_service = Self::new(state.clone(), user.clone());
script_service.load_bot_config_params(&state, user.bot_id);
match script_service.compile(script) {
Ok(ast) => {
match script_service.run(&ast) {
Ok(result) => Ok(result.to_string()),
Err(e) => Err(format!("Execution error: {}", e)),
}
}
Err(e) => Err(format!("Compilation error: {}", e)),
match script_service.run(ast_content) {
Ok(result) => Ok(result.to_string()),
Err(e) => Err(format!("Script error: {}", e)),
}
}
/// Convert SAVE statements for tool compilation (simplified, no DB lookup)
/// SAVE "table", var1, var2, ... -> let __data__ = #{var1: var1, var2: var2, ...}; SAVE "table", __data__
fn convert_save_for_tools(script: &str) -> String {
let mut result = String::new();
let mut save_counter = 0;
for line in script.lines() {
let trimmed = line.trim();
// Check if this is a SAVE statement
if trimmed.to_uppercase().starts_with("SAVE ") {
// Parse SAVE statement
// Format: SAVE "table", value1, value2, ...
let content = &trimmed[4..].trim();
// Simple parse by splitting on commas (outside quotes)
let parts = Self::parse_save_parts(content);
// If more than 2 parts, convert to map-based SAVE
if parts.len() > 2 {
let table_name = parts[0].trim_matches('"');
let values: Vec<&str> = parts.iter().skip(1).map(|s| s.trim()).collect();
// Build map with variable names as keys
let map_pairs: Vec<String> = values.iter().map(|v| format!("{}: {}", v, v)).collect();
let map_expr = format!("#{{{}}}", map_pairs.join(", "));
let data_var = format!("__save_data_{}__", save_counter);
save_counter += 1;
let converted = format!("let {} = {};\nINSERT \"{}\", {};", data_var, map_expr, table_name, data_var);
result.push_str(&converted);
result.push('\n');
continue;
}
}
result.push_str(line);
result.push('\n');
}
result
}
/// Parse SAVE statement parts (handles quoted strings)
fn parse_save_parts(s: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if !in_quotes => {
in_quotes = true;
current.push(c);
}
'"' if in_quotes => {
in_quotes = false;
current.push(c);
}
',' if !in_quotes => {
parts.push(current.trim().to_string());
current = String::new();
// Skip whitespace after comma
while let Some(&next_c) = chars.peek() {
if next_c.is_whitespace() {
chars.next();
} else {
break;
}
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
parts.push(current.trim().to_string());
}
parts
}
/// Set a variable in the script scope (for tool parameters)
/// Pre-declare all BASIC variables at the top of the script with `let var = ();`.
pub fn set_variable(&mut self, name: &str, value: &str) -> Result<(), Box<dyn std::error::Error>> {
use rhai::Dynamic;
self.scope.set_or_push(name, Dynamic::from(value.to_string()));
@ -1682,312 +1275,6 @@ impl ScriptService {
result
}
fn normalize_variables_to_lowercase(script: &str) -> String {
use regex::Regex;
let mut result = String::new();
let keywords = [
"SET",
"CREATE",
"PRINT",
"FOR",
"FIND",
"GET",
"EXIT",
"IF",
"THEN",
"ELSE",
"END",
"WHILE",
"WEND",
"DO",
"LOOP",
"HEAR",
"TALK",
"NEXT",
"FUNCTION",
"SUB",
"CALL",
"RETURN",
"DIM",
"AS",
"NEW",
"ARRAY",
"OBJECT",
"LET",
"REM",
"AND",
"OR",
"NOT",
"TRUE",
"FALSE",
"NULL",
"SWITCH",
"CASE",
"DEFAULT",
"USE",
"KB",
"TOOL",
"CLEAR",
"ADD",
"SUGGESTION",
"SUGGESTIONS",
"TOOLS",
"CONTEXT",
"USER",
"BOT",
"MEMORY",
"IMAGE",
"VIDEO",
"AUDIO",
"SEE",
"SEND",
"FILE",
"POST",
"PUT",
"PATCH",
"DELETE",
"SAVE",
"INSERT",
"UPDATE",
"MERGE",
"FILL",
"MAP",
"FILTER",
"AGGREGATE",
"JOIN",
"PIVOT",
"GROUP",
"BY",
"READ",
"WRITE",
"COPY",
"MOVE",
"LIST",
"COMPRESS",
"EXTRACT",
"UPLOAD",
"DOWNLOAD",
"GENERATE",
"PDF",
"WEBHOOK",
"TEMPLATE",
"FORM",
"SUBMIT",
"SCORE",
"LEAD",
"QUALIFY",
"AI",
"ABS",
"ROUND",
"INT",
"FIX",
"FLOOR",
"CEIL",
"MAX",
"MIN",
"MOD",
"RANDOM",
"RND",
"SGN",
"SQR",
"SQRT",
"LOG",
"EXP",
"POW",
"SIN",
"COS",
"TAN",
"SUM",
"AVG",
"NOW",
"TODAY",
"DATE",
"TIME",
"YEAR",
"MONTH",
"DAY",
"HOUR",
"MINUTE",
"SECOND",
"WEEKDAY",
"DATEADD",
"DATEDIFF",
"FORMAT",
"ISDATE",
"VAL",
"STR",
"CINT",
"CDBL",
"CSTR",
"ISNULL",
"ISEMPTY",
"TYPEOF",
"ISARRAY",
"ISOBJECT",
"ISSTRING",
"ISNUMBER",
"NVL",
"IIF",
"UBOUND",
"LBOUND",
"COUNT",
"SORT",
"UNIQUE",
"CONTAINS",
"INDEX",
"OF",
"PUSH",
"POP",
"SHIFT",
"REVERSE",
"SLICE",
"SPLIT",
"CONCAT",
"FLATTEN",
"RANGE",
"THROW",
"ERROR",
"IS",
"ASSERT",
"WARN",
"INFO",
"EACH",
"WITH",
"TO",
"STEP",
"BEGIN",
"SYSTEM",
"PROMPT",
"SCHEDULE",
"REFRESH",
"ALLOW",
"ROLE",
"ANSWER",
"MODE",
"SYNCHRONIZE",
"TABLE",
"ON",
"EMAIL",
"REPORT",
"RESET",
"WAIT",
"FIRST",
"LAST",
"LLM",
"INSTR",
"NUMERIC",
"LEN",
"LEFT",
"RIGHT",
"MID",
"LOWER",
"UPPER",
"TRIM",
"LTRIM",
"RTRIM",
"REPLACE",
"LIKE",
"DELEGATE",
"PRIORITY",
"BOTS",
"REMOVE",
"MEMBER",
"BOOK",
"REMEMBER",
"TASK",
"SITE",
"DRAFT",
"INSTAGRAM",
"FACEBOOK",
"LINKEDIN",
"TWITTER",
"METRICS",
"HEADER",
"HEADERS",
"GRAPHQL",
"SOAP",
"HTTP",
"DESCRIPTION",
"PARAM",
"REQUIRED",
"WEBSITE",
"MODEL",
"DETECT",
"LLM",
"TALK",
];
let _identifier_re = Regex::new(r"([a-zA-Z_][a-zA-Z0-9_]*)").expect("valid regex");
for line in script.lines() {
let trimmed = line.trim();
if trimmed.starts_with("REM") || trimmed.starts_with('\'') || trimmed.starts_with("//")
{
continue;
}
// Skip lines with custom syntax that should not be lowercased
// These are registered directly with Rhai in uppercase
let trimmed_upper = trimmed.to_uppercase();
if trimmed_upper.contains("ADD_SUGGESTION_TOOL") ||
trimmed_upper.contains("ADD_SUGGESTION_TEXT") ||
trimmed_upper.starts_with("ADD_SUGGESTION_") ||
trimmed_upper.starts_with("ADD_MEMBER") {
// Keep original line as-is
result.push_str(line);
result.push('\n');
continue;
}
let mut processed_line = String::new();
let mut chars = line.chars().peekable();
let mut in_string = false;
let mut string_char = '"';
let mut current_word = String::new();
while let Some(c) = chars.next() {
if in_string {
processed_line.push(c);
if c == string_char {
in_string = false;
} else if c == '\\' {
if let Some(&next) = chars.peek() {
processed_line.push(next);
chars.next();
}
}
} else if c == '"' || c == '\'' {
if !current_word.is_empty() {
processed_line.push_str(&Self::normalize_word(&current_word, &keywords));
current_word.clear();
}
in_string = true;
string_char = c;
processed_line.push(c);
} else if c.is_alphanumeric() || c == '_' {
current_word.push(c);
} else {
if !current_word.is_empty() {
processed_line.push_str(&Self::normalize_word(&current_word, &keywords));
current_word.clear();
}
processed_line.push(c);
}
}
if !current_word.is_empty() {
processed_line.push_str(&Self::normalize_word(&current_word, &keywords));
}
result.push_str(&processed_line);
result.push('\n');
}
result
}
/// Convert ALL multi-word keywords to underscore versions (function calls)
/// This avoids Rhai custom syntax conflicts and makes the system more secure
@ -2163,24 +1450,7 @@ impl ScriptService {
params
}
fn normalize_word(word: &str, keywords: &[&str]) -> String {
let upper = word.to_uppercase();
if keywords.contains(&upper.as_str()) {
upper
} else if word
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
word.to_string()
} else {
word.to_lowercase()
}
}
fn preprocess_llm_keyword(script: &str) -> String {
pub(crate) fn preprocess_llm_keyword(script: &str) -> String {
// Transform LLM "prompt" to LLM "prompt" WITH OPTIMIZE FOR "speed"
// Handle cases like:
// LLM "text"

View file

@ -145,14 +145,10 @@ impl AutomationService {
script_service.load_bot_config_params(&self.state, automation.bot_id);
match script_service.compile(&script_content) {
Ok(ast) => {
if let Err(e) = script_service.run(&ast) {
error!("Script execution failed: {}", e);
}
}
match script_service.run(&script_content) {
Ok(_) => {}
Err(e) => {
error!("Script compilation failed: {}", e);
error!("Script execution failed: {}", e);
}
}
Ok(())

View file

@ -634,78 +634,73 @@ impl BotOrchestrator {
trace!("Executing start.bas for session {} at: {}", actual_session_id, start_script_path);
// Use pre-compiled .ast if available (avoids preprocessing)
// Load pre-compiled .ast only (compilation happens in Drive Monitor)
let ast_path = start_script_path.replace(".bas", ".ast");
let (script_content, is_preprocessed) = if std::path::Path::new(&ast_path).exists() {
(tokio::fs::read_to_string(&ast_path).await.unwrap_or_default(), true)
} else {
(tokio::fs::read_to_string(&start_script_path).await.unwrap_or_default(), false)
let ast_content = match tokio::fs::read_to_string(&ast_path).await {
Ok(content) if !content.is_empty() => content,
_ => {
let content = tokio::fs::read_to_string(&start_script_path).await.unwrap_or_default();
if content.is_empty() {
trace!("No start.bas/start.ast found for bot {}", bot_name_for_context);
return Ok(());
}
content
}
};
if !script_content.is_empty() {
let state_clone = self.state.clone();
let actual_session_id_for_task = session.id;
let bot_id_clone = session.bot_id;
let state_clone = self.state.clone();
let actual_session_id_for_task = session.id;
let bot_id_clone = session.bot_id;
// Execute start.bas synchronously (blocking)
let result = tokio::task::spawn_blocking(move || {
let session_result = {
let mut sm = state_clone.session_manager.blocking_lock();
sm.get_session_by_id(actual_session_id_for_task)
};
// Execute start.bas synchronously (blocking)
let result = tokio::task::spawn_blocking(move || {
let session_result = {
let mut sm = state_clone.session_manager.blocking_lock();
sm.get_session_by_id(actual_session_id_for_task)
};
let sess = match session_result {
Ok(Some(s)) => s,
Ok(None) => {
return Err(format!("Session {} not found during start.bas execution", actual_session_id_for_task));
}
Err(e) => return Err(format!("Failed to get session: {}", e)),
};
let mut script_service = crate::basic::ScriptService::new(
state_clone.clone(),
sess
);
script_service.load_bot_config_params(&state_clone, bot_id_clone);
let compile_result = if is_preprocessed {
script_service.compile_preprocessed(&script_content)
} else {
script_service.compile(&script_content)
};
match compile_result {
Ok(ast) => match script_service.run(&ast) {
Ok(_) => Ok(()),
Err(e) => Err(format!("Script execution error: {}", e)),
},
Err(e) => Err(format!("Script compilation error: {}", e)),
let sess = match session_result {
Ok(Some(s)) => s,
Ok(None) => {
return Err(format!("Session {} not found during start.bas execution", actual_session_id_for_task));
}
}).await;
Err(e) => return Err(format!("Failed to get session: {}", e)),
};
match result {
Ok(Ok(())) => {
trace!("start.bas completed successfully for session {}", actual_session_id);
let mut script_service = crate::basic::ScriptService::new(
state_clone.clone(),
sess
);
script_service.load_bot_config_params(&state_clone, bot_id_clone);
// Mark start.bas as executed for this session to prevent re-running
if let Some(cache) = &self.state.cache {
if let Ok(mut conn) = cache.get_multiplexed_async_connection().await {
let _: Result<(), redis::RedisError> = redis::cmd("SET")
.arg(&start_bas_key)
.arg("1")
.arg("EX")
.arg("86400") // Expire after 24 hours
.query_async(&mut conn)
.await;
}
match script_service.run(&ast_content) {
Ok(_) => Ok(()),
Err(e) => Err(format!("Script execution error: {}", e)),
}
}).await;
match result {
Ok(Ok(())) => {
trace!("start.bas completed successfully for session {}", actual_session_id);
// Mark start.bas as executed for this session to prevent re-running
if let Some(cache) = &self.state.cache {
if let Ok(mut conn) = cache.get_multiplexed_async_connection().await {
let _: Result<(), redis::RedisError> = redis::cmd("SET")
.arg(&start_bas_key)
.arg("1")
.arg("EX")
.arg("86400") // Expire after 24 hours
.query_async(&mut conn)
.await;
}
}
Ok(Err(e)) => {
error!("start.bas error for session {}: {}", actual_session_id, e);
}
Err(e) => {
error!("start.bas task error for session {}: {}", actual_session_id, e);
}
}
Ok(Err(e)) => {
error!("start.bas error for session {}: {}", actual_session_id, e);
}
Err(e) => {
error!("start.bas task error for session {}: {}", actual_session_id, e);
}
}
} // End of if should_execute_start_bas
@ -1147,13 +1142,17 @@ impl BotOrchestrator {
// DEBUG: Log LLM output for troubleshooting HTML rendering issues
let has_html = full_response.contains("</") || full_response.contains("<!--");
let preview = if full_response.len() > 500 {
format!("{}... ({} chars total)", &full_response[..500], full_response.len())
let has_div = full_response.contains("<div") || full_response.contains("</div>");
let has_style = full_response.contains("<style");
let is_truncated = !full_response.trim_end().ends_with("</div>") && has_div;
let preview = if full_response.len() > 800 {
format!("{}... ({} chars total)", &full_response[..800], full_response.len())
} else {
full_response.clone()
};
info!("[LLM_OUTPUT] session={} has_html={} preview=\"{}\"",
session_id, has_html, preview.replace('\n', "\\n"));
info!("[LLM_OUTPUT] session={} has_html={} has_div={} has_style={} is_truncated={} len={} preview=\"{}\"",
session_id, has_html, has_div, has_style, is_truncated, full_response.len(),
preview.replace('\n', "\\n"));
trace!("LLM stream complete. Full response: {}", full_response);
@ -1433,27 +1432,22 @@ async fn handle_websocket(
info!("Looking for start.bas at: {}", start_script_path);
// Check for pre-compiled .ast file first (avoids preprocessing overhead)
// Load pre-compiled .ast only (compilation happens in Drive Monitor)
let ast_path = start_script_path.replace(".bas", ".ast");
let (script_content, is_preprocessed) = if tokio::fs::metadata(&ast_path).await.is_ok() {
if let Ok(content) = tokio::fs::read_to_string(&ast_path).await {
info!("Using pre-compiled start.ast for {}", bot_name);
(content, true)
} else {
(String::new(), false)
let ast_content = match tokio::fs::read_to_string(&ast_path).await {
Ok(content) if !content.is_empty() => content,
_ => {
let content = tokio::fs::read_to_string(&start_script_path).await.unwrap_or_default();
if content.is_empty() {
info!("No start.bas/start.ast found for bot {}", bot_name);
String::new()
} else {
content
}
}
} else if tokio::fs::metadata(&start_script_path).await.is_ok() {
if let Ok(content) = tokio::fs::read_to_string(&start_script_path).await {
info!("Compiling start.bas for {}", bot_name);
(content, false)
} else {
(String::new(), false)
}
} else {
(String::new(), false)
};
if !script_content.is_empty() {
if !ast_content.is_empty() {
info!(
"Executing start.bas for bot {} on session {}",
bot_name, session_id
@ -1464,8 +1458,6 @@ async fn handle_websocket(
let bot_id_str = bot_id.to_string();
let session_id_str = session_id.to_string();
let mut send_ready_rx = send_ready_rx;
let script_content_owned = script_content.clone();
let is_preprocessed_owned = is_preprocessed;
tokio::spawn(async move {
let _ = send_ready_rx.recv().await;
@ -1502,18 +1494,9 @@ async fn handle_websocket(
);
script_service.load_bot_config_params(&state_for_start, bot_id);
let compile_result = if is_preprocessed_owned {
script_service.compile_preprocessed(&script_content_owned)
} else {
script_service.compile(&script_content_owned)
};
match compile_result {
Ok(ast) => match script_service.run(&ast) {
Ok(_) => Ok(()),
Err(e) => Err(format!("Script execution error: {}", e)),
},
Err(e) => Err(format!("Script compilation error: {}", e)),
match script_service.run(&ast_content) {
Ok(_) => Ok(()),
Err(e) => Err(format!("Script execution error: {}", e)),
}
}).await;

View file

@ -157,46 +157,25 @@ impl ToolExecutor {
}
};
// Load the .bas tool file (prefer pre-compiled .ast if available)
let bas_path = Self::get_tool_bas_path(bot_name, &tool_call.tool_name);
let ast_path = bas_path.with_extension("ast");
// Load the pre-compiled .ast file (compilation happens only in Drive Monitor)
let ast_path = Self::get_tool_ast_path(bot_name, &tool_call.tool_name);
// Check for .ast first (pre-compiled), fallback to .bas
let (script_content, is_preprocessed) = if ast_path.exists() {
match tokio::fs::read_to_string(&ast_path).await {
Ok(script) => {
trace!("Using pre-compiled .ast for tool: {}", tool_call.tool_name);
(script, true)
}
Err(_) => (String::new(), false)
let ast_content = match tokio::fs::read_to_string(&ast_path).await {
Ok(content) => content,
Err(e) => {
let error_msg = format!("Failed to read tool .ast file {}: {}", ast_path.display(), e);
Self::log_tool_error(bot_name, &tool_call.tool_name, &error_msg);
return ToolExecutionResult {
tool_call_id: tool_call.id.clone(),
success: false,
result: String::new(),
error: Some(Self::format_user_friendly_error(&tool_call.tool_name, &error_msg)),
};
}
} else if bas_path.exists() {
match tokio::fs::read_to_string(&bas_path).await {
Ok(script) => (script, false),
Err(e) => {
let error_msg = format!("Failed to read tool file: {}", e);
Self::log_tool_error(bot_name, &tool_call.tool_name, &error_msg);
return ToolExecutionResult {
tool_call_id: tool_call.id.clone(),
success: false,
result: String::new(),
error: Some(Self::format_user_friendly_error(&tool_call.tool_name, &error_msg)),
};
}
}
} else {
let error_msg = format!("Tool file not found: {:?}", bas_path);
Self::log_tool_error(bot_name, &tool_call.tool_name, &error_msg);
return ToolExecutionResult {
tool_call_id: tool_call.id.clone(),
success: false,
result: String::new(),
error: Some(Self::format_user_friendly_error(&tool_call.tool_name, &error_msg)),
};
};
if script_content.is_empty() {
let error_msg = "Tool script is empty".to_string();
if ast_content.is_empty() {
let error_msg = "Tool .ast file is empty".to_string();
Self::log_tool_error(bot_name, &tool_call.tool_name, &error_msg);
return ToolExecutionResult {
tool_call_id: tool_call.id.clone(),
@ -245,10 +224,9 @@ impl ToolExecutor {
&bot_name_clone,
bot_id_clone,
&session,
&script_content,
&ast_content,
&tool_name_clone,
&arguments_clone,
is_preprocessed,
)
})
.await;
@ -274,10 +252,9 @@ impl ToolExecutor {
bot_name: &str,
bot_id: Uuid,
session: &crate::core::shared::models::UserSession,
script_content: &str,
ast_content: &str,
tool_name: &str,
arguments: &Value,
is_preprocessed: bool,
) -> ToolExecutionResult {
let tool_call_id = format!("tool_{}", uuid::Uuid::new_v4());
@ -304,30 +281,8 @@ impl ToolExecutor {
}
}
// Compile: use compile_preprocessed for .ast files, compile_tool_script for .bas
let ast = if is_preprocessed {
script_service.compile_preprocessed(script_content)
} else {
script_service.compile_tool_script(script_content)
};
let ast = match ast {
Ok(ast) => ast,
Err(e) => {
let error_msg = format!("Compilation error: {}", e);
Self::log_tool_error(bot_name, tool_name, &error_msg);
let user_message = Self::format_user_friendly_error(tool_name, &error_msg);
return ToolExecutionResult {
tool_call_id,
success: false,
result: String::new(),
error: Some(user_message),
};
}
};
// Run the script
match script_service.run(&ast) {
// Run the pre-compiled .ast content (compilation happens only in Drive Monitor)
match script_service.run(ast_content) {
Ok(result) => {
trace!("Tool '{}' executed successfully", tool_name);
@ -365,13 +320,12 @@ impl ToolExecutor {
.ok()
}
/// Get the path to a tool's .bas file
fn get_tool_bas_path(bot_name: &str, tool_name: &str) -> std::path::PathBuf {
// Use work directory for compiled .bas files
/// Get the path to a tool's pre-compiled .ast file
fn get_tool_ast_path(bot_name: &str, tool_name: &str) -> std::path::PathBuf {
let work_path = std::path::PathBuf::from(crate::core::shared::utils::get_work_path())
.join(format!("{}.gbai", bot_name))
.join(format!("{}.gbdialog", bot_name))
.join(format!("{}.bas", tool_name));
.join(format!("{}.ast", tool_name));
work_path
}

View file

@ -213,13 +213,11 @@ pub async fn auth_handler(
let mut script_service =
crate::basic::ScriptService::new(state_clone.clone(), session_clone);
script_service.load_bot_config_params(&state_clone, bot_id);
match script_service.compile(&auth_script) {
Ok(ast) => match script_service.run(&ast) {
Ok(_) => Ok(()),
Err(e) => Err(format!("Script execution error: {}", e)),
},
Err(e) => Err(format!("Script compilation error: {}", e)),
script_service.load_bot_config_params(&state_clone, bot_id);
match script_service.run(&auth_script) {
Ok(_) => Ok(()),
Err(e) => Err(format!("Script execution error: {}", e)),
}
})
.await

View file

@ -1,6 +1,6 @@
use async_trait::async_trait;
use futures::StreamExt;
use log::{error, info};
use log::{error, info, trace};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
@ -413,7 +413,8 @@ impl LLMProvider for OpenAIClient {
let mut request_body = serde_json::json!({
"model": model,
"messages": messages,
"stream": true
"stream": true,
"max_tokens": 16384
});
// Add tools to the request if provided
@ -477,7 +478,17 @@ impl LLMProvider for OpenAIClient {
for line in chunk_str.lines() {
if line.starts_with("data: ") && !line.contains("[DONE]") {
if let Ok(data) = serde_json::from_str::<Value>(&line[6..]) {
// Kimi K2.5 and other reasoning models send thinking in "reasoning" field
// Only process "content" (actual response), ignore "reasoning" (thinking)
let content = data["choices"][0]["delta"]["content"].as_str();
let reasoning = data["choices"][0]["delta"]["reasoning"].as_str();
// Log first chunk to help debug reasoning models
if reasoning.is_some() && content.is_none() {
trace!("[LLM] Kimi reasoning chunk (no content yet): {} chars",
reasoning.unwrap_or("").len());
}
if let Some(content) = content {
let processed = handler.process_content(content);
if !processed.is_empty() {