From ccce513c5535673263e247c437239c544748eb3f Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Fri, 1 May 2026 19:23:22 +0200 Subject: [PATCH 1/3] 0.3.0: capture function doc strings in `evolvable!` macro, keep them in `EvolvableDecl.full_source` to add more prompt context. --- Cargo.lock | 4 ++-- examples/counter/src/main.rs | 7 +++++-- symbiont-macros/Cargo.toml | 2 +- symbiont-macros/src/lib.rs | 24 ++++++++++++++++++++---- symbiont/Cargo.toml | 4 ++-- symbiont/src/decl.rs | 3 ++- symbiont/src/runtime.rs | 7 ++++++- 7 files changed, 38 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9061120..80d4762 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2918,7 +2918,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symbiont" -version = "0.2.0" +version = "0.3.0" dependencies = [ "criterion", "libloading", @@ -2937,7 +2937,7 @@ dependencies = [ [[package]] name = "symbiont-macros" -version = "0.2.0" +version = "0.3.0" dependencies = [ "proc-macro2", "quote", diff --git a/examples/counter/src/main.rs b/examples/counter/src/main.rs index 278af72..64009a6 100644 --- a/examples/counter/src/main.rs +++ b/examples/counter/src/main.rs @@ -12,8 +12,11 @@ use tracing::info; // The starting function definition, used during constrained generation, // where the LLM model will implement the function body. -// The body can be empty too. The Agent will only see the function signature. +// The body can be empty too. +// If prompting with `fn_sigs`, then the Agent will only see the function signature. This is used here. +// If prompting with `fn_full_sources`, then the Agent will see the entire function, including docs and default body. (Not shown here.) symbiont::evolvable! { + /// Should increment the counter by a value in the range 5..20 fn step(counter: &mut usize) { *counter += 1; println!("doing stuff in iteration {}", counter); @@ -25,7 +28,7 @@ async fn main() -> symbiont::Result<()> { symbiont::init_tracing(); let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug).await?; - let fn_sigs = runtime.fn_sigs(); + let fn_sigs = runtime.fn_sigs(); // Alternatively, `fn_full_sources` can be used to also show doc string and default function body. info!("fn_sigs: {fn_sigs:?}"); let agent = symbiont::inference::init_agent()?; diff --git a/symbiont-macros/Cargo.toml b/symbiont-macros/Cargo.toml index 51826c4..1aac48a 100644 --- a/symbiont-macros/Cargo.toml +++ b/symbiont-macros/Cargo.toml @@ -7,7 +7,7 @@ keywords = ["agent", "dylib", "dynamic", "harness", "llm"] license = "MPL-2.0" name = "symbiont-macros" repository = "https://github.com/MathisWellmann/symbiont" -version = "0.2.0" +version = "0.3.0" [lints] workspace = true diff --git a/symbiont-macros/src/lib.rs b/symbiont-macros/src/lib.rs index eb7bb5d..7cd7287 100644 --- a/symbiont-macros/src/lib.rs +++ b/symbiont-macros/src/lib.rs @@ -47,6 +47,13 @@ impl EvolvableFn { EvolvableFn::WithoutBody(f) => &f.vis, } } + + fn attrs(&self) -> &[syn::Attribute] { + match self { + EvolvableFn::WithBody(f) => &f.attrs, + EvolvableFn::WithoutBody(f) => &f.attrs, + } + } } /// The contents of an `evolvable! { ... }` block: zero or more function declarations. @@ -117,10 +124,10 @@ fn normalize_tokens(mut s: String) -> String { s } -/// Build the `default_source` string for the dylib from a function declaration. +/// Build the `full_source` string for the dylib from a function declaration. /// /// Forces `pub` visibility and prepends `#[unsafe(no_mangle)]`. -fn build_default_source(func: &EvolvableFn) -> String { +fn build_full_source(func: &EvolvableFn) -> String { let sig = func.sig(); // Keep the body as a TokenStream so `quote!` splices it as code, not as a string literal. @@ -136,7 +143,16 @@ fn build_default_source(func: &EvolvableFn) -> String { let output = &sig.output; let ident = &sig.ident; + // Preserve doc comments (`///` and `#[doc = "..."]`) on the generated function so + // they are available in the dylib's source for tooling and documentation purposes. + let doc_attrs = Vec::<&syn::Attribute>::from_iter( + func.attrs() + .iter() + .filter(|attr| attr.path().is_ident("doc")), + ); + let fn_source = quote! { + #(#doc_attrs)* #[unsafe(no_mangle)] pub fn #ident(#inputs) #output #body_tokens }; @@ -183,7 +199,7 @@ pub fn evolvable(input: TokenStream) -> TokenStream { let ident = &sig.ident; let fn_name_str = ident.to_string(); let signature_str = format_signature(sig); - let default_source = build_default_source(func); + let full_source = build_full_source(func); // Generate a unique static name for the cached function pointer let static_name = syn::Ident::new( @@ -221,7 +237,7 @@ pub fn evolvable(input: TokenStream) -> TokenStream { ::symbiont::EvolvableDecl { name: #fn_name_str, signature: #signature_str, - default_source: #default_source, + full_source: #full_source, fn_ptr: &#static_name, } }); diff --git a/symbiont/Cargo.toml b/symbiont/Cargo.toml index ba42c5c..1af7f1d 100644 --- a/symbiont/Cargo.toml +++ b/symbiont/Cargo.toml @@ -9,13 +9,13 @@ license = "MPL-2.0" name = "symbiont" readme = "README.md" repository = "https://github.com/MathisWellmann/symbiont" -version = "0.2.0" +version = "0.3.0" [lints] workspace = true [dependencies] -symbiont-macros = { version = "0.2.0", path = "../symbiont-macros" } +symbiont-macros = { version = "0.3.0", path = "../symbiont-macros" } rig-core.workspace = true tokio.workspace = true diff --git a/symbiont/src/decl.rs b/symbiont/src/decl.rs index 7204844..4e71933 100644 --- a/symbiont/src/decl.rs +++ b/symbiont/src/decl.rs @@ -5,13 +5,14 @@ use std::sync::atomic::AtomicPtr; /// /// Contains the metadata needed to create the temporary dylib crate and validate /// LLM-generated code against the expected function signatures. +#[derive(Debug)] pub struct EvolvableDecl { /// Function name (e.g., `"step"`). pub name: &'static str, /// Formatted signature string (e.g., `"fn step(counter: &mut usize)"`). pub signature: &'static str, /// Full source code for the dylib, including `#[unsafe(no_mangle)]` and `pub`. - pub default_source: &'static str, + pub full_source: &'static str, /// Pointer to the per-function `AtomicPtr<()>` that caches the loaded symbol. /// Updated by the runtime on init and each reload. pub fn_ptr: &'static AtomicPtr<()>, diff --git a/symbiont/src/runtime.rs b/symbiont/src/runtime.rs index e0e6111..10e25dd 100644 --- a/symbiont/src/runtime.rs +++ b/symbiont/src/runtime.rs @@ -232,6 +232,11 @@ impl Runtime { &self.fn_sigs } + /// Get the full function signatures, including doc comments and default function body. + pub fn fn_full_sources(&self) -> Vec<&'static str> { + Vec::from_iter(self.decls.iter().map(|d| d.full_source)) + } + /// Generate LLM response, then parse, validate, compile, and hot-swap. /// It does not catch validation errors and feed it back to the LLM, allowing the user to customize prompting behaviour. /// @@ -419,7 +424,7 @@ panic = "unwind" fn generate_lib_rs(decls: &[EvolvableDecl]) -> String { let mut src = String::with_capacity(1_000); for d in decls { - src.push_str(d.default_source); + src.push_str(d.full_source); src.push_str("\n\n"); } src From 66a4c83bcd370afc805bf6f4d42680098d6a78d2 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sat, 2 May 2026 00:02:55 +0200 Subject: [PATCH 2/3] improve doc rendering, using `///` instead of `#[doc = "..."]`. --- examples/counter/src/main.rs | 1 + symbiont-macros/src/lib.rs | 39 ++++++++++++++++++++++++++---------- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/examples/counter/src/main.rs b/examples/counter/src/main.rs index 64009a6..2e46124 100644 --- a/examples/counter/src/main.rs +++ b/examples/counter/src/main.rs @@ -27,6 +27,7 @@ symbiont::evolvable! { async fn main() -> symbiont::Result<()> { symbiont::init_tracing(); + info!("SYMBIONT_DECLS: {SYMBIONT_DECLS:#?}"); let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug).await?; let fn_sigs = runtime.fn_sigs(); // Alternatively, `fn_full_sources` can be used to also show doc string and default function body. info!("fn_sigs: {fn_sigs:?}"); diff --git a/symbiont-macros/src/lib.rs b/symbiont-macros/src/lib.rs index 7cd7287..5015e34 100644 --- a/symbiont-macros/src/lib.rs +++ b/symbiont-macros/src/lib.rs @@ -124,6 +124,19 @@ fn normalize_tokens(mut s: String) -> String { s } +/// Extract the string value from a `#[doc = "..."]` attribute. +fn extract_doc_string(attr: &syn::Attribute) -> Option { + if let syn::Meta::NameValue(nv) = &attr.meta + && let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(s), + .. + }) = &nv.value + { + return Some(s.value()); + } + None +} + /// Build the `full_source` string for the dylib from a function declaration. /// /// Forces `pub` visibility and prepends `#[unsafe(no_mangle)]`. @@ -143,21 +156,25 @@ fn build_full_source(func: &EvolvableFn) -> String { let output = &sig.output; let ident = &sig.ident; - // Preserve doc comments (`///` and `#[doc = "..."]`) on the generated function so - // they are available in the dylib's source for tooling and documentation purposes. - let doc_attrs = Vec::<&syn::Attribute>::from_iter( - func.attrs() - .iter() - .filter(|attr| attr.path().is_ident("doc")), - ); - - let fn_source = quote! { - #(#doc_attrs)* + // Preserve doc comments on the generated function so they are available in the + // dylib's source for tooling and documentation purposes. Render them as `///` + // line comments rather than `#[doc = "..."]` attributes for readability. + let doc_lines: String = func + .attrs() + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .filter_map(extract_doc_string) + .map(|line| format!("///{line}\n")) + .collect(); + + let fn_body = quote! { #[unsafe(no_mangle)] pub fn #ident(#inputs) #output #body_tokens }; - fn_source.to_string() + format!("{doc_lines}{fn_body}\n") + .trim_start() + .to_string() } /// Declare hot-reloadable functions that are compiled into a temporary dylib and loaded at runtime. From 16d703dbfb92e42fc632fcc4924294f1bcf2bba5 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Sat, 2 May 2026 00:28:17 +0200 Subject: [PATCH 3/3] fix `cargo clippy` --- symbiont-macros/src/lib.rs | 11 ++++++----- symbiont/src/parser.rs | 22 +++++++++++----------- symbiont/src/validation.rs | 38 +++++++++++++++++++------------------- 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/symbiont-macros/src/lib.rs b/symbiont-macros/src/lib.rs index 5015e34..0e9dc92 100644 --- a/symbiont-macros/src/lib.rs +++ b/symbiont-macros/src/lib.rs @@ -159,22 +159,23 @@ fn build_full_source(func: &EvolvableFn) -> String { // Preserve doc comments on the generated function so they are available in the // dylib's source for tooling and documentation purposes. Render them as `///` // line comments rather than `#[doc = "..."]` attributes for readability. + use std::fmt::Write as _; let doc_lines: String = func .attrs() .iter() .filter(|attr| attr.path().is_ident("doc")) .filter_map(extract_doc_string) - .map(|line| format!("///{line}\n")) - .collect(); + .fold(String::new(), |mut acc, line| { + let _ = writeln!(acc, "///{line}"); + acc + }); let fn_body = quote! { #[unsafe(no_mangle)] pub fn #ident(#inputs) #output #body_tokens }; - format!("{doc_lines}{fn_body}\n") - .trim_start() - .to_string() + format!("{doc_lines}{fn_body}\n").trim_start().to_string() } /// Declare hot-reloadable functions that are compiled into a temporary dylib and loaded at runtime. diff --git a/symbiont/src/parser.rs b/symbiont/src/parser.rs index f8dbaad..8985354 100644 --- a/symbiont/src/parser.rs +++ b/symbiont/src/parser.rs @@ -85,11 +85,11 @@ mod tests { #[test] fn test_extract_rust_code_simple_fence() { - let input = r#"```rust + let input = "```rust fn step(counter: &mut usize) { *counter += 1; } -```"#; +```"; let code = extract_rust_code(input).expect("Can parse"); assert_eq!( code.trim(), @@ -99,14 +99,14 @@ fn step(counter: &mut usize) { #[test] fn test_extract_rust_code_with_text_around() { - let input = r#"Here is the implementation: + let input = "Here is the implementation: ```rust pub fn add(a: i32, b: i32) -> i32 { a + b } ``` -Hope that helps!"#; - let code = extract_rust_code(input).unwrap(); +Hope that helps!"; + let code = extract_rust_code(input).expect("can extract"); assert_eq!( code.trim(), "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}" @@ -121,22 +121,22 @@ Hope that helps!"#; #[test] fn test_extract_rust_code_generic_fence() { - let input = r#"``` + let input = "``` fn no_lang_marker(x: i32) -> i32 { x } -```"#; - let code = extract_rust_code(input).unwrap(); +```"; + let code = extract_rust_code(input).expect("can extract"); assert_eq!(code.trim(), "fn no_lang_marker(x: i32) -> i32 { x }"); } #[test] fn test_parse_rust_code_from_block() { - let input = r#"```rust + let input = "```rust #[unsafe(no_mangle)] pub fn step(state: &mut usize) { *state += 1; } -```"#; - let file = parse_rust_code(input).unwrap(); +```"; + let file = parse_rust_code(input).expect("can parse"); assert_eq!(file.items.len(), 1); } } diff --git a/symbiont/src/validation.rs b/symbiont/src/validation.rs index 8ba8915..941ff11 100644 --- a/symbiont/src/validation.rs +++ b/symbiont/src/validation.rs @@ -126,25 +126,25 @@ mod tests { #[test] fn test_validate_valid_code() { - let input = r#"```rust + let input = "```rust #[unsafe(no_mangle)] pub fn step(counter: &mut usize) { *counter += 1; } -```"#; - let mut file = parse_rust_code(input).unwrap(); +```"; + let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; validate_generated_ast(&mut file, &expected).expect("validation passed"); } #[test] fn test_validate_missing_no_mangle_gets_added() { - let input = r#"```rust + let input = "```rust pub fn step(counter: &mut usize) { *counter += 1; } -```"#; - let mut file = parse_rust_code(input).unwrap(); +```"; + let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; validate_generated_ast(&mut file, &expected) .expect("should succeed by adding #[unsafe(no_mangle)]"); @@ -162,12 +162,12 @@ pub fn step(counter: &mut usize) { #[test] fn test_validate_non_public_gets_pub_added() { - let input = r#"```rust + let input = "```rust fn step(counter: &mut usize) { *counter += 1; } -```"#; - let mut file = parse_rust_code(input).unwrap(); +```"; + let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; validate_generated_ast(&mut file, &expected) .expect("should succeed by adding `pub` and #[unsafe(no_mangle)]"); @@ -188,15 +188,15 @@ fn step(counter: &mut usize) { #[test] fn test_validate_signature_mismatch() { - let input = r#"```rust + let input = "```rust #[unsafe(no_mangle)] pub fn add(a: i32, b: i32) -> i32 { a + b } -```"#; - let mut file = parse_rust_code(input).unwrap(); +```"; + let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; - let err = validate_generated_ast(&mut file, &expected).unwrap_err(); + let err = validate_generated_ast(&mut file, &expected).expect_err("should error"); dbg!(&err); match err { Error::SignatureMismatch { code, expected } => { @@ -212,26 +212,26 @@ pub fn add(a: i32, b: i32) -> i32 { #[test] fn test_validate_unsafe_no_mangle() { - let input = r#"```rust + let input = "```rust #[unsafe(no_mangle)] pub fn step(counter: &mut usize) { *counter += 1; } -```"#; - let mut file = parse_rust_code(input).unwrap(); +```"; + let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize)".to_string()]; validate_generated_ast(&mut file, &expected).expect("#[unsafe(no_mangle)] should be valid"); } #[test] fn test_validate_with_return_type() { - let input = r#"```rust + let input = "```rust #[unsafe(no_mangle)] pub fn step(counter: &mut usize) -> usize { *counter } -```"#; - let mut file = parse_rust_code(input).unwrap(); +```"; + let mut file = parse_rust_code(input).expect("can parse"); let expected = vec!["fn step(counter: &mut usize) -> usize".to_string()]; validate_generated_ast(&mut file, &expected).expect("validation with return type passed"); }