diff --git a/runtime/rust/prompty-anthropic/src/processor.rs b/runtime/rust/prompty-anthropic/src/processor.rs index 7062fe9d..e2b6770d 100644 --- a/runtime/rust/prompty-anthropic/src/processor.rs +++ b/runtime/rust/prompty-anthropic/src/processor.rs @@ -99,16 +99,9 @@ pub fn process_response(agent: &Prompty, response: &Value) -> Result(&text) { - Ok(parsed) => { - return Ok(parsed); - } - Err(e) => { - return Err(InvokerError::Process( - format!("Failed to parse structured output: {e}").into(), - )); - } + // Per spec §8.1: try JSON parse, fall back to raw string on failure + if let Ok(parsed) = serde_json::from_str::(&text) { + return Ok(parsed); } } @@ -235,6 +228,18 @@ impl futures::Stream for AnthropicStreamProcessor { } } } + "thinking_delta" => { + // Per spec §13.1: emit Thinking events for reasoning/chain-of-thought + if let Some(thinking) = + delta.get("thinking").and_then(Value::as_str) + { + if !thinking.is_empty() { + return Poll::Ready(Some( + StreamChunk::Thinking(thinking.to_string()), + )); + } + } + } _ => {} } } diff --git a/runtime/rust/prompty-openai/src/processor.rs b/runtime/rust/prompty-openai/src/processor.rs index 6db30bee..1ff82c9b 100644 --- a/runtime/rust/prompty-openai/src/processor.rs +++ b/runtime/rust/prompty-openai/src/processor.rs @@ -336,11 +336,14 @@ impl futures::Stream for OpenAIStreamProcessor { } } - // Refusal + // Refusal — per spec §10.3: MUST raise error, stream MUST NOT continue if let Some(refusal) = delta.get("refusal").and_then(Value::as_str) { - return std::task::Poll::Ready(Some(StreamChunk::Text(format!( - "Model refused: {refusal}" - )))); + if !refusal.is_empty() { + this.phase = StreamPhase::Done; + return std::task::Poll::Ready(Some(StreamChunk::Error( + format!("Model refused: {refusal}"), + ))); + } } } @@ -713,6 +716,7 @@ mod tests { match chunk { StreamChunk::Text(t) => texts.push(t), StreamChunk::Tool(_) => panic!("unexpected tool call"), + _ => {} } } assert_eq!(texts.join(""), "Hello world"); @@ -736,6 +740,7 @@ mod tests { match chunk { StreamChunk::Text(_) => {} StreamChunk::Tool(tc) => tools.push(tc), + _ => {} } } assert_eq!(tools.len(), 1); @@ -750,14 +755,14 @@ mod tests { let chunks = vec![json!({"choices": [{"delta": {"refusal": "I cannot help with that"}}]})]; let inner = futures::stream::iter(chunks); let mut stream = process_stream(inner); - let mut texts = Vec::new(); + let mut errors = Vec::new(); while let Some(chunk) = stream.next().await { - if let StreamChunk::Text(t) = chunk { - texts.push(t); + if let StreamChunk::Error(e) = chunk { + errors.push(e); } } - assert_eq!(texts.len(), 1); - assert!(texts[0].contains("refused")); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("refused")); } #[tokio::test] @@ -793,6 +798,7 @@ mod tests { match chunk { StreamChunk::Text(t) => texts.push(t), StreamChunk::Tool(tc) => tools.push(tc), + _ => {} } } assert_eq!(texts.join(""), "Let me check..."); diff --git a/runtime/rust/prompty-openai/src/wire.rs b/runtime/rust/prompty-openai/src/wire.rs index a4e4c5bc..12564264 100644 --- a/runtime/rust/prompty-openai/src/wire.rs +++ b/runtime/rust/prompty-openai/src/wire.rs @@ -58,7 +58,7 @@ fn part_to_wire(part: &ContentPart) -> Value { .media_type .as_deref() .map(mime_to_audio_format) - .unwrap_or("wav"); + .unwrap_or_else(|| "wav".to_string()); json!({ "type": "input_audio", "input_audio": { @@ -74,16 +74,17 @@ fn part_to_wire(part: &ContentPart) -> Value { } } -fn mime_to_audio_format(mime: &str) -> &str { +fn mime_to_audio_format(mime: &str) -> String { match mime { - "audio/wav" | "audio/x-wav" => "wav", - "audio/mpeg" | "audio/mp3" => "mp3", - "audio/mp4" => "mp4", - "audio/ogg" => "ogg", - "audio/flac" => "flac", - "audio/webm" => "webm", - "audio/pcm" => "pcm", - _ => "wav", + "audio/wav" | "audio/x-wav" => "wav".to_string(), + "audio/mpeg" | "audio/mp3" => "mp3".to_string(), + "audio/mp4" => "mp4".to_string(), + "audio/ogg" => "ogg".to_string(), + "audio/flac" => "flac".to_string(), + "audio/webm" => "webm".to_string(), + "audio/pcm" => "pcm".to_string(), + // Per spec §7.1.2: strip "audio/" prefix for unmapped types + other => other.strip_prefix("audio/").unwrap_or("wav").to_string(), } } @@ -766,6 +767,11 @@ mod tests { assert_eq!(mime_to_audio_format("audio/flac"), "flac"); assert_eq!(mime_to_audio_format("audio/webm"), "webm"); assert_eq!(mime_to_audio_format("audio/pcm"), "pcm"); + // Per spec §7.1.2: unmapped audio/* types strip the prefix + assert_eq!(mime_to_audio_format("audio/aac"), "aac"); + assert_eq!(mime_to_audio_format("audio/opus"), "opus"); + // Non-audio MIME falls back to "wav" + assert_eq!(mime_to_audio_format("text/plain"), "wav"); } #[test] diff --git a/runtime/rust/prompty/src/pipeline.rs b/runtime/rust/prompty/src/pipeline.rs index e326e25d..80c179a4 100644 --- a/runtime/rust/prompty/src/pipeline.rs +++ b/runtime/rust/prompty/src/pipeline.rs @@ -668,7 +668,11 @@ impl TurnOptions { fn emit(&self, event: AgentEvent) { if let Some(ref cb) = self.on_event { - cb(event); + // Per spec §13.1: event callbacks MUST NOT block the loop. + // If a callback panics, log and continue. + if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cb(event))) { + eprintln!("[prompty] Event callback panicked: {e:?}"); + } } } @@ -755,9 +759,20 @@ pub async fn turn( let mut text_parts = Vec::new(); futures::pin_mut!(chunk_stream); while let Some(chunk) = chunk_stream.next().await { - if let StreamChunk::Text(t) = chunk { - opts.emit(AgentEvent::Token(t.clone())); - text_parts.push(t); + match chunk { + StreamChunk::Text(t) => { + opts.emit(AgentEvent::Token(t.clone())); + text_parts.push(t); + } + StreamChunk::Thinking(t) => { + opts.emit(AgentEvent::Thinking(t)); + } + StreamChunk::Error(e) => { + span.emit("error", &json!(e)); + span.end(); + return Err(InvokerError::Execute(e.into())); + } + _ => {} } } json!(text_parts.join("")) @@ -842,11 +857,18 @@ pub async fn turn( iter_span.emit("iteration", &json!(iteration)); // Drain steering messages - if let Some(ref mut steering) = opts.steering { + if let Some(ref steering) = opts.steering { let steering_msgs = steering.drain(); if !steering_msgs.is_empty() { - iter_span.emit("steering_messages", &json!(steering_msgs.len())); + let count = steering_msgs.len(); + iter_span.emit("steering_messages", &json!(count)); messages.extend(steering_msgs); + opts.emit(AgentEvent::Status(format!( + "Injected {count} steering message(s)" + ))); + opts.emit(AgentEvent::MessagesUpdated { + messages: messages.clone(), + }); } } @@ -855,8 +877,13 @@ pub async fn turn( let (dropped, trimmed) = crate::context::trim_to_context_window(&messages, budget); if dropped > 0 { iter_span.emit("context_trimmed", &json!(dropped)); + messages = trimmed; + opts.emit(AgentEvent::MessagesUpdated { + messages: messages.clone(), + }); + } else { + messages = trimmed; } - messages = trimmed; } // Input guardrail @@ -887,6 +914,7 @@ pub async fn turn( use futures::StreamExt; let mut tool_calls_vec = Vec::new(); let mut text_parts = Vec::new(); + let mut stream_error = None; futures::pin_mut!(chunk_stream); while let Some(chunk) = chunk_stream.next().await { match chunk { @@ -894,11 +922,24 @@ pub async fn turn( opts.emit(AgentEvent::Token(t.clone())); text_parts.push(t); } + StreamChunk::Thinking(t) => { + opts.emit(AgentEvent::Thinking(t)); + } StreamChunk::Tool(tc) => { tool_calls_vec.push(tc); } + StreamChunk::Error(e) => { + stream_error = Some(e); + break; + } } } + if let Some(err) = stream_error { + iter_span.end(); + span.emit("error", &json!(err)); + span.end(); + return Err(InvokerError::Execute(err.into())); + } (tool_calls_vec, text_parts.join("")) }; diff --git a/runtime/rust/prompty/src/steering.rs b/runtime/rust/prompty/src/steering.rs index 514cc9c0..20ff1718 100644 --- a/runtime/rust/prompty/src/steering.rs +++ b/runtime/rust/prompty/src/steering.rs @@ -1,9 +1,10 @@ //! Steering — inject messages between agent loop iterations. //! -//! Matches TypeScript `core/steering.ts`. A simple FIFO string queue +//! Matches TypeScript `core/steering.ts`. A thread-safe FIFO string queue //! that converts queued strings to `user` messages when drained. use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; use crate::types::{Message, Role}; @@ -11,13 +12,16 @@ use crate::types::{Message, Role}; // Steering // --------------------------------------------------------------------------- -/// A FIFO queue of steering messages to inject into the agent loop. +/// A thread-safe FIFO queue of steering messages to inject into the agent loop. +/// +/// Per spec §13.5, `send()` MUST be safe to call from any thread while the +/// agent loop is running, and `drain()` MUST atomically remove all messages. /// /// Usage: /// ``` /// use prompty::steering::Steering; /// -/// let mut s = Steering::new(); +/// let s = Steering::new(); /// s.send("Please use the search tool first."); /// s.send("Remember to cite sources."); /// @@ -25,28 +29,34 @@ use crate::types::{Message, Role}; /// let messages = s.drain(); /// // → [Message { role: User, content: "Please use..." }, Message { role: User, content: "Remember..." }] /// ``` +#[derive(Clone)] pub struct Steering { - queue: VecDeque, + queue: Arc>>, } impl Steering { /// Create a new empty Steering queue. pub fn new() -> Self { Self { - queue: VecDeque::new(), + queue: Arc::new(Mutex::new(VecDeque::new())), } } - /// Enqueue a steering message (as a plain string). - pub fn send(&mut self, message: impl Into) { - self.queue.push_back(message.into()); + /// Enqueue a steering message (as a plain string). Thread-safe. + pub fn send(&self, message: impl Into) { + self.queue + .lock() + .expect("steering lock poisoned") + .push_back(message.into()); } - /// Drain all queued messages, converting them to `user` Messages. + /// Atomically drain all queued messages, converting them to `user` Messages. /// /// The queue is emptied after calling this. - pub fn drain(&mut self) -> Vec { + pub fn drain(&self) -> Vec { self.queue + .lock() + .expect("steering lock poisoned") .drain(..) .map(|text| Message::text(Role::User, &text)) .collect() @@ -54,17 +64,24 @@ impl Steering { /// Check if there are pending steering messages. pub fn has_pending(&self) -> bool { - !self.queue.is_empty() + !self + .queue + .lock() + .expect("steering lock poisoned") + .is_empty() } /// Get the number of pending messages. pub fn len(&self) -> usize { - self.queue.len() + self.queue.lock().expect("steering lock poisoned").len() } /// Check if the queue is empty. pub fn is_empty(&self) -> bool { - self.queue.is_empty() + self.queue + .lock() + .expect("steering lock poisoned") + .is_empty() } } @@ -92,7 +109,7 @@ mod tests { #[test] fn test_send_and_drain() { - let mut s = Steering::new(); + let s = Steering::new(); s.send("First message"); s.send("Second message"); @@ -112,14 +129,14 @@ mod tests { #[test] fn test_drain_empty() { - let mut s = Steering::new(); + let s = Steering::new(); let msgs = s.drain(); assert!(msgs.is_empty()); } #[test] fn test_fifo_order() { - let mut s = Steering::new(); + let s = Steering::new(); s.send("A"); s.send("B"); s.send("C"); @@ -135,4 +152,17 @@ mod tests { let s = Steering::default(); assert!(s.is_empty()); } + + #[test] + fn test_thread_safe_send() { + let s = Steering::new(); + let s2 = s.clone(); + let handle = std::thread::spawn(move || { + s2.send("from another thread"); + }); + handle.join().unwrap(); + assert_eq!(s.len(), 1); + let msgs = s.drain(); + assert_eq!(msgs[0].text_content(), "from another thread"); + } } diff --git a/runtime/rust/prompty/src/types.rs b/runtime/rust/prompty/src/types.rs index dde831f0..8e5eb822 100644 --- a/runtime/rust/prompty/src/types.rs +++ b/runtime/rust/prompty/src/types.rs @@ -427,8 +427,12 @@ impl Drop for PromptyStream { pub enum StreamChunk { /// A text content token. Text(String), + /// A thinking/reasoning token from the LLM. + Thinking(String), /// A completed tool call (yielded at end of stream). Tool(ToolCall), + /// A stream error (e.g., model refusal). Consumers MUST stop iteration. + Error(String), } // --------------------------------------------------------------------------- @@ -457,9 +461,16 @@ pub async fn consume_stream_chunks( } text_parts.push(t); } + StreamChunk::Thinking(_) => { + // Thinking tokens are informational; not collected into output + } StreamChunk::Tool(tc) => { tool_calls.push(tc); } + StreamChunk::Error(_) => { + // Error chunks signal stream termination; stop consuming + break; + } } } diff --git a/web/docs-examples/rust/Cargo.toml b/web/docs-examples/rust/Cargo.toml new file mode 100644 index 00000000..b9aac22a --- /dev/null +++ b/web/docs-examples/rust/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "prompty-docs-examples" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +prompty = { version = "2.0.0-alpha.8" } +prompty-openai = { version = "2.0.0-alpha.8" } +serde_json = "1" +tokio = { version = "1", features = ["full"] } + +[[example]] +name = "chat_basic" +path = "examples/chat_basic.rs" + +[[example]] +name = "streaming" +path = "examples/streaming.rs" + +[[example]] +name = "agent_tool_calling" +path = "examples/agent_tool_calling.rs" + +[[example]] +name = "structured_output" +path = "examples/structured_output.rs" diff --git a/web/docs-examples/rust/examples/agent_tool_calling.rs b/web/docs-examples/rust/examples/agent_tool_calling.rs new file mode 100644 index 00000000..64fe27e0 --- /dev/null +++ b/web/docs-examples/rust/examples/agent_tool_calling.rs @@ -0,0 +1,45 @@ +use prompty::TurnOptions; +use serde_json::json; +use std::sync::Arc; + +#[tokio::main] +async fn main() -> Result<(), Box> { + prompty::register_defaults(); + prompty_openai::register(); + + // Register tool handlers + prompty::register_tool_handler("get_weather", |args| { + Box::pin(async move { + let city = args["city"].as_str().unwrap_or("unknown"); + Ok(json!(format!("72°F and sunny in {city}"))) + }) + }); + + prompty::register_tool_handler("get_time", |args| { + Box::pin(async move { + let timezone = args["timezone"].as_str().unwrap_or("UTC"); + Ok(json!(format!("2025-01-15T10:30:00 {timezone}"))) + }) + }); + + // Load agent and run with tool-calling loop + let agent = prompty::load("weather_agent.prompty")?; + + let options = TurnOptions { + max_iterations: Some(10), + events: Some(Arc::new(|event| { + println!("Agent event: {event:?}"); + })), + ..Default::default() + }; + + let result = prompty::turn( + &agent, + Some(&json!({ "question": "What's the weather in Seattle?" })), + Some(options), + ) + .await?; + + println!("Result: {result}"); + Ok(()) +} diff --git a/web/docs-examples/rust/examples/chat_basic.rs b/web/docs-examples/rust/examples/chat_basic.rs new file mode 100644 index 00000000..29dcab9c --- /dev/null +++ b/web/docs-examples/rust/examples/chat_basic.rs @@ -0,0 +1,20 @@ +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Register built-in renderers/parsers and OpenAI provider + prompty::register_defaults(); + prompty_openai::register(); + + // All-in-one: load → render → parse → execute → process + let result = prompty::invoke_from_path( + "greeting.prompty", + Some(&json!({ "userName": "Jane" })), + ) + .await?; + + println!("{result}"); + // "Hello Jane! 👋 How's your day going so far?" + + Ok(()) +} diff --git a/web/docs-examples/rust/examples/streaming.rs b/web/docs-examples/rust/examples/streaming.rs new file mode 100644 index 00000000..d7255ef3 --- /dev/null +++ b/web/docs-examples/rust/examples/streaming.rs @@ -0,0 +1,28 @@ +use prompty::StreamChunk; +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<(), Box> { + prompty::register_defaults(); + prompty_openai::register(); + + // Load agent and prepare messages + let agent = prompty::load("chat.prompty")?; + let messages = prompty::prepare(&agent, Some(&json!({ "question": "Tell me a joke" }))).await?; + + // Run returns a PromptyStream when stream: true is set + let result = prompty::run(&agent, &messages).await?; + + // Process streaming chunks + let stream = prompty::from_structured_value::(&result)?; + prompty::consume_stream_chunks(stream, |chunk| match chunk { + StreamChunk::Text(text) => print!("{text}"), + StreamChunk::Thinking(thought) => print!("[thinking] {thought}"), + StreamChunk::Tool(tc) => println!("[tool call] {}: {}", tc.name, tc.arguments), + StreamChunk::Error(err) => eprintln!("[error] {err}"), + }) + .await; + + println!(); + Ok(()) +} diff --git a/web/docs-examples/rust/examples/structured_output.rs b/web/docs-examples/rust/examples/structured_output.rs new file mode 100644 index 00000000..a1c997c7 --- /dev/null +++ b/web/docs-examples/rust/examples/structured_output.rs @@ -0,0 +1,23 @@ +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<(), Box> { + prompty::register_defaults(); + prompty_openai::register(); + + // Load a .prompty file with outputSchema defined + let agent = prompty::load("structured.prompty")?; + + let result = prompty::invoke_agent( + &agent, + Some(&json!({ "topic": "Rust programming language" })), + ) + .await?; + + // result is a parsed JSON object matching the outputSchema + println!("Title: {}", result["title"]); + println!("Summary: {}", result["summary"]); + println!("Tags: {}", result["tags"]); + + Ok(()) +} diff --git a/web/src/content/docs/api-reference/index.mdx b/web/src/content/docs/api-reference/index.mdx index 5a0672bc..def87bd4 100644 --- a/web/src/content/docs/api-reference/index.mdx +++ b/web/src/content/docs/api-reference/index.mdx @@ -38,6 +38,13 @@ Parse a `.prompty` file into a typed `Prompty` object. Console.WriteLine(agent.Model.Id); // "gpt-4o" ``` + + ```rust + let agent = prompty::load("chat.prompty")?; + println!("{}", agent.name()); // "chat" + println!("{}", agent.model().id()); // "gpt-4o" + ``` + ### `render(agent, inputs) → str` @@ -64,6 +71,12 @@ before parsing into messages. // "system:\nYou are helpful.\n\nuser:\nHi" ``` + + ```rust + let rendered = prompty::render(&agent, Some(&json!({ "q": "Hi" }))).await?; + // "system:\nYou are helpful.\n\nuser:\nHi" + ``` + ### `parse(agent, rendered) → list[Message]` @@ -89,6 +102,12 @@ Parse a rendered string into structured messages. // List with system and user messages ``` + + ```rust + let messages = prompty::parse(&agent, &rendered).await?; + // Vec with system and user messages + ``` + ### `prepare(agent, inputs) → list[Message]` @@ -113,6 +132,11 @@ wire-ready messages. var messages = await Pipeline.PrepareAsync(agent, new() { ["q"] = "Hi" }); ``` + + ```rust + let messages = prompty::prepare(&agent, Some(&json!({ "q": "Hi" }))).await?; + ``` + ### `run(agent, messages) → result` @@ -144,6 +168,15 @@ Composite: call the LLM executor then process the response. var response = await Pipeline.RunAsync(agent, messages, raw: true); ``` + + ```rust + let result = prompty::run(&agent, &messages).await?; + // "Hello! How can I help you?" + + // Pass raw flag to get the raw SDK response + let response = prompty::run_raw(&agent, &messages).await?; + ``` + ### `process(agent, response) → result` @@ -167,6 +200,11 @@ Extract clean content from a raw LLM response. var result = await Pipeline.ProcessAsync(agent, response); ``` + + ```rust + let result = prompty::process(&agent, &response).await?; + ``` + ### `invoke(path_or_agent, inputs) → result` @@ -190,6 +228,11 @@ One-shot pipeline: load → prepare → execute → process. var result = await Pipeline.InvokeAsync("chat.prompty", new() { ["q"] = "Hi" }); ``` + + ```rust + let result = prompty::invoke_from_path("chat.prompty", Some(&json!({ "q": "Hi" }))).await?; + ``` + ### `validate_inputs(agent, inputs)` @@ -217,6 +260,12 @@ provided. Raises an error if any are missing. // Throws ArgumentException if required fields are missing ``` + + ```rust + prompty::validate_inputs(&agent, &json!({ "name": "Jane" }))?; + // Returns Err(InvokerError) if required fields are missing + ``` + ## Async Variants @@ -256,6 +305,16 @@ provided. Raises an error if any are missing. var result = await Pipeline.RunAsync(agent, messages); // async ``` + + All pipeline functions are async by default — they return `Result`. + Use `.await` on every call: + + ```rust + let agent = prompty::load("chat.prompty")?; // sync + let messages = prompty::prepare(&agent, Some(&inputs)).await?; // async + let result = prompty::run(&agent, &messages).await?; // async + ``` + ## Conversational Turns (Agent Mode) @@ -310,6 +369,29 @@ Run a conversational turn with optional tool-calling loop. ); ``` + + ```rust + use prompty::{self, TurnOptions}; + use serde_json::json; + + fn get_weather(location: &str) -> String { + format!("72°F and sunny in {location}") + } + + prompty::register_tool_handler("get_weather", |args| { + Box::pin(async move { + let loc = args["location"].as_str().unwrap_or("unknown"); + Ok(json!(get_weather(loc))) + }) + }); + + let result = prompty::turn_from_path( + "agent.prompty", + Some(&json!({ "question": "Weather in Seattle?" })), + Some(TurnOptions { max_iterations: Some(10), ..Default::default() }), + ).await?; + ``` + @@ -388,6 +470,22 @@ can reference them via `connection.kind: reference`. ConnectionRegistry.Clear(); // useful in tests ``` + + ```rust + use prompty; + use serde_json::json; + + prompty::register_connection("my-foundry", json!({ + "kind": "key", + "endpoint": std::env::var("AZURE_ENDPOINT")?, + "apiKey": std::env::var("AZURE_API_KEY")?, + })); + + // Lookup and clear + let conn = prompty::get_connection("my-foundry"); + prompty::clear_connections(); // useful in tests + ``` + Then in your `.prompty` file: @@ -440,6 +538,21 @@ Set `stream: true` in model options: } ``` + + ```rust + use prompty; + use serde_json::json; + use futures::StreamExt; + + let agent = prompty::load("chat.prompty")?; + let messages = prompty::prepare(&agent, Some(&json!({}))).await?; + + let mut stream = prompty::run_stream(&agent, &messages).await?; + while let Some(chunk) = stream.next().await { + print!("{chunk}"); + } + ``` + ## Structured Output @@ -499,6 +612,21 @@ from `StructuredResult` when available — no dict→JSON→T round-trip. // Or: await Pipeline.InvokeAsync("weather.prompty", inputs); ``` + + ```rust + use prompty; + use serde::Deserialize; + + #[derive(Deserialize)] + struct Weather { + city: String, + temperature: i32, + } + + let weather: Weather = prompty::cast(&result)?; + // Supports any type implementing serde::Deserialize + ``` + ### Generic `invoke` / `turn` @@ -528,6 +656,27 @@ Cast the final result in one step: new() { ["question"] = "..." }, tools: tools); ``` + + ```rust + use prompty; + use serde::Deserialize; + use serde_json::json; + + #[derive(Deserialize)] + struct Weather { city: String, temperature: i32 } + + let weather: Weather = prompty::invoke_from_path_typed( + "weather.prompty", + Some(&json!({ "city": "Seattle" })), + ).await?; + + let weather2: Weather = prompty::turn_from_path_typed( + "agent.prompty", + Some(&json!({ "question": "..." })), + None, + ).await?; + ``` + ## Tracing @@ -587,6 +736,30 @@ Cast the final result in one step: }); ``` + + ```rust + use prompty::{Tracer, PromptyTracer, console_tracer, trace_async}; + + // Console tracer + Tracer::register("console", console_tracer); + + // JSON file tracer + let pt = PromptyTracer::new("./traces"); + Tracer::register("file", pt.tracer()); + + // OpenTelemetry (feature-gated) + #[cfg(feature = "otel")] + { + prompty::init_otel_stdout(); + Tracer::register("otel", prompty::otel_tracer()); + } + + // Trace your own async functions + let result = trace_async("my-operation", async { + // your code here + }).await; + ``` + ## Key Types diff --git a/web/src/content/docs/contributing/code-guidelines.mdx b/web/src/content/docs/contributing/code-guidelines.mdx index ba520b63..1263c4c8 100644 --- a/web/src/content/docs/contributing/code-guidelines.mdx +++ b/web/src/content/docs/contributing/code-guidelines.mdx @@ -13,7 +13,7 @@ index: 2 import { Tabs, TabItem, Aside } from '@astrojs/starlight/components'; -These guidelines cover coding conventions for the **Python**, **TypeScript**, and **C#** Prompty runtimes. +These guidelines cover coding conventions for the **Python**, **TypeScript**, **C#**, and **Rust** Prompty runtimes. Following them keeps the codebase consistent and makes PRs easier to review. ## Repository Layout @@ -382,6 +382,34 @@ public static class MyProviderExtensions 4. Add a NuGet package spec in the project file. 5. Add a test project `Prompty.MyProvider.Tests`. + +1. Create `prompty-myprovider/` as a new crate in the workspace. +2. Implement the `Executor` and `Processor` traits. +3. Register via the global registry in a `register()` function: + +```rust +// prompty-myprovider/src/lib.rs +use prompty::{register_executor, register_processor, Executor, Processor}; + +pub fn register() { + register_executor("myprovider", MyExecutor); + register_processor("myprovider", MyProcessor); +} +``` + +4. Add the crate to the workspace `Cargo.toml`. +5. Add tests using `#[tokio::test]` with `serial_test` for registry isolation. + +```rust +// Rust ≥ 1.85, edition 2024 +// Async runtime: Tokio +// Serialization: serde + serde_json + serde_yaml +// HTTP: reqwest +// Templates: minijinja (Nunjucks/Jinja2 compatible) +// Error handling: thiserror +// Testing: #[tokio::test] with serial_test for registry isolation +``` + --- diff --git a/web/src/content/docs/core-concepts/agent-extensions.mdx b/web/src/content/docs/core-concepts/agent-extensions.mdx index b517f35c..f20480b3 100644 --- a/web/src/content/docs/core-concepts/agent-extensions.mdx +++ b/web/src/content/docs/core-concepts/agent-extensions.mdx @@ -96,6 +96,31 @@ var result = await Pipeline.TurnAsync( ); ``` + +```rust +use prompty::{TurnOptions, AgentEvent}; +use std::sync::Arc; + +let agent = prompty::load("agent.prompty")?; + +let options = TurnOptions { + events: Some(Arc::new(|event: AgentEvent| { + match &event { + AgentEvent::Status(msg) => println!("Status: {msg}"), + AgentEvent::MessagesUpdated => println!("Messages updated"), + _ => println!("Event: {event:?}"), + } + })), + ..Default::default() +}; + +let result = prompty::turn( + &agent, + Some(&serde_json::json!({"question": "Hello"})), + Some(options), +).await?; +``` + ### Event Types @@ -178,10 +203,38 @@ catch (OperationCanceledException) } ``` + +```rust +use prompty::TurnOptions; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; + +let cancel = Arc::new(AtomicBool::new(false)); +let cancel_clone = cancel.clone(); + +// Cancel from another task after 5 seconds +tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + cancel_clone.store(true, Ordering::SeqCst); +}); + +let options = TurnOptions { + cancellation_token: Some(cancel), + ..Default::default() +}; + +match prompty::turn(&agent, Some(&inputs), Some(options)).await { + Ok(result) => println!("{result}"), + Err(e) if e.to_string().contains("cancelled") => { + println!("Agent loop was cancelled"); + } + Err(e) => return Err(e), +} +``` + @@ -221,6 +274,16 @@ var result = await Pipeline.TurnAsync( ); ``` + +```rust +let options = TurnOptions { + context_window: Some(50_000), + ..Default::default() +}; + +let result = prompty::turn(&agent, Some(&inputs), Some(options)).await?; +``` + ### How Trimming Works @@ -336,6 +399,42 @@ catch (GuardrailError e) } ``` + +```rust +use prompty::{TurnOptions, Guardrails, GuardrailResult}; +use std::sync::Arc; + +let guardrails = Guardrails { + input: vec![Arc::new(|msgs: &[prompty::Message]| { + Box::pin(async move { + for msg in msgs { + if msg.text().to_lowercase().contains("ignore previous instructions") { + return Ok(GuardrailResult::Deny("Prompt injection detected".into())); + } + } + Ok(GuardrailResult::Allow) + }) + })], + output: vec![], + tool: vec![Arc::new(|name: &str, _args: &serde_json::Value| { + Box::pin(async move { + if name == "delete_all_data" { + return Ok(GuardrailResult::Deny("Dangerous tool blocked".into())); + } + Ok(GuardrailResult::Allow) + }) + })], + ..Default::default() +}; + +let options = TurnOptions { + guardrails: Some(guardrails), + ..Default::default() +}; + +let result = prompty::turn(&agent, Some(&inputs), Some(options)).await?; +``` + ### Guardrail Checkpoints @@ -409,6 +508,26 @@ var result = await Pipeline.TurnAsync( ); ``` + +```rust +use prompty::{TurnOptions, Steering}; + +let steering = Steering::new(); + +// Inject a message that will be picked up at the next iteration +steering.send(prompty::Message::text( + prompty::Role::User, + "Actually, check Paris instead of London", +)); + +let options = TurnOptions { + steering: Some(steering), + ..Default::default() +}; + +let result = prompty::turn(&agent, Some(&inputs), Some(options)).await?; +``` + At the top of each iteration, the loop calls `steering.drain()` to collect all @@ -450,6 +569,17 @@ var result = await Pipeline.TurnAsync( ); ``` + +```rust +// Uses tokio::join! for concurrent execution +let options = TurnOptions { + parallel_tool_calls: Some(true), + ..Default::default() +}; + +let result = prompty::turn(&agent, Some(&inputs), Some(options)).await?; +``` +