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