Skip to main content

simple_agents_ffi/
lib.rs

1//! C-compatible FFI bindings for SimpleAgents.
2
3use futures_util::StreamExt;
4use serde::Serialize;
5use serde_json::Value as JsonValue;
6use simple_agent_type::coercion::{CoercionFlag, CoercionResult};
7use simple_agent_type::message::{Message, Role};
8use simple_agent_type::prelude::{ApiKey, CompletionRequest, Provider, Result, SimpleAgentsError};
9use simple_agent_type::response::{CompletionResponse, FinishReason, Usage};
10use simple_agent_type::tool::{ToolCall, ToolType};
11use simple_agents_core::{
12    CompletionMode, CompletionOptions, CompletionOutcome, HealedJsonResponse, HealedSchemaResponse,
13    SimpleAgentsClient, SimpleAgentsClientBuilder,
14};
15use simple_agents_healing::schema::{Field as SchemaField, ObjectSchema, Schema, StreamAnnotation};
16use simple_agents_providers::anthropic::AnthropicProvider;
17use simple_agents_providers::openai::OpenAIProvider;
18use simple_agents_providers::openrouter::OpenRouterProvider;
19use simple_agents_workflow::{
20    run_email_workflow_yaml_file_with_client,
21    run_workflow_yaml_file_with_client_and_custom_worker_and_events_and_options, YamlWorkflowEvent,
22    YamlWorkflowEventSink, YamlWorkflowRunOptions,
23};
24use std::cell::RefCell;
25use std::ffi::{CStr, CString};
26use std::os::raw::{c_char, c_void};
27use std::panic::{catch_unwind, AssertUnwindSafe};
28use std::sync::{Arc, Mutex};
29
30// Keep runtime ownership in the FFI layer so each client is self-contained.
31type Runtime = tokio::runtime::Runtime;
32
33struct FfiClient {
34    runtime: Mutex<Runtime>,
35    client: SimpleAgentsClient,
36}
37
38#[repr(C)]
39pub struct SAClient {
40    inner: FfiClient,
41}
42
43#[repr(C)]
44pub struct SAMessage {
45    pub role: *const c_char,
46    pub content: *const c_char,
47    pub name: *const c_char,
48    pub tool_call_id: *const c_char,
49}
50
51#[derive(Serialize)]
52struct FfiToolCallFunction {
53    name: String,
54    arguments: String,
55}
56
57#[derive(Serialize)]
58struct FfiToolCall {
59    id: String,
60    tool_type: String,
61    function: FfiToolCallFunction,
62}
63
64#[derive(Serialize)]
65struct FfiUsage {
66    prompt_tokens: u32,
67    completion_tokens: u32,
68    total_tokens: u32,
69}
70
71#[derive(Serialize)]
72struct FfiHealingData {
73    value: JsonValue,
74    flags: Vec<CoercionFlag>,
75    confidence: f32,
76}
77
78#[derive(Serialize)]
79struct FfiCompletionResult {
80    id: String,
81    model: String,
82    role: String,
83    content: Option<String>,
84    tool_calls: Option<Vec<FfiToolCall>>,
85    finish_reason: Option<String>,
86    usage: FfiUsage,
87    raw: Option<String>,
88    healed: Option<FfiHealingData>,
89    coerced: Option<FfiHealingData>,
90}
91
92type SAStreamCallback =
93    Option<extern "C" fn(event_json: *const c_char, user_data: *mut c_void) -> i32>;
94
95type SAWorkflowEventCallback =
96    Option<extern "C" fn(event_json: *const c_char, user_data: *mut c_void) -> i32>;
97
98struct RecordingWorkflowEventSink {
99    events: Mutex<Vec<YamlWorkflowEvent>>,
100}
101
102impl RecordingWorkflowEventSink {
103    fn new() -> Self {
104        Self {
105            events: Mutex::new(Vec::new()),
106        }
107    }
108
109    fn attach_to_output(&self, output: &mut JsonValue) -> Result<()> {
110        let events = self
111            .events
112            .lock()
113            .map_err(|_| {
114                SimpleAgentsError::Config("workflow event sink lock poisoned".to_string())
115            })?
116            .clone();
117        let events_value = serde_json::to_value(events)
118            .map_err(|e| SimpleAgentsError::Config(format!("serialize workflow events: {e}")))?;
119        if let JsonValue::Object(object) = output {
120            object.insert("events".to_string(), events_value);
121        }
122        Ok(())
123    }
124}
125
126impl YamlWorkflowEventSink for RecordingWorkflowEventSink {
127    fn emit(&self, event: &YamlWorkflowEvent) {
128        if let Ok(mut events) = self.events.lock() {
129            events.push(event.clone());
130        }
131    }
132}
133
134struct CallbackWorkflowEventSink {
135    callback: extern "C" fn(event_json: *const c_char, user_data: *mut c_void) -> i32,
136    user_data: *mut c_void,
137    callback_failed: Mutex<bool>,
138}
139
140impl CallbackWorkflowEventSink {
141    fn new(
142        callback: extern "C" fn(event_json: *const c_char, user_data: *mut c_void) -> i32,
143        user_data: *mut c_void,
144    ) -> Self {
145        Self {
146            callback,
147            user_data,
148            callback_failed: Mutex::new(false),
149        }
150    }
151
152    fn callback_failed(&self) -> bool {
153        self.callback_failed
154            .lock()
155            .map(|flag| *flag)
156            .unwrap_or(true)
157    }
158}
159
160// SAFETY:
161// - The sink does not dereference `user_data`; it forwards the raw pointer back to the caller's callback.
162// - `callback` is an immutable function pointer copied by value.
163// - Shared mutable state inside the sink is guarded by `Mutex<bool>`.
164// - FFI callers are responsible for ensuring `user_data` remains valid for the callback lifetime.
165unsafe impl Send for CallbackWorkflowEventSink {}
166// SAFETY: same invariants as `Send`; no additional aliasing or ownership assumptions are introduced.
167unsafe impl Sync for CallbackWorkflowEventSink {}
168
169impl YamlWorkflowEventSink for CallbackWorkflowEventSink {
170    fn emit(&self, event: &YamlWorkflowEvent) {
171        let payload = match serde_json::to_string(event) {
172            Ok(value) => value,
173            Err(_) => {
174                if let Ok(mut failed) = self.callback_failed.lock() {
175                    *failed = true;
176                }
177                return;
178            }
179        };
180        let payload = match CString::new(payload) {
181            Ok(value) => value,
182            Err(_) => {
183                if let Ok(mut failed) = self.callback_failed.lock() {
184                    *failed = true;
185                }
186                return;
187            }
188        };
189        let status = (self.callback)(payload.as_ptr(), self.user_data);
190        if status != 0 {
191            if let Ok(mut failed) = self.callback_failed.lock() {
192                *failed = true;
193            }
194        }
195    }
196
197    fn is_cancelled(&self) -> bool {
198        self.callback_failed()
199    }
200}
201
202#[derive(Serialize)]
203#[serde(tag = "type", rename_all = "snake_case")]
204enum FfiStreamEvent {
205    Chunk {
206        chunk: simple_agent_type::response::CompletionChunk,
207    },
208    Error {
209        message: String,
210    },
211    Done,
212}
213
214thread_local! {
215    static LAST_ERROR: RefCell<Option<String>> = const { RefCell::new(None) };
216}
217
218fn set_last_error(message: impl Into<String>) {
219    LAST_ERROR.with(|slot| {
220        *slot.borrow_mut() = Some(message.into());
221    });
222}
223
224fn clear_last_error() {
225    LAST_ERROR.with(|slot| {
226        *slot.borrow_mut() = None;
227    });
228}
229
230fn take_last_error() -> Option<String> {
231    LAST_ERROR.with(|slot| slot.borrow_mut().take())
232}
233
234fn build_runtime() -> Result<Runtime> {
235    Runtime::new().map_err(|e| SimpleAgentsError::Config(format!("Failed to build runtime: {e}")))
236}
237
238fn provider_from_env(provider_name: &str) -> Result<Arc<dyn Provider>> {
239    match provider_name {
240        "openai" => Ok(Arc::new(OpenAIProvider::from_env()?)),
241        "anthropic" => Ok(Arc::new(AnthropicProvider::from_env()?)),
242        "openrouter" => Ok(Arc::new(openrouter_from_env()?)),
243        _ => Err(SimpleAgentsError::Config(format!(
244            "Unknown provider '{provider_name}'"
245        ))),
246    }
247}
248
249fn openrouter_from_env() -> Result<OpenRouterProvider> {
250    let api_key = std::env::var("OPENROUTER_API_KEY").map_err(|_| {
251        SimpleAgentsError::Config("OPENROUTER_API_KEY environment variable is required".to_string())
252    })?;
253    let api_key = ApiKey::new(api_key)?;
254    let base_url = std::env::var("OPENROUTER_API_BASE")
255        .unwrap_or_else(|_| OpenRouterProvider::DEFAULT_BASE_URL.to_string());
256    OpenRouterProvider::with_base_url(api_key, base_url)
257}
258
259unsafe fn cstr_to_string(ptr: *const c_char, field: &str) -> Result<String> {
260    if ptr.is_null() {
261        return Err(SimpleAgentsError::Config(format!("{field} cannot be null")));
262    }
263
264    let c_str = CStr::from_ptr(ptr);
265    let value = c_str
266        .to_str()
267        .map_err(|_| SimpleAgentsError::Config(format!("{field} must be valid UTF-8")))?;
268    if value.is_empty() {
269        return Err(SimpleAgentsError::Config(format!(
270            "{field} cannot be empty"
271        )));
272    }
273
274    Ok(value.to_string())
275}
276
277unsafe fn cstr_to_optional_string(ptr: *const c_char, field: &str) -> Result<Option<String>> {
278    if ptr.is_null() {
279        return Ok(None);
280    }
281    let c_str = CStr::from_ptr(ptr);
282    let value = c_str
283        .to_str()
284        .map_err(|_| SimpleAgentsError::Config(format!("{field} must be valid UTF-8")))?;
285    if value.is_empty() {
286        return Ok(None);
287    }
288    Ok(Some(value.to_string()))
289}
290
291fn parse_workflow_run_options(raw_json: Option<String>) -> Result<YamlWorkflowRunOptions> {
292    match raw_json {
293        None => Ok(YamlWorkflowRunOptions::default()),
294        Some(value) => {
295            if value.trim().is_empty() {
296                return Ok(YamlWorkflowRunOptions::default());
297            }
298            serde_json::from_str::<YamlWorkflowRunOptions>(&value).map_err(|error| {
299                SimpleAgentsError::Config(format!(
300                    "workflow_options_json must be valid JSON: {error}"
301                ))
302            })
303        }
304    }
305}
306
307fn build_client(provider: Arc<dyn Provider>) -> Result<SimpleAgentsClient> {
308    SimpleAgentsClientBuilder::new()
309        .with_provider(provider)
310        .build()
311}
312
313fn build_request_from_messages(
314    model: &str,
315    messages: Vec<Message>,
316    max_tokens: i32,
317    temperature: f32,
318    top_p: f32,
319) -> Result<CompletionRequest> {
320    let mut builder = CompletionRequest::builder().model(model).messages(messages);
321
322    if max_tokens > 0 {
323        builder = builder.max_tokens(max_tokens as u32);
324    }
325
326    if temperature >= 0.0 {
327        builder = builder.temperature(temperature);
328    }
329
330    if top_p >= 0.0 {
331        builder = builder.top_p(top_p);
332    }
333
334    builder.build()
335}
336
337fn build_request(
338    model: &str,
339    prompt: &str,
340    max_tokens: i32,
341    temperature: f32,
342) -> Result<CompletionRequest> {
343    build_request_from_messages(
344        model,
345        vec![Message::user(prompt)],
346        max_tokens,
347        temperature,
348        -1.0,
349    )
350}
351
352fn schema_aliases(value: Option<&JsonValue>) -> Vec<String> {
353    value
354        .and_then(JsonValue::as_array)
355        .map(|arr| {
356            arr.iter()
357                .filter_map(|v| v.as_str().map(str::to_string))
358                .collect()
359        })
360        .unwrap_or_default()
361}
362
363fn parse_schema_field(value: &JsonValue) -> Result<SchemaField> {
364    let name = value
365        .get("name")
366        .and_then(JsonValue::as_str)
367        .ok_or_else(|| SimpleAgentsError::Config("schema field missing `name`".to_string()))?;
368    let schema_value = value.get("schema").ok_or_else(|| {
369        SimpleAgentsError::Config(format!("schema field `{name}` missing `schema`"))
370    })?;
371
372    Ok(SchemaField {
373        name: name.to_string(),
374        schema: parse_schema(schema_value)?,
375        required: value
376            .get("required")
377            .and_then(JsonValue::as_bool)
378            .unwrap_or(true),
379        aliases: schema_aliases(value.get("aliases")),
380        default: None,
381        description: None,
382        stream_annotation: StreamAnnotation::Normal,
383    })
384}
385
386fn parse_schema(value: &JsonValue) -> Result<Schema> {
387    let kind = value
388        .get("kind")
389        .and_then(JsonValue::as_str)
390        .ok_or_else(|| SimpleAgentsError::Config("schema requires `kind`".to_string()))?
391        .to_lowercase();
392
393    match kind.as_str() {
394        "string" => Ok(Schema::String),
395        "int" => Ok(Schema::Int),
396        "uint" => Ok(Schema::UInt),
397        "float" => Ok(Schema::Float),
398        "bool" => Ok(Schema::Bool),
399        "null" => Ok(Schema::Null),
400        "any" => Ok(Schema::Any),
401        "array" => {
402            let elements = value.get("elements").ok_or_else(|| {
403                SimpleAgentsError::Config("array schema requires `elements`".to_string())
404            })?;
405            Ok(Schema::array(parse_schema(elements)?))
406        }
407        "union" => {
408            let variants = value
409                .get("variants")
410                .and_then(JsonValue::as_array)
411                .ok_or_else(|| {
412                    SimpleAgentsError::Config("union schema requires `variants` array".to_string())
413                })?;
414            let schemas = variants
415                .iter()
416                .map(parse_schema)
417                .collect::<Result<Vec<_>>>()?;
418            Ok(Schema::union(schemas))
419        }
420        "object" => {
421            let fields = value
422                .get("fields")
423                .and_then(JsonValue::as_array)
424                .ok_or_else(|| {
425                    SimpleAgentsError::Config("object schema requires `fields` array".to_string())
426                })?;
427            let converted = fields
428                .iter()
429                .map(parse_schema_field)
430                .collect::<Result<Vec<_>>>()?;
431            Ok(Schema::Object(ObjectSchema {
432                fields: converted,
433                allow_additional_fields: value
434                    .get("allow_additional_fields")
435                    .and_then(JsonValue::as_bool)
436                    .unwrap_or(false),
437            }))
438        }
439        other => Err(SimpleAgentsError::Config(format!(
440            "unsupported schema kind `{other}`"
441        ))),
442    }
443}
444
445fn completion_options(mode: Option<&str>, schema_json: Option<&str>) -> Result<CompletionOptions> {
446    let mode = match mode.map(|m| m.to_ascii_lowercase()) {
447        None => CompletionMode::Standard,
448        Some(m) if m.is_empty() || m == "standard" => CompletionMode::Standard,
449        Some(m) if m == "healed_json" => CompletionMode::HealedJson,
450        Some(m) if m == "schema" => {
451            let raw_schema = schema_json.ok_or_else(|| {
452                SimpleAgentsError::Config("mode `schema` requires `schema_json`".to_string())
453            })?;
454            let value: JsonValue = serde_json::from_str(raw_schema)
455                .map_err(|e| SimpleAgentsError::Config(format!("invalid `schema_json`: {e}")))?;
456            CompletionMode::CoercedSchema(parse_schema(&value)?)
457        }
458        Some(other) => {
459            return Err(SimpleAgentsError::Config(format!(
460                "unknown mode `{other}` (expected standard|healed_json|schema)"
461            )))
462        }
463    };
464
465    Ok(CompletionOptions { mode })
466}
467
468fn role_to_string(role: Role) -> String {
469    role.as_str().to_string()
470}
471
472fn finish_reason_to_string(finish_reason: FinishReason) -> String {
473    finish_reason.as_str().to_string()
474}
475
476fn tool_type_to_string(tool_type: ToolType) -> String {
477    match tool_type {
478        ToolType::Function => "function".to_string(),
479    }
480}
481
482fn usage_to_ffi(usage: Usage) -> FfiUsage {
483    FfiUsage {
484        prompt_tokens: usage.prompt_tokens,
485        completion_tokens: usage.completion_tokens,
486        total_tokens: usage.total_tokens,
487    }
488}
489
490fn map_tool_calls(tool_calls: Option<Vec<ToolCall>>) -> Option<Vec<FfiToolCall>> {
491    tool_calls.map(|calls| {
492        calls
493            .into_iter()
494            .map(|call| FfiToolCall {
495                id: call.id,
496                tool_type: tool_type_to_string(call.tool_type),
497                function: FfiToolCallFunction {
498                    name: call.function.name,
499                    arguments: call.function.arguments,
500                },
501            })
502            .collect()
503    })
504}
505
506fn healing_data_from(result: CoercionResult<JsonValue>) -> FfiHealingData {
507    FfiHealingData {
508        value: result.value,
509        flags: result.flags,
510        confidence: result.confidence,
511    }
512}
513
514fn completion_result_from_response(
515    response: CompletionResponse,
516    healed: Option<FfiHealingData>,
517    coerced: Option<FfiHealingData>,
518) -> FfiCompletionResult {
519    let content = response.content().map(str::to_string);
520    let choice = response.choices.first();
521    let role = choice
522        .map(|c| role_to_string(c.message.role))
523        .unwrap_or_else(|| "assistant".to_string());
524    let finish_reason = choice.map(|c| finish_reason_to_string(c.finish_reason));
525    let tool_calls = choice.and_then(|c| c.message.tool_calls.clone());
526    let usage = response.usage;
527
528    FfiCompletionResult {
529        id: response.id.clone(),
530        model: response.model.clone(),
531        role: role.clone(),
532        content: content.clone(),
533        tool_calls: map_tool_calls(tool_calls),
534        finish_reason,
535        usage: usage_to_ffi(usage),
536        raw: content,
537        healed,
538        coerced,
539    }
540}
541
542fn parse_messages(messages: *const SAMessage, messages_len: usize) -> Result<Vec<Message>> {
543    if messages.is_null() {
544        return Err(SimpleAgentsError::Config(
545            "messages cannot be null".to_string(),
546        ));
547    }
548    if messages_len == 0 {
549        return Err(SimpleAgentsError::Config(
550            "messages cannot be empty".to_string(),
551        ));
552    }
553
554    let input = unsafe { std::slice::from_raw_parts(messages, messages_len) };
555    input
556        .iter()
557        .enumerate()
558        .map(|(idx, msg)| {
559            let role = unsafe { cstr_to_string(msg.role, &format!("messages[{idx}].role"))? }
560                .to_ascii_lowercase();
561            let content =
562                unsafe { cstr_to_string(msg.content, &format!("messages[{idx}].content"))? };
563            let name =
564                unsafe { cstr_to_optional_string(msg.name, &format!("messages[{idx}].name"))? };
565            let tool_call_id = unsafe {
566                cstr_to_optional_string(msg.tool_call_id, &format!("messages[{idx}].tool_call_id"))?
567            };
568
569            let parsed_role = role.parse::<Role>().map_err(|_| {
570                SimpleAgentsError::Config(format!(
571                    "messages[{idx}].role must be one of user|assistant|system|tool"
572                ))
573            })?;
574
575            let parsed = match parsed_role {
576                Role::User => Message::user(content),
577                Role::Assistant => Message::assistant(content),
578                Role::System => Message::system(content),
579                Role::Tool => {
580                    let call_id = tool_call_id.ok_or_else(|| {
581                        SimpleAgentsError::Config(format!(
582                            "messages[{idx}].tool_call_id is required for tool role"
583                        ))
584                    })?;
585                    Message::tool(content, call_id)
586                }
587            };
588
589            Ok(match name {
590                Some(name) => parsed.with_name(name),
591                None => parsed,
592            })
593        })
594        .collect()
595}
596
597fn ffi_result_string(result: Result<String>) -> *mut c_char {
598    match result {
599        Ok(value) => match CString::new(value) {
600            Ok(c_string) => {
601                clear_last_error();
602                c_string.into_raw()
603            }
604            Err(_) => {
605                set_last_error("Response contained an interior null byte".to_string());
606                std::ptr::null_mut()
607            }
608        },
609        Err(error) => {
610            set_last_error(error.to_string());
611            std::ptr::null_mut()
612        }
613    }
614}
615
616fn ffi_guard<T>(action: impl FnOnce() -> Result<T>) -> *mut c_char
617where
618    T: Into<String>,
619{
620    let result = catch_unwind(AssertUnwindSafe(action));
621    match result {
622        Ok(inner) => ffi_result_string(inner.map(Into::into)),
623        Err(_) => {
624            set_last_error("Panic occurred in FFI call".to_string());
625            std::ptr::null_mut()
626        }
627    }
628}
629
630fn ffi_guard_status(action: impl FnOnce() -> Result<()>) -> i32 {
631    let result = catch_unwind(AssertUnwindSafe(action));
632    match result {
633        Ok(Ok(())) => {
634            clear_last_error();
635            0
636        }
637        Ok(Err(error)) => {
638            set_last_error(error.to_string());
639            -1
640        }
641        Err(_) => {
642            set_last_error("Panic occurred in FFI call".to_string());
643            -1
644        }
645    }
646}
647
648fn emit_stream_event(
649    callback: extern "C" fn(*const c_char, *mut c_void) -> i32,
650    user_data: *mut c_void,
651    event: FfiStreamEvent,
652) -> Result<()> {
653    let payload = serde_json::to_string(&event)
654        .map_err(|e| SimpleAgentsError::Config(format!("failed to serialize stream event: {e}")))?;
655    let payload = CString::new(payload).map_err(|_| {
656        SimpleAgentsError::Config("stream event contains interior null byte".to_string())
657    })?;
658
659    let callback_status = callback(payload.as_ptr(), user_data);
660    if callback_status == 0 {
661        Ok(())
662    } else {
663        Err(SimpleAgentsError::Config(
664            "stream cancelled by callback".to_string(),
665        ))
666    }
667}
668
669/// Create a client from environment variables for a provider.
670///
671/// `provider_name` must be one of: "openai", "anthropic", "openrouter".
672///
673/// # Safety
674///
675/// The `provider_name` pointer must be a valid null-terminated C string or null.
676/// The returned pointer must be freed with `sa_client_free`.
677#[no_mangle]
678pub unsafe extern "C" fn sa_client_new_from_env(provider_name: *const c_char) -> *mut SAClient {
679    let result = catch_unwind(AssertUnwindSafe(|| -> Result<Box<SAClient>> {
680        let provider = cstr_to_string(provider_name, "provider_name")?;
681        let provider = provider_from_env(&provider)?;
682        let client = build_client(provider)?;
683        let runtime = build_runtime()?;
684
685        Ok(Box::new(SAClient {
686            inner: FfiClient {
687                runtime: Mutex::new(runtime),
688                client,
689            },
690        }))
691    }));
692
693    match result {
694        Ok(Ok(client)) => {
695            clear_last_error();
696            Box::into_raw(client)
697        }
698        Ok(Err(error)) => {
699            set_last_error(error.to_string());
700            std::ptr::null_mut()
701        }
702        Err(_) => {
703            set_last_error("Panic occurred in sa_client_new_from_env".to_string());
704            std::ptr::null_mut()
705        }
706    }
707}
708
709/// Free a client created by `sa_client_new_from_env`.
710///
711/// # Safety
712///
713/// The `client` pointer must be null or a valid pointer returned by `sa_client_new_from_env`.
714/// After calling this function, the pointer is no longer valid and must not be used.
715#[no_mangle]
716pub unsafe extern "C" fn sa_client_free(client: *mut SAClient) {
717    if client.is_null() {
718        return;
719    }
720
721    drop(Box::from_raw(client));
722}
723
724/// Execute a completion request with a single user prompt.
725///
726/// Use `max_tokens <= 0` to omit, and `temperature < 0.0` to omit.
727///
728/// # Safety
729///
730/// The `client` pointer must be a valid pointer returned by `sa_client_new_from_env`.
731/// The `model` and `prompt` pointers must be valid null-terminated C strings.
732/// The returned pointer must be freed with `sa_string_free`.
733#[no_mangle]
734pub unsafe extern "C" fn sa_complete(
735    client: *mut SAClient,
736    model: *const c_char,
737    prompt: *const c_char,
738    max_tokens: i32,
739    temperature: f32,
740) -> *mut c_char {
741    if client.is_null() {
742        set_last_error("client cannot be null".to_string());
743        return std::ptr::null_mut();
744    }
745
746    ffi_guard(|| {
747        let model = cstr_to_string(model, "model")?;
748        let prompt = cstr_to_string(prompt, "prompt")?;
749        let request = build_request(&model, &prompt, max_tokens, temperature)?;
750
751        let client = &(*client).inner;
752        let runtime = client
753            .runtime
754            .lock()
755            .map_err(|_| SimpleAgentsError::Config("runtime lock poisoned".to_string()))?;
756        let outcome = runtime.block_on(
757            client
758                .client
759                .complete(&request, CompletionOptions::default()),
760        )?;
761        let response = match outcome {
762            CompletionOutcome::Response(response) => response,
763            CompletionOutcome::Stream(_) => {
764                return Err(SimpleAgentsError::Config(
765                    "streaming response returned from complete".to_string(),
766                ))
767            }
768            CompletionOutcome::HealedJson(_) => {
769                return Err(SimpleAgentsError::Config(
770                    "healed json response returned from complete".to_string(),
771                ))
772            }
773            CompletionOutcome::CoercedSchema(_) => {
774                return Err(SimpleAgentsError::Config(
775                    "schema response returned from complete".to_string(),
776                ))
777            }
778        };
779
780        Ok(response.content().unwrap_or_default().to_string())
781    })
782}
783
784/// Execute a completion request with full message input and return a structured JSON payload.
785///
786/// Use `max_tokens <= 0`, `temperature < 0.0`, or `top_p < 0.0` to omit those options.
787/// `mode` supports `standard`, `healed_json`, and `schema`; when mode is `schema`, `schema_json`
788/// must be a JSON object with the internal schema shape.
789///
790/// # Safety
791///
792/// - `client` must be a pointer returned by `sa_client_new_from_env`.
793/// - `model` must be a valid null-terminated C string.
794/// - `messages` must point to `messages_len` valid `SAMessage` values.
795/// - Returned string must be freed with `sa_string_free`.
796#[no_mangle]
797pub unsafe extern "C" fn sa_complete_messages_json(
798    client: *mut SAClient,
799    model: *const c_char,
800    messages: *const SAMessage,
801    messages_len: usize,
802    max_tokens: i32,
803    temperature: f32,
804    top_p: f32,
805    mode: *const c_char,
806    schema_json: *const c_char,
807) -> *mut c_char {
808    if client.is_null() {
809        set_last_error("client cannot be null".to_string());
810        return std::ptr::null_mut();
811    }
812
813    ffi_guard(|| {
814        let model = cstr_to_string(model, "model")?;
815        let messages = parse_messages(messages, messages_len)?;
816        let request =
817            build_request_from_messages(&model, messages, max_tokens, temperature, top_p)?;
818
819        let mode = cstr_to_optional_string(mode, "mode")?;
820        let schema_json = cstr_to_optional_string(schema_json, "schema_json")?;
821        let options = completion_options(mode.as_deref(), schema_json.as_deref())?;
822
823        let client = &(*client).inner;
824        let runtime = client
825            .runtime
826            .lock()
827            .map_err(|_| SimpleAgentsError::Config("runtime lock poisoned".to_string()))?;
828        let outcome = runtime.block_on(client.client.complete(&request, options))?;
829
830        let payload = match outcome {
831            CompletionOutcome::Response(response) => {
832                completion_result_from_response(response, None, None)
833            }
834            CompletionOutcome::HealedJson(HealedJsonResponse { response, parsed }) => {
835                completion_result_from_response(response, Some(healing_data_from(parsed)), None)
836            }
837            CompletionOutcome::CoercedSchema(HealedSchemaResponse {
838                response,
839                parsed,
840                coerced,
841            }) => completion_result_from_response(
842                response,
843                Some(healing_data_from(parsed)),
844                Some(healing_data_from(coerced)),
845            ),
846            CompletionOutcome::Stream(_) => {
847                return Err(SimpleAgentsError::Config(
848                    "streaming mode is not supported via sa_complete_messages_json".to_string(),
849                ))
850            }
851        };
852
853        serde_json::to_string(&payload)
854            .map_err(|e| SimpleAgentsError::Config(format!("failed to serialize result: {e}")))
855    })
856}
857
858/// Execute a message-based completion request in streaming mode and emit JSON events to a callback.
859///
860/// Returns `0` on success and non-zero on failure. On failure call `sa_last_error_message`.
861///
862/// # Safety
863///
864/// - `client` must be a pointer returned by `sa_client_new_from_env`.
865/// - `model` must be a valid null-terminated C string.
866/// - `messages` must point to `messages_len` valid `SAMessage` values.
867/// - `callback` must point to a valid C function for the duration of the call.
868#[no_mangle]
869pub unsafe extern "C" fn sa_stream_messages(
870    client: *mut SAClient,
871    model: *const c_char,
872    messages: *const SAMessage,
873    messages_len: usize,
874    max_tokens: i32,
875    temperature: f32,
876    top_p: f32,
877    callback: SAStreamCallback,
878    user_data: *mut c_void,
879) -> i32 {
880    if client.is_null() {
881        set_last_error("client cannot be null".to_string());
882        return -1;
883    }
884
885    let Some(callback) = callback else {
886        set_last_error("callback cannot be null".to_string());
887        return -1;
888    };
889
890    ffi_guard_status(|| {
891        let model = cstr_to_string(model, "model")?;
892        let messages = parse_messages(messages, messages_len)?;
893
894        let mut builder = CompletionRequest::builder()
895            .model(&model)
896            .messages(messages);
897        if max_tokens > 0 {
898            builder = builder.max_tokens(max_tokens as u32);
899        }
900        if temperature >= 0.0 {
901            builder = builder.temperature(temperature);
902        }
903        if top_p >= 0.0 {
904            builder = builder.top_p(top_p);
905        }
906        builder = builder.stream(true);
907        let request = builder.build()?;
908
909        let client = &(*client).inner;
910        let runtime = client
911            .runtime
912            .lock()
913            .map_err(|_| SimpleAgentsError::Config("runtime lock poisoned".to_string()))?;
914
915        let outcome = runtime.block_on(
916            client
917                .client
918                .complete(&request, CompletionOptions::default()),
919        )?;
920        let mut stream = match outcome {
921            CompletionOutcome::Stream(stream) => stream,
922            CompletionOutcome::Response(_) => {
923                return Err(SimpleAgentsError::Config(
924                    "non-streaming response returned from sa_stream_messages".to_string(),
925                ))
926            }
927            CompletionOutcome::HealedJson(_) => {
928                return Err(SimpleAgentsError::Config(
929                    "healed json response returned from sa_stream_messages".to_string(),
930                ))
931            }
932            CompletionOutcome::CoercedSchema(_) => {
933                return Err(SimpleAgentsError::Config(
934                    "schema response returned from sa_stream_messages".to_string(),
935                ))
936            }
937        };
938
939        runtime.block_on(async {
940            while let Some(chunk_result) = stream.next().await {
941                match chunk_result {
942                    Ok(chunk) => {
943                        emit_stream_event(callback, user_data, FfiStreamEvent::Chunk { chunk })?;
944                    }
945                    Err(error) => {
946                        let message = error.to_string();
947                        let _ = emit_stream_event(
948                            callback,
949                            user_data,
950                            FfiStreamEvent::Error {
951                                message: message.clone(),
952                            },
953                        );
954                        return Err(error);
955                    }
956                }
957            }
958
959            emit_stream_event(callback, user_data, FfiStreamEvent::Done)
960        })
961    })
962}
963
964/// Execute workflow email YAML through the Rust workflow runner and return JSON output.
965///
966/// # Safety
967///
968/// - `client` must be a pointer returned by `sa_client_new_from_env`.
969/// - `workflow_path` and `email_text` must be valid null-terminated UTF-8 strings.
970/// - Returned string must be freed with `sa_string_free`.
971#[no_mangle]
972pub unsafe extern "C" fn sa_run_email_workflow_yaml(
973    client: *mut SAClient,
974    workflow_path: *const c_char,
975    email_text: *const c_char,
976) -> *mut c_char {
977    if client.is_null() {
978        set_last_error("client cannot be null".to_string());
979        return std::ptr::null_mut();
980    }
981
982    ffi_guard(|| {
983        let workflow_path = cstr_to_string(workflow_path, "workflow_path")?;
984        let email_text = cstr_to_string(email_text, "email_text")?;
985
986        let client = &(*client).inner;
987        let runtime = client
988            .runtime
989            .lock()
990            .map_err(|_| SimpleAgentsError::Config("runtime lock poisoned".to_string()))?;
991
992        let output = runtime
993            .block_on(run_email_workflow_yaml_file_with_client(
994                std::path::Path::new(workflow_path.as_str()),
995                email_text.as_str(),
996                &client.client,
997            ))
998            .map_err(|error| {
999                SimpleAgentsError::Config(format!("failed to run workflow yaml: {error}"))
1000            })?;
1001
1002        serde_json::to_string(&output)
1003            .map_err(|e| SimpleAgentsError::Config(format!("failed to serialize result: {e}")))
1004    })
1005}
1006
1007/// Execute workflow YAML with arbitrary workflow input JSON and return JSON output.
1008///
1009/// # Safety
1010///
1011/// - `client` must be a pointer returned by `sa_client_new_from_env`.
1012/// - `workflow_path` and `workflow_input_json` must be valid null-terminated UTF-8 strings.
1013/// - `workflow_input_json` must be a valid JSON object string.
1014/// - Returned string must be freed with `sa_string_free`.
1015#[no_mangle]
1016pub unsafe extern "C" fn sa_run_workflow_yaml(
1017    client: *mut SAClient,
1018    workflow_path: *const c_char,
1019    workflow_input_json: *const c_char,
1020) -> *mut c_char {
1021    sa_run_workflow_yaml_with_options(client, workflow_path, workflow_input_json, std::ptr::null())
1022}
1023
1024/// Execute workflow YAML with arbitrary input JSON and optional telemetry options JSON.
1025///
1026/// `workflow_options_json` accepts a `YamlWorkflowRunOptions` JSON object and may be null.
1027///
1028/// # Safety
1029///
1030/// - `client` must be a pointer returned by `sa_client_new_from_env` and remain valid for the call.
1031/// - `workflow_path` and `workflow_input_json` must be valid null-terminated UTF-8 strings.
1032/// - `workflow_input_json` must be a valid JSON object string.
1033/// - `workflow_options_json` may be null; when non-null it must be valid null-terminated UTF-8 JSON.
1034/// - Returned string must be freed with `sa_string_free`.
1035#[no_mangle]
1036pub unsafe extern "C" fn sa_run_workflow_yaml_with_options(
1037    client: *mut SAClient,
1038    workflow_path: *const c_char,
1039    workflow_input_json: *const c_char,
1040    workflow_options_json: *const c_char,
1041) -> *mut c_char {
1042    if client.is_null() {
1043        set_last_error("client cannot be null".to_string());
1044        return std::ptr::null_mut();
1045    }
1046
1047    ffi_guard(|| {
1048        let workflow_path = cstr_to_string(workflow_path, "workflow_path")?;
1049        let workflow_input_json = cstr_to_string(workflow_input_json, "workflow_input_json")?;
1050        let workflow_options_json =
1051            cstr_to_optional_string(workflow_options_json, "workflow_options_json")?;
1052        let workflow_input: JsonValue =
1053            serde_json::from_str(&workflow_input_json).map_err(|e| {
1054                SimpleAgentsError::Config(format!("workflow_input_json must be valid JSON: {e}"))
1055            })?;
1056        if !workflow_input.is_object() {
1057            return Err(SimpleAgentsError::Config(
1058                "workflow_input_json must decode to a JSON object".to_string(),
1059            ));
1060        }
1061        let workflow_options = parse_workflow_run_options(workflow_options_json)?;
1062
1063        let client = &(*client).inner;
1064        let runtime = client
1065            .runtime
1066            .lock()
1067            .map_err(|_| SimpleAgentsError::Config("runtime lock poisoned".to_string()))?;
1068
1069        let output = runtime
1070            .block_on(
1071                run_workflow_yaml_file_with_client_and_custom_worker_and_events_and_options(
1072                    std::path::Path::new(workflow_path.as_str()),
1073                    &workflow_input,
1074                    &client.client,
1075                    None,
1076                    None,
1077                    &workflow_options,
1078                ),
1079            )
1080            .map_err(|error| {
1081                SimpleAgentsError::Config(format!("failed to run workflow yaml: {error}"))
1082            })?;
1083
1084        serde_json::to_string(&output)
1085            .map_err(|e| SimpleAgentsError::Config(format!("failed to serialize result: {e}")))
1086    })
1087}
1088
1089/// Execute workflow YAML and include collected workflow events in the JSON output under `events`.
1090///
1091/// # Safety
1092///
1093/// - `client` must be a pointer returned by `sa_client_new_from_env` and remain valid for the call.
1094/// - `workflow_path` and `workflow_input_json` must be valid null-terminated UTF-8 strings.
1095/// - `workflow_input_json` must decode to a JSON object.
1096/// - `workflow_options_json` may be null.
1097/// - Returned string must be freed with `sa_string_free`.
1098#[no_mangle]
1099pub unsafe extern "C" fn sa_run_workflow_yaml_with_events(
1100    client: *mut SAClient,
1101    workflow_path: *const c_char,
1102    workflow_input_json: *const c_char,
1103    workflow_options_json: *const c_char,
1104) -> *mut c_char {
1105    if client.is_null() {
1106        set_last_error("client cannot be null".to_string());
1107        return std::ptr::null_mut();
1108    }
1109
1110    ffi_guard(|| {
1111        let workflow_path = cstr_to_string(workflow_path, "workflow_path")?;
1112        let workflow_input_json = cstr_to_string(workflow_input_json, "workflow_input_json")?;
1113        let workflow_options_json =
1114            cstr_to_optional_string(workflow_options_json, "workflow_options_json")?;
1115        let workflow_input: JsonValue =
1116            serde_json::from_str(&workflow_input_json).map_err(|e| {
1117                SimpleAgentsError::Config(format!("workflow_input_json must be valid JSON: {e}"))
1118            })?;
1119        if !workflow_input.is_object() {
1120            return Err(SimpleAgentsError::Config(
1121                "workflow_input_json must decode to a JSON object".to_string(),
1122            ));
1123        }
1124        let workflow_options = parse_workflow_run_options(workflow_options_json)?;
1125
1126        let client = &(*client).inner;
1127        let runtime = client
1128            .runtime
1129            .lock()
1130            .map_err(|_| SimpleAgentsError::Config("runtime lock poisoned".to_string()))?;
1131
1132        let event_sink = RecordingWorkflowEventSink::new();
1133        let output = runtime
1134            .block_on(
1135                run_workflow_yaml_file_with_client_and_custom_worker_and_events_and_options(
1136                    std::path::Path::new(workflow_path.as_str()),
1137                    &workflow_input,
1138                    &client.client,
1139                    None,
1140                    Some(&event_sink),
1141                    &workflow_options,
1142                ),
1143            )
1144            .map_err(|error| {
1145                SimpleAgentsError::Config(format!("failed to run workflow yaml: {error}"))
1146            })?;
1147
1148        let mut output_value = serde_json::to_value(output)
1149            .map_err(|e| SimpleAgentsError::Config(format!("failed to serialize result: {e}")))?;
1150        event_sink.attach_to_output(&mut output_value)?;
1151        serde_json::to_string(&output_value)
1152            .map_err(|e| SimpleAgentsError::Config(format!("failed to serialize result: {e}")))
1153    })
1154}
1155
1156/// Execute workflow YAML and emit live workflow events to a callback while returning final output.
1157///
1158/// # Safety
1159///
1160/// - `client` must be a pointer returned by `sa_client_new_from_env` and remain valid for the call.
1161/// - `workflow_path` and `workflow_input_json` must be valid null-terminated UTF-8 strings.
1162/// - `workflow_input_json` must decode to a JSON object.
1163/// - `workflow_options_json` may be null.
1164/// - `callback` must be non-null for the duration of the call.
1165/// - Returned string must be freed with `sa_string_free`.
1166#[no_mangle]
1167pub unsafe extern "C" fn sa_run_workflow_yaml_stream_events(
1168    client: *mut SAClient,
1169    workflow_path: *const c_char,
1170    workflow_input_json: *const c_char,
1171    workflow_options_json: *const c_char,
1172    callback: SAWorkflowEventCallback,
1173    user_data: *mut c_void,
1174) -> *mut c_char {
1175    if client.is_null() {
1176        set_last_error("client cannot be null".to_string());
1177        return std::ptr::null_mut();
1178    }
1179
1180    let Some(callback) = callback else {
1181        set_last_error("callback cannot be null".to_string());
1182        return std::ptr::null_mut();
1183    };
1184
1185    ffi_guard(|| {
1186        let workflow_path = cstr_to_string(workflow_path, "workflow_path")?;
1187        let workflow_input_json = cstr_to_string(workflow_input_json, "workflow_input_json")?;
1188        let workflow_options_json =
1189            cstr_to_optional_string(workflow_options_json, "workflow_options_json")?;
1190        let workflow_input: JsonValue =
1191            serde_json::from_str(&workflow_input_json).map_err(|e| {
1192                SimpleAgentsError::Config(format!("workflow_input_json must be valid JSON: {e}"))
1193            })?;
1194        if !workflow_input.is_object() {
1195            return Err(SimpleAgentsError::Config(
1196                "workflow_input_json must decode to a JSON object".to_string(),
1197            ));
1198        }
1199        let workflow_options = parse_workflow_run_options(workflow_options_json)?;
1200
1201        let client = &(*client).inner;
1202        let runtime = client
1203            .runtime
1204            .lock()
1205            .map_err(|_| SimpleAgentsError::Config("runtime lock poisoned".to_string()))?;
1206
1207        let event_sink = CallbackWorkflowEventSink::new(callback, user_data);
1208        let output = runtime
1209            .block_on(
1210                run_workflow_yaml_file_with_client_and_custom_worker_and_events_and_options(
1211                    std::path::Path::new(workflow_path.as_str()),
1212                    &workflow_input,
1213                    &client.client,
1214                    None,
1215                    Some(&event_sink),
1216                    &workflow_options,
1217                ),
1218            )
1219            .map_err(|error| {
1220                SimpleAgentsError::Config(format!("failed to run workflow yaml: {error}"))
1221            })?;
1222
1223        if event_sink.callback_failed() {
1224            return Err(SimpleAgentsError::Config(
1225                "workflow event callback returned non-zero status or failed to serialize payload"
1226                    .to_string(),
1227            ));
1228        }
1229
1230        serde_json::to_string(&output)
1231            .map_err(|e| SimpleAgentsError::Config(format!("failed to serialize result: {e}")))
1232    })
1233}
1234
1235/// Get the last error message for the current thread.
1236///
1237/// Returns null if there is no error. Caller must free the string.
1238#[no_mangle]
1239pub extern "C" fn sa_last_error_message() -> *mut c_char {
1240    match take_last_error() {
1241        Some(message) => match CString::new(message) {
1242            Ok(c_string) => c_string.into_raw(),
1243            Err(_) => std::ptr::null_mut(),
1244        },
1245        None => std::ptr::null_mut(),
1246    }
1247}
1248
1249/// Free a string returned by SimpleAgents FFI.
1250///
1251/// # Safety
1252///
1253/// The `value` pointer must be null or a valid pointer returned by a SimpleAgents FFI function.
1254/// After calling this function, the pointer is no longer valid and must not be used.
1255#[no_mangle]
1256pub unsafe extern "C" fn sa_string_free(value: *mut c_char) {
1257    if value.is_null() {
1258        return;
1259    }
1260
1261    drop(CString::from_raw(value));
1262}