adk_rust_macros/lib.rs
1//! # adk-macros
2//!
3//! Proc macros for ADK-Rust that eliminate tool registration boilerplate.
4//!
5//! ## `#[tool]`
6//!
7//! Turns an async function into a fully-wired `adk_tool::Tool` implementation:
8//!
9//! ```rust,ignore
10//! use adk_macros::tool;
11//! use schemars::JsonSchema;
12//! use serde::Deserialize;
13//!
14//! #[derive(Deserialize, JsonSchema)]
15//! struct WeatherArgs {
16//! /// The city to look up
17//! city: String,
18//! }
19//!
20//! /// Get the current weather for a city.
21//! #[tool]
22//! async fn get_weather(args: WeatherArgs) -> Result<serde_json::Value, adk_tool::AdkError> {
23//! Ok(serde_json::json!({ "temp": 72, "city": args.city }))
24//! }
25//!
26//! // This generates a struct `GetWeather` that implements `adk_tool::Tool`.
27//! // Use it like: Arc::new(GetWeather)
28//! ```
29//!
30//! The macro:
31//! - Uses the function's doc comment as the tool description
32//! - Derives the JSON schema from the argument type via `schemars::schema_for!`
33//! - Names the tool after the function (snake_case)
34//! - Generates a zero-sized struct (PascalCase) implementing `Tool`
35
36use proc_macro::TokenStream;
37use quote::{format_ident, quote};
38use syn::{FnArg, ItemFn, Meta, Type, parse_macro_input};
39
40/// Attribute macro that generates a `Tool` implementation from an async function.
41///
42/// # Requirements
43///
44/// - The function must be `async`
45/// - It must take exactly one argument (the args struct) that implements
46/// `serde::de::DeserializeOwned` and `schemars::JsonSchema`
47/// - It must return `Result<serde_json::Value, adk_tool::AdkError>`
48/// - Doc comments become the tool description
49///
50/// # Attributes
51///
52/// Optional attributes can be passed to configure tool metadata:
53///
54/// - `read_only` — marks the tool as having no side effects (`is_read_only() → true`)
55/// - `concurrency_safe` — marks the tool as safe for concurrent execution (`is_concurrency_safe() → true`)
56/// - `long_running` — marks the tool as long-running (`is_long_running() → true`)
57///
58/// # Examples
59///
60/// ```rust,ignore
61/// /// Search the knowledge base for documents matching a query.
62/// #[tool]
63/// async fn search_docs(args: SearchArgs) -> Result<serde_json::Value, adk_tool::AdkError> {
64/// // ...
65/// }
66///
67/// /// Look up cached data (read-only, safe for parallel dispatch).
68/// #[tool(read_only, concurrency_safe)]
69/// async fn cache_lookup(args: LookupArgs) -> Result<serde_json::Value, adk_tool::AdkError> {
70/// // ...
71/// }
72///
73/// // Generated: pub struct SearchDocs; implements Tool
74/// // Use: agent_builder.tool(Arc::new(SearchDocs))
75/// ```
76#[proc_macro_attribute]
77pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
78 let input_fn = parse_macro_input!(item as ItemFn);
79
80 // Parse optional attributes: #[tool(read_only, concurrency_safe, long_running)]
81 let mut is_read_only = false;
82 let mut is_concurrency_safe = false;
83 let mut is_long_running = false;
84
85 if !attr.is_empty() {
86 let meta = parse_macro_input!(attr as ToolAttrs);
87 is_read_only = meta.read_only;
88 is_concurrency_safe = meta.concurrency_safe;
89 is_long_running = meta.long_running;
90 }
91
92 let fn_name = &input_fn.sig.ident;
93 let fn_vis = &input_fn.vis;
94
95 // Extract doc comments for description
96 let doc_lines: Vec<String> = input_fn
97 .attrs
98 .iter()
99 .filter(|attr| attr.path().is_ident("doc"))
100 .filter_map(|attr| {
101 if let syn::Meta::NameValue(nv) = &attr.meta
102 && let syn::Expr::Lit(lit) = &nv.value
103 && let syn::Lit::Str(s) = &lit.lit
104 {
105 return Some(s.value().trim().to_string());
106 }
107 None
108 })
109 .collect();
110
111 let description = if doc_lines.is_empty() {
112 fn_name.to_string().replace('_', " ")
113 } else {
114 doc_lines.join(" ")
115 };
116
117 let tool_name_str = fn_name.to_string();
118
119 // Generate PascalCase struct name: get_weather → GetWeather
120 let struct_name = format_ident!(
121 "{}",
122 tool_name_str
123 .split('_')
124 .map(|seg| {
125 let mut chars = seg.chars();
126 match chars.next() {
127 None => String::new(),
128 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
129 }
130 })
131 .collect::<String>()
132 );
133
134 // Extract the single argument type
135 let args_type = extract_args_type(&input_fn);
136
137 // Check if we have a typed args parameter or no params
138 let (schema_gen, deserialize_call) = if let Some(args_ty) = &args_type {
139 (
140 quote! {
141 {
142 let mut schema = serde_json::to_value(
143 schemars::schema_for!(#args_ty)
144 ).unwrap_or_default();
145 // Strip fields that Gemini/LLM APIs don't accept
146 if let Some(obj) = schema.as_object_mut() {
147 obj.remove("$schema");
148 obj.remove("title");
149 }
150 // Simplify nullable types: {"type": ["string", "null"]} → {"type": "string"}
151 fn simplify_nullable(v: &mut serde_json::Value) {
152 match v {
153 serde_json::Value::Object(map) => {
154 if let Some(serde_json::Value::Array(types)) = map.get("type") {
155 let non_null: Vec<_> = types.iter()
156 .filter(|t| t.as_str() != Some("null"))
157 .cloned()
158 .collect();
159 if non_null.len() == 1 {
160 map.insert("type".to_string(), non_null[0].clone());
161 }
162 }
163 // Remove anyOf wrappers for simple nullable types
164 if let Some(serde_json::Value::Array(any_of)) = map.remove("anyOf") {
165 for variant in &any_of {
166 if let Some(obj) = variant.as_object() {
167 if obj.get("type").and_then(|t| t.as_str()) != Some("null") {
168 for (k, val) in obj {
169 map.insert(k.clone(), val.clone());
170 }
171 break;
172 }
173 }
174 }
175 }
176 for val in map.values_mut() {
177 simplify_nullable(val);
178 }
179 }
180 serde_json::Value::Array(arr) => {
181 for item in arr {
182 simplify_nullable(item);
183 }
184 }
185 _ => {}
186 }
187 }
188 simplify_nullable(&mut schema);
189 Some(schema)
190 }
191 },
192 quote! {
193 let typed_args: #args_ty = serde_json::from_value(args)
194 .map_err(|e| adk_tool::AdkError::tool(
195 format!("invalid arguments for '{}': {e}", #tool_name_str)
196 ))?;
197 #fn_name(typed_args).await
198 },
199 )
200 } else {
201 (
202 quote! { None },
203 quote! {
204 let _ = args;
205 #fn_name().await
206 },
207 )
208 };
209
210 // Check if the function signature includes ctx: Arc<dyn ToolContext>
211 let has_ctx = has_tool_context_param(&input_fn);
212 let execute_body = if has_ctx {
213 if let Some(args_ty) = &args_type {
214 quote! {
215 let typed_args: #args_ty = serde_json::from_value(args)
216 .map_err(|e| adk_tool::AdkError::tool(
217 format!("invalid arguments for '{}': {e}", #tool_name_str)
218 ))?;
219 #fn_name(ctx, typed_args).await
220 }
221 } else {
222 quote! {
223 let _ = args;
224 #fn_name(ctx).await
225 }
226 }
227 } else {
228 deserialize_call
229 };
230
231 // Generate optional trait method overrides
232 let read_only_override = if is_read_only {
233 quote! {
234 fn is_read_only(&self) -> bool { true }
235 }
236 } else {
237 quote! {}
238 };
239
240 let concurrency_safe_override = if is_concurrency_safe {
241 quote! {
242 fn is_concurrency_safe(&self) -> bool { true }
243 }
244 } else {
245 quote! {}
246 };
247
248 let long_running_override = if is_long_running {
249 quote! {
250 fn is_long_running(&self) -> bool { true }
251 }
252 } else {
253 quote! {}
254 };
255
256 let output = quote! {
257 // Keep the original function
258 #input_fn
259
260 /// Auto-generated tool struct for [`#fn_name`].
261 #fn_vis struct #struct_name;
262
263 #[adk_tool::async_trait]
264 impl adk_tool::Tool for #struct_name {
265 fn name(&self) -> &str {
266 #tool_name_str
267 }
268
269 fn description(&self) -> &str {
270 #description
271 }
272
273 fn parameters_schema(&self) -> Option<serde_json::Value> {
274 #schema_gen
275 }
276
277 #read_only_override
278 #concurrency_safe_override
279 #long_running_override
280
281 async fn execute(
282 &self,
283 ctx: std::sync::Arc<dyn adk_tool::ToolContext>,
284 args: serde_json::Value,
285 ) -> adk_tool::Result<serde_json::Value> {
286 #execute_body
287 }
288 }
289 };
290
291 output.into()
292}
293
294/// Extract the args type from the function signature.
295/// Skips any `Arc<dyn ToolContext>` parameter.
296fn extract_args_type(func: &ItemFn) -> Option<Type> {
297 for arg in &func.sig.inputs {
298 if let FnArg::Typed(pat_type) = arg {
299 // Skip context parameters (Arc<dyn ToolContext>)
300 let ty = &pat_type.ty;
301 let ty_str = quote!(#ty).to_string();
302 if ty_str.contains("ToolContext") {
303 continue;
304 }
305 return Some((*pat_type.ty).clone());
306 }
307 }
308 None
309}
310
311/// Check if the function has an Arc<dyn ToolContext> parameter.
312fn has_tool_context_param(func: &ItemFn) -> bool {
313 func.sig.inputs.iter().any(|arg| {
314 if let FnArg::Typed(pat_type) = arg {
315 let ty = &pat_type.ty;
316 let ty_str = quote!(#ty).to_string();
317 ty_str.contains("ToolContext")
318 } else {
319 false
320 }
321 })
322}
323
324/// Parsed attributes from `#[tool(read_only, concurrency_safe, long_running)]`.
325struct ToolAttrs {
326 read_only: bool,
327 concurrency_safe: bool,
328 long_running: bool,
329}
330
331impl syn::parse::Parse for ToolAttrs {
332 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
333 let mut attrs =
334 ToolAttrs { read_only: false, concurrency_safe: false, long_running: false };
335
336 let punctuated =
337 syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated(input)?;
338
339 for meta in punctuated {
340 if let Meta::Path(path) = &meta {
341 if path.is_ident("read_only") {
342 attrs.read_only = true;
343 } else if path.is_ident("concurrency_safe") {
344 attrs.concurrency_safe = true;
345 } else if path.is_ident("long_running") {
346 attrs.long_running = true;
347 } else {
348 return Err(syn::Error::new_spanned(
349 path,
350 "unknown tool attribute; expected `read_only`, `concurrency_safe`, or `long_running`",
351 ));
352 }
353 } else {
354 return Err(syn::Error::new_spanned(
355 meta,
356 "expected identifier (e.g., `read_only`), not key-value",
357 ));
358 }
359 }
360
361 Ok(attrs)
362 }
363}
364
365// ─── Functional API Macros ─────────────────────────────────────────────────────
366
367/// Attribute macro that generates a workflow agent struct from an async function.
368///
369/// The annotated function becomes the workflow body. The macro generates:
370/// - A PascalCase struct (e.g., `my_workflow` → `MyWorkflowAgent`)
371/// - A `new()` constructor accepting `Arc<dyn Checkpointer>`
372/// - An `invoke()` method that creates/restores `TaskContext`, validates state,
373/// creates checkpoints, calls the function, and persists the final checkpoint
374///
375/// # Requirements
376///
377/// - The function **must** be `async`
378/// - The function **must** accept `&mut TaskContext` as its sole parameter
379/// - The function **must** return `Result<Value>` (or equivalent)
380///
381/// # Example
382///
383/// ```rust,ignore
384/// use adk_graph::functional::TaskContext;
385/// use adk_graph::error::Result;
386/// use serde_json::Value;
387///
388/// #[entrypoint]
389/// async fn my_workflow(ctx: &mut TaskContext) -> Result<Value> {
390/// let data = step_a(ctx, "input").await?;
391/// let result = step_b(ctx, data).await?;
392/// Ok(result)
393/// }
394///
395/// // Generates: pub struct MyWorkflowAgent { ... }
396/// // with MyWorkflowAgent::new(checkpointer) and invoke(initial_state, config)
397/// ```
398#[proc_macro_attribute]
399pub fn entrypoint(_attr: TokenStream, item: TokenStream) -> TokenStream {
400 let input_fn = parse_macro_input!(item as ItemFn);
401
402 // Validate: must be async
403 if input_fn.sig.asyncness.is_none() {
404 return syn::Error::new_spanned(
405 input_fn.sig.fn_token,
406 "#[entrypoint] functions must be async",
407 )
408 .to_compile_error()
409 .into();
410 }
411
412 // Validate: must accept &mut TaskContext
413 let has_task_context = input_fn.sig.inputs.iter().any(|arg| {
414 if let FnArg::Typed(pat_type) = arg {
415 let full_str = quote!(#pat_type).to_string();
416 full_str.contains("TaskContext")
417 } else {
418 false
419 }
420 });
421
422 if !has_task_context {
423 return syn::Error::new_spanned(
424 &input_fn.sig,
425 "#[entrypoint] functions must accept `&mut TaskContext` as a parameter",
426 )
427 .to_compile_error()
428 .into();
429 }
430
431 let fn_name = &input_fn.sig.ident;
432 let fn_vis = &input_fn.vis;
433 let fn_name_str = fn_name.to_string();
434
435 // Generate PascalCase struct name: my_workflow → MyWorkflowAgent
436 let struct_name = format_ident!(
437 "{}Agent",
438 fn_name_str
439 .split('_')
440 .map(|seg| {
441 let mut chars = seg.chars();
442 match chars.next() {
443 None => String::new(),
444 Some(c) => c.to_uppercase().to_string() + chars.as_str(),
445 }
446 })
447 .collect::<String>()
448 );
449
450 let output = quote! {
451 // Preserve the original function for direct testing
452 #input_fn
453
454 /// Auto-generated workflow agent struct for [`#fn_name`].
455 ///
456 /// Created by the `#[entrypoint]` macro. Provides `new()` and `invoke()`
457 /// methods for executing the workflow with automatic checkpointing.
458 #fn_vis struct #struct_name {
459 checkpointer: std::sync::Arc<dyn adk_graph::checkpoint::Checkpointer>,
460 }
461
462 impl #struct_name {
463 /// Create a new workflow agent with the given checkpointer.
464 pub fn new(checkpointer: std::sync::Arc<dyn adk_graph::checkpoint::Checkpointer>) -> Self {
465 Self { checkpointer }
466 }
467
468 /// Invoke the workflow with an initial state and execution configuration.
469 ///
470 /// This method:
471 /// 1. Creates or restores a `TaskContext` from the last checkpoint
472 /// 2. Validates initial state against the configured schema
473 /// 3. Creates a checkpoint before execution
474 /// 4. Calls the annotated workflow function
475 /// 5. Persists the final checkpoint
476 /// 6. Returns the final workflow state
477 pub async fn invoke(
478 &self,
479 initial_state: adk_graph::state::State,
480 execution_config: adk_graph::node::ExecutionConfig,
481 ) -> adk_graph::error::Result<adk_graph::state::State> {
482 use adk_graph::checkpoint::Checkpointer;
483 use adk_graph::functional::ExecutionLog;
484 use adk_graph::state::Checkpoint;
485 use adk_graph::stream::StreamEvent;
486
487 let thread_id = execution_config.thread_id.clone();
488
489 // Try to restore from checkpoint if resuming
490 let (state, execution_log) = if execution_config.resume_from.is_some() {
491 match self.checkpointer.load(&thread_id).await? {
492 Some(checkpoint) => {
493 let log: ExecutionLog = checkpoint
494 .metadata
495 .get("execution_log")
496 .and_then(|v| serde_json::from_value(v.clone()).ok())
497 .unwrap_or_default();
498 (checkpoint.state, log)
499 }
500 None => (initial_state, ExecutionLog::new()),
501 }
502 } else {
503 (initial_state, ExecutionLog::new())
504 };
505
506 // Create broadcast channel for stream events
507 let (event_tx, _) = tokio::sync::broadcast::channel::<StreamEvent>(256);
508 let cancel_token = tokio_util::sync::CancellationToken::new();
509 let execution_log = std::sync::Arc::new(tokio::sync::RwLock::new(execution_log));
510
511 // Create TaskContext
512 let mut ctx = adk_graph::functional::TaskContext::new(
513 thread_id.clone(),
514 state,
515 self.checkpointer.clone(),
516 event_tx.clone(),
517 execution_log.clone(),
518 cancel_token,
519 None,
520 );
521
522 // Validate initial state against schema (if configured)
523 ctx.validate_state().map_err(|e| adk_graph::error::GraphError::Other(e.to_string()))?;
524
525 // Create pre-execution checkpoint
526 let pre_checkpoint = Checkpoint::new(
527 &thread_id,
528 ctx.state().clone(),
529 0,
530 vec![],
531 )
532 .with_metadata("phase", serde_json::Value::String("pre_execution".to_string()));
533 self.checkpointer.save(&pre_checkpoint).await?;
534
535 // Emit workflow start event
536 let _ = event_tx.send(StreamEvent::node_start(#fn_name_str, 0));
537
538 // Call the workflow function
539 let start = std::time::Instant::now();
540 let result = #fn_name(&mut ctx).await;
541
542 let duration = start.elapsed().as_millis() as u64;
543
544 match result {
545 Ok(_value) => {
546 // Persist final checkpoint
547 let step = execution_log.read().await.current_step();
548 let final_checkpoint = Checkpoint::new(
549 &thread_id,
550 ctx.state().clone(),
551 step,
552 vec![],
553 )
554 .with_metadata("phase", serde_json::Value::String("completed".to_string()))
555 .with_metadata(
556 "execution_log",
557 serde_json::to_value(&*execution_log.read().await)
558 .unwrap_or(serde_json::Value::Null),
559 );
560 self.checkpointer.save(&final_checkpoint).await?;
561
562 // Emit workflow end event
563 let _ = event_tx.send(StreamEvent::node_end(#fn_name_str, step, duration));
564
565 Ok(ctx.state().clone())
566 }
567 Err(e) => {
568 // Persist failure checkpoint
569 let step = execution_log.read().await.current_step();
570 let fail_checkpoint = Checkpoint::new(
571 &thread_id,
572 ctx.state().clone(),
573 step,
574 vec![],
575 )
576 .with_metadata("phase", serde_json::Value::String("failed".to_string()))
577 .with_metadata("error", serde_json::Value::String(e.to_string()))
578 .with_metadata(
579 "execution_log",
580 serde_json::to_value(&*execution_log.read().await)
581 .unwrap_or(serde_json::Value::Null),
582 );
583 let _ = self.checkpointer.save(&fail_checkpoint).await;
584
585 // Emit error event
586 let _ = event_tx.send(StreamEvent::error(&e.to_string(), Some(#fn_name_str)));
587
588 Err(e)
589 }
590 }
591 }
592 }
593 };
594
595 output.into()
596}
597
598/// Attribute macro that generates a task wrapper with checkpointing, retry, and streaming.
599///
600/// The annotated function becomes the inner task body. The macro generates a wrapper
601/// function (prefixed with `__task_`) that:
602/// - Checks `ExecutionLog` for cached results (resume-skip path)
603/// - Emits `StreamEvent::node_start` and `StreamEvent::node_end` events
604/// - Implements retry logic when `retry(max_attempts, backoff)` is specified
605/// - Calls `record_completion()` on success
606/// - Calls `record_failure()` after all retries are exhausted
607///
608/// # Requirements
609///
610/// - The function **must** be `async`
611/// - The function **must** accept `&mut TaskContext` as its first argument
612///
613/// # Attributes
614///
615/// - `retry(max_attempts = N, backoff = "Xs")` — retry on failure, sleeping
616/// `backoff × attempt` between tries, so the delay grows linearly: `Xs`, `2Xs`,
617/// `3Xs`. For exponential growth, a cap, or jitter, attach an
618/// `adk_graph::retry::RetryPolicy` to a graph node instead
619/// - `rerun_on_resume` — always re-execute on workflow resume, skip cached results
620/// - `rerun_on_resume = true` / `rerun_on_resume = false` — explicit boolean form
621///
622/// # Examples
623///
624/// ```rust,ignore
625/// use adk_graph::functional::TaskContext;
626/// use adk_graph::error::Result;
627/// use serde_json::Value;
628///
629/// #[task(retry(max_attempts = 3, backoff = "1s"))]
630/// async fn step_a(ctx: &mut TaskContext, input: &str) -> Result<Value> {
631/// Ok(serde_json::json!({"processed": input}))
632/// }
633///
634/// #[task(rerun_on_resume)]
635/// async fn step_b(ctx: &mut TaskContext) -> Result<Value> {
636/// // This task always re-executes on resume, never uses cached results
637/// Ok(serde_json::json!({"timestamp": chrono::Utc::now().to_rfc3339()}))
638/// }
639///
640/// #[task(rerun_on_resume, retry(max_attempts = 2, backoff = "2s"))]
641/// async fn step_c(ctx: &mut TaskContext) -> Result<Value> {
642/// // Combined: re-executes on resume with retry logic
643/// Ok(serde_json::json!({"status": "ok"}))
644/// }
645///
646/// // Generates: async fn __task_step_a(ctx: &mut TaskContext, input: &str) -> Result<Value>
647/// // which wraps step_a with checkpoint/retry/streaming logic.
648/// ```
649#[proc_macro_attribute]
650pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream {
651 let input_fn = parse_macro_input!(item as ItemFn);
652
653 // Validate: must be async
654 if input_fn.sig.asyncness.is_none() {
655 return syn::Error::new_spanned(input_fn.sig.fn_token, "#[task] functions must be async")
656 .to_compile_error()
657 .into();
658 }
659
660 // Validate: first argument must be &mut TaskContext
661 let has_task_context_first = input_fn
662 .sig
663 .inputs
664 .first()
665 .map(|arg| {
666 if let FnArg::Typed(pat_type) = arg {
667 let full_str = quote!(#pat_type).to_string();
668 full_str.contains("TaskContext")
669 } else {
670 false
671 }
672 })
673 .unwrap_or(false);
674
675 if !has_task_context_first {
676 return syn::Error::new_spanned(
677 &input_fn.sig,
678 "#[task] functions must accept `&mut TaskContext` as the first argument",
679 )
680 .to_compile_error()
681 .into();
682 }
683
684 // Parse retry attributes from #[task(retry(max_attempts = N, backoff = "Xs"))]
685 let task_attrs = parse_task_attrs(attr);
686
687 let fn_name = &input_fn.sig.ident;
688 let fn_vis = &input_fn.vis;
689 let fn_name_str = fn_name.to_string();
690 let wrapper_name = format_ident!("__task_{}", fn_name);
691
692 // Collect function parameters (all of them for the wrapper signature)
693 let params = &input_fn.sig.inputs;
694 let return_type = &input_fn.sig.output;
695
696 // Collect the argument names for forwarding the call (skip `ctx`)
697 let forward_args: Vec<_> = input_fn
698 .sig
699 .inputs
700 .iter()
701 .skip(1) // Skip ctx
702 .filter_map(|arg| if let FnArg::Typed(pat_type) = arg { Some(&pat_type.pat) } else { None })
703 .collect();
704
705 // Build the call expression
706 let call_expr = if forward_args.is_empty() {
707 quote! { #fn_name(ctx).await }
708 } else {
709 quote! { #fn_name(ctx, #(#forward_args),*).await }
710 };
711
712 // Generate retry logic or single-attempt logic
713 let execution_body = if let Some(retry_config) = &task_attrs.retry {
714 let max_attempts = retry_config.max_attempts;
715 let backoff_secs = retry_config.backoff_secs;
716 quote! {
717 let mut attempts: u32 = 0;
718 let max_attempts: u32 = #max_attempts;
719 let backoff = std::time::Duration::from_secs(#backoff_secs);
720
721 let result = loop {
722 attempts += 1;
723 match #call_expr {
724 Ok(value) => break Ok(value),
725 Err(e) if attempts < max_attempts => {
726 tokio::time::sleep(backoff * attempts).await;
727 continue;
728 }
729 Err(e) => {
730 ctx.record_failure(task_id, &e.to_string()).await?;
731 ctx.emit(adk_graph::stream::StreamEvent::error(
732 &e.to_string(),
733 Some(task_id),
734 ));
735 break Err(e);
736 }
737 }
738 };
739 }
740 } else {
741 quote! {
742 let result = match #call_expr {
743 Ok(value) => Ok(value),
744 Err(e) => {
745 ctx.record_failure(task_id, &e.to_string()).await?;
746 ctx.emit(adk_graph::stream::StreamEvent::error(
747 &e.to_string(),
748 Some(task_id),
749 ));
750 Err(e)
751 }
752 };
753 }
754 };
755
756 // Generate cache-check code based on rerun_on_resume flag
757 let cache_check = if task_attrs.rerun_on_resume {
758 // rerun_on_resume = true: skip cache check, always execute
759 quote! {}
760 } else {
761 // Default: check ExecutionLog for cached results (resume-skip path)
762 quote! {
763 // Check if already completed (resume path)
764 if let Some(cached_result) = ctx.get_cached_result(task_id).await {
765 return Ok(cached_result);
766 }
767 }
768 };
769
770 let output = quote! {
771 // Preserve the original function for direct testing
772 #input_fn
773
774 /// Auto-generated task wrapper for [`#fn_name`].
775 ///
776 /// Wraps the original function with:
777 /// - Resume-skip logic (checks `ExecutionLog` for cached results)
778 /// - `StreamEvent::node_start` / `StreamEvent::node_end` emission
779 /// - Retry logic (if configured)
780 /// - `record_completion()` on success
781 /// - `record_failure()` after all retries exhausted
782 #fn_vis async fn #wrapper_name(#params) #return_type {
783 let task_id = #fn_name_str;
784
785 #cache_check
786
787 // Emit task start event
788 let current_step = ctx.current_step().await;
789 ctx.emit(adk_graph::stream::StreamEvent::node_start(task_id, current_step));
790
791 let start = std::time::Instant::now();
792
793 #execution_body
794
795 if let Ok(ref value) = result {
796 // Record completion and checkpoint
797 ctx.record_completion(task_id, value).await?;
798 let duration = start.elapsed().as_millis() as u64;
799 let step = ctx.current_step().await;
800 ctx.emit(adk_graph::stream::StreamEvent::node_end(task_id, step, duration));
801 }
802
803 result
804 }
805 };
806
807 output.into()
808}
809
810// ─── Task Attribute Parsing ────────────────────────────────────────────────────
811
812/// Parsed retry configuration from `#[task(retry(max_attempts = N, backoff = "Xs"))]`.
813struct RetryConfig {
814 max_attempts: u32,
815 backoff_secs: u64,
816}
817
818/// Parsed attributes from `#[task(...)]`.
819struct TaskAttrs {
820 retry: Option<RetryConfig>,
821 rerun_on_resume: bool,
822}
823
824/// Parse task attributes from the attribute token stream.
825///
826/// Supports:
827/// - `#[task]` — no retry, no rerun
828/// - `#[task(retry(max_attempts = 3, backoff = "1s"))]` — with retry
829/// - `#[task(rerun_on_resume)]` — always re-execute on resume (skip cache)
830/// - `#[task(rerun_on_resume = true)]` — explicit boolean form
831/// - `#[task(rerun_on_resume, retry(max_attempts = 3, backoff = "1s"))]` — combined
832fn parse_task_attrs(attr: TokenStream) -> TaskAttrs {
833 if attr.is_empty() {
834 return TaskAttrs { retry: None, rerun_on_resume: false };
835 }
836
837 // Parse the attribute as a Meta list
838 let attr_meta: syn::Result<syn::Meta> = syn::parse(attr.clone());
839 if let Ok(syn::Meta::List(meta_list)) = attr_meta
840 && meta_list.path.is_ident("retry")
841 && let Some(retry) = parse_retry_from_meta_list(&meta_list)
842 {
843 return TaskAttrs { retry: Some(retry), rerun_on_resume: false };
844 }
845
846 // Try parsing as just the inner content of task(...)
847 // e.g., the attr stream is: `retry(max_attempts = 3, backoff = "1s")`
848 // or: `rerun_on_resume`
849 // or: `rerun_on_resume, retry(max_attempts = 3, backoff = "1s")`
850 let attr2: proc_macro2::TokenStream = attr.into();
851 let parsed: syn::Result<TaskAttrContent> = syn::parse2(attr2);
852 if let Ok(content) = parsed {
853 return TaskAttrs { retry: content.retry, rerun_on_resume: content.rerun_on_resume };
854 }
855
856 TaskAttrs { retry: None, rerun_on_resume: false }
857}
858
859/// Inner content parsed from `#[task(retry(...), rerun_on_resume)]`.
860struct TaskAttrContent {
861 retry: Option<RetryConfig>,
862 rerun_on_resume: bool,
863}
864
865impl syn::parse::Parse for TaskAttrContent {
866 fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
867 let mut retry = None;
868 let mut rerun_on_resume = false;
869
870 // Parse comma-separated items: identifiers, name-value pairs, or calls like retry(...)
871 while !input.is_empty() {
872 let ident: syn::Ident = input.parse()?;
873
874 if ident == "retry" {
875 let content;
876 syn::parenthesized!(content in input);
877
878 let mut max_attempts: u32 = 3;
879 let mut backoff_secs: u64 = 1;
880
881 let pairs = syn::punctuated::Punctuated::<syn::MetaNameValue, syn::Token![,]>::parse_terminated(&content)?;
882
883 for pair in pairs {
884 if pair.path.is_ident("max_attempts")
885 && let syn::Expr::Lit(expr_lit) = &pair.value
886 && let syn::Lit::Int(lit_int) = &expr_lit.lit
887 {
888 max_attempts = lit_int.base10_parse().unwrap_or(3);
889 } else if pair.path.is_ident("backoff")
890 && let syn::Expr::Lit(expr_lit) = &pair.value
891 && let syn::Lit::Str(lit_str) = &expr_lit.lit
892 {
893 backoff_secs = parse_duration_str(&lit_str.value());
894 }
895 }
896
897 retry = Some(RetryConfig { max_attempts, backoff_secs });
898 } else if ident == "rerun_on_resume" {
899 // Accept both `rerun_on_resume` (flag, implies true)
900 // and `rerun_on_resume = true` / `rerun_on_resume = false`
901 if input.peek(syn::Token![=]) {
902 let _eq: syn::Token![=] = input.parse()?;
903 let lit: syn::LitBool = input.parse()?;
904 rerun_on_resume = lit.value;
905 } else {
906 rerun_on_resume = true;
907 }
908 } else {
909 return Err(syn::Error::new_spanned(
910 ident,
911 "unknown task attribute; expected `retry(...)` or `rerun_on_resume`",
912 ));
913 }
914
915 // Consume optional trailing comma
916 if input.peek(syn::Token![,]) {
917 let _comma: syn::Token![,] = input.parse()?;
918 }
919 }
920
921 Ok(TaskAttrContent { retry, rerun_on_resume })
922 }
923}
924
925/// Parse retry config from a `Meta::List` (e.g., `retry(max_attempts = 3, backoff = "1s")`).
926fn parse_retry_from_meta_list(meta_list: &syn::MetaList) -> Option<RetryConfig> {
927 let mut max_attempts: u32 = 3;
928 let mut backoff_secs: u64 = 1;
929
930 let pairs: syn::Result<syn::punctuated::Punctuated<syn::MetaNameValue, syn::Token![,]>> =
931 meta_list.parse_args_with(syn::punctuated::Punctuated::parse_terminated);
932
933 if let Ok(pairs) = pairs {
934 for pair in pairs {
935 if pair.path.is_ident("max_attempts")
936 && let syn::Expr::Lit(expr_lit) = &pair.value
937 && let syn::Lit::Int(lit_int) = &expr_lit.lit
938 {
939 max_attempts = lit_int.base10_parse().unwrap_or(3);
940 } else if pair.path.is_ident("backoff")
941 && let syn::Expr::Lit(expr_lit) = &pair.value
942 && let syn::Lit::Str(lit_str) = &expr_lit.lit
943 {
944 backoff_secs = parse_duration_str(&lit_str.value());
945 }
946 }
947 Some(RetryConfig { max_attempts, backoff_secs })
948 } else {
949 None
950 }
951}
952
953/// Parse a duration string like "1s", "500ms", "2s" into seconds.
954/// Defaults to 1 second if parsing fails.
955fn parse_duration_str(s: &str) -> u64 {
956 let s = s.trim();
957 // Check "ms" suffix first (before "s" since "ms" ends with 's')
958 if let Some(ms) = s.strip_suffix("ms") {
959 return ms.parse::<u64>().ok().map(|v| v / 1000).unwrap_or(1);
960 }
961 if let Some(secs) = s.strip_suffix('s') {
962 return secs.parse::<u64>().unwrap_or(1);
963 }
964 // Try parsing as plain number (assume seconds)
965 s.parse::<u64>().unwrap_or(1)
966}