Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions examples/counter/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -24,8 +27,9 @@ 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();
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()?;
Expand Down
2 changes: 1 addition & 1 deletion symbiont-macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 40 additions & 6 deletions symbiont-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -117,10 +124,23 @@ fn normalize_tokens(mut s: String) -> String {
s
}

/// Build the `default_source` string for the dylib from a function declaration.
/// Extract the string value from a `#[doc = "..."]` attribute.
fn extract_doc_string(attr: &syn::Attribute) -> Option<String> {
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)]`.
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.
Expand All @@ -136,12 +156,26 @@ fn build_default_source(func: &EvolvableFn) -> String {
let output = &sig.output;
let ident = &sig.ident;

let fn_source = quote! {
// 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)
.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
};

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.
Expand Down Expand Up @@ -183,7 +217,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(
Expand Down Expand Up @@ -221,7 +255,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,
}
});
Expand Down
4 changes: 2 additions & 2 deletions symbiont/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion symbiont/src/decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()>,
Expand Down
22 changes: 11 additions & 11 deletions symbiont/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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}"
Expand All @@ -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);
}
}
7 changes: 6 additions & 1 deletion symbiont/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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
Expand Down
38 changes: 19 additions & 19 deletions symbiont/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]");
Expand All @@ -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)]");
Expand All @@ -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 } => {
Expand All @@ -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");
}
Expand Down
Loading