Skip to main content
The function-authoring lane declares LM calls as bodyless Rust functions and whole pipelines as ordinary function bodies. #[predict] and #[cot] turn one function signature into one model call; #[module] compiles a function body that chains those calls into an IR Program. The macro reads each function once at expansion and emits both the runnable code and its program form, so the two cannot drift.

Steps

A step is one model call declared as a function with no body. The function is the contract; the framework writes the prompt and parses the answer.
Call it like a normal async function:
The mapping is the signature idea applied to a function: So fn answer(question: String) -> String gives the model an input called question and asks for an output called answer, which is why the result reads as out.answer. A step can take several inputs:
Rules, each a compile error when broken: the function is bodyless and ends with ;; do not write async (the generated function is async automatically); no generics, no self, parameters must be plain identifiers; at least one input parameter and an explicit return type are required.

Chain of thought

#[cot] is the chain-of-thought variant of #[predict]: the model produces a reasoning field before the output. Same rules, same options. The result carries the extra field and auto-derefs to the output.

Model handles

Both attributes accept one option, model = "@name", which selects a declared model by reference; the leading @ is stripped, and anything else in the attribute is a compile error.
The name is a handle, not a hardcoded model id: which model strong really is gets decided by whoever runs the program. Steps with no model = ... ride the default model from your configure(...) line automatically. Named handles have no such fallback; see Named model binding. The function name follows the step everywhere: fx::Params::set_instruction("answer", ...) overrides its instruction, trace spans record the same name, and the generated signature lives in a module of the same name (answer::Sig). One exception: when a step is called inside a #[module] body, the trace and step name is the let binding name for that call, so let drafter = draft(...).await?; records as drafter.

Modules

#[module] compiles an ordinary async Rust function body into an IR Program. One parse, two projections: the executable function (typed boundary, runs through the interpreter, reads the ambient overlay) and name::program(), the same pipeline as a servable, optimizable, printable artifact.
It runs the way it reads:
The function must be async, cannot be generic, and must return Result<Out, Err> where Err: From<dspy_rs::ir::RunError>. Two options: caps("...", ...) declares the program’s capability ceiling, and deny_holes makes any non-step expression a compile error instead of a hole.

Accepted body shapes

The body is straight-line only, built from three shapes:
  • let x = step(args).await?; where step is a #[predict], #[cot], or #[agent] function. Arguments must be ports: function parameters, prior binding.field accesses, or literals; .clone() and & wrappers are stripped.
  • let y: SimpleType = <any Rust expr>; for plain Rust. The type ascription is required and must be simple: String, bool, an integer width from i8 through i64 or u8 through u32, f32/f64, or Vec<...>/Option<...> of those. The expression becomes a typed extern hole, a named boundary around code the IR cannot describe as data; the hole page covers how holes bind and travel.
  • A tail expression Ok(Struct { field: port, ... }) giving the program’s output bindings.

The printed program

The output below was captured from a real run of this pipeline:
Every step appears under its let binding name (sum, drafter), so the program speaks in your vocabulary. Every wire is a line of Rust written as a connection: sum.summarize flowing into draft’s summary input is the sum.summarize.clone() argument. The @strong handle survives intact, and the privacy scrub prints as hole clean_hole with an extern fingerprint in place of its code. This is the build-time design point stated plainly: the macro reads the function once at expansion and emits both the runnable function and the Program. The two are projections of one source, so they cannot drift. The printed text is the canonical .dsrs form; its grammar and identity rules live in The .dsrs file.

Generated items

For a step #[predict] fn answer(question: String) -> String;: #[cot] generates the same items; its function returns Result<Predicted<WithReasoning<answer::SigOutput>>, PredictError>, and WithReasoning auto-derefs to the output. For #[module] async fn frontdesk(...), inside a module named after the function: It also emits the executable async fn itself, which loads the interpreter once, reads the ambient overlay via current_overlay(), and runs with a default budget.

Named model binding

Steps with no model = ... ride the default model from your configure(...) line automatically. A named handle has no such fallback, by design: bind it at load with frontdesk::env().bind_model("strong", strong) and run through Interpreter::load, or the load refuses, by name. The runnable example 22-frontdesk-module.rs does exactly this.

Common mistakes

Adding async to a step. The generated function is already async. Writing async fn on a #[predict] or #[cot] step is a compile error with a clear message: remove async.
Adding a body to a step. A step has no body; the framework builds the behavior from the signature. If you want to write the body yourself, you want a tool, not a step. Forgetting the return type. The return type is the output field. Without it there is nothing for the model to produce, so it is a compile error. No inputs. A step needs at least one input parameter.

See also