agnt5 0.3.2a1__cp310-abi3-manylinux_2_34_aarch64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of agnt5 might be problematic. Click here for more details.

agnt5/tool.py ADDED
@@ -0,0 +1,721 @@
1
+ """
2
+ Tool component for Agent capabilities with automatic schema extraction.
3
+
4
+ Tools extend what agents can do by providing structured interfaces to functions,
5
+ with automatic schema generation from Python type hints and docstrings.
6
+ """
7
+
8
+ import asyncio
9
+ import dataclasses as dc
10
+ import functools
11
+ import inspect
12
+ import json
13
+ import logging
14
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, TypeVar, get_args, get_origin
15
+
16
+ from docstring_parser import parse as parse_docstring
17
+
18
+ from .context import Context, set_current_context
19
+ from .exceptions import ConfigurationError
20
+ from ._telemetry import setup_module_logger
21
+
22
+ logger = setup_module_logger(__name__)
23
+
24
+ T = TypeVar("T")
25
+ ToolHandler = Callable[..., Awaitable[T]]
26
+
27
+
28
+ def _serialize_for_span(value: Any) -> str:
29
+ """Serialize a value to JSON string for span attributes.
30
+
31
+ Handles Pydantic models, dataclasses, and other complex types.
32
+
33
+ Args:
34
+ value: The value to serialize
35
+
36
+ Returns:
37
+ JSON string representation of the value
38
+ """
39
+ if value is None:
40
+ return "null"
41
+
42
+ # Handle Pydantic models (v2 API)
43
+ if hasattr(value, 'model_dump'):
44
+ return json.dumps(value.model_dump())
45
+
46
+ # Handle Pydantic models (v1 API)
47
+ if hasattr(value, 'dict') and hasattr(value, '__fields__'):
48
+ return json.dumps(value.dict())
49
+
50
+ # Handle dataclasses
51
+ if dc.is_dataclass(value) and not isinstance(value, type):
52
+ return json.dumps(dc.asdict(value))
53
+
54
+ # Default JSON serialization
55
+ try:
56
+ return json.dumps(value)
57
+ except (TypeError, ValueError):
58
+ return repr(value)
59
+
60
+
61
+ def _python_type_to_json_schema(py_type: Any) -> Dict[str, Any]:
62
+ """
63
+ Convert Python type to JSON Schema.
64
+
65
+ Args:
66
+ py_type: Python type annotation
67
+
68
+ Returns:
69
+ JSON Schema dict with type and potentially items/properties
70
+ """
71
+ # Handle None/NoneType
72
+ if py_type is None or py_type is type(None):
73
+ return {"type": "null"}
74
+
75
+ # Get origin and args for generic types
76
+ origin = get_origin(py_type)
77
+ args = get_args(py_type)
78
+
79
+ # Handle Optional[T] -> unwrap to T
80
+ if origin is type(None.__class__): # Union type
81
+ # Filter out NoneType
82
+ non_none_args = [arg for arg in args if arg is not type(None)]
83
+ if len(non_none_args) == 1:
84
+ return _python_type_to_json_schema(non_none_args[0])
85
+ # Multiple non-None types -> just use first one
86
+ if non_none_args:
87
+ return _python_type_to_json_schema(non_none_args[0])
88
+ return {"type": "null"}
89
+
90
+ # Handle List[T] - need to specify items
91
+ if origin is list or (origin is None and py_type is list):
92
+ if args:
93
+ # List[T] - extract T and create items schema
94
+ item_type = args[0]
95
+ return {
96
+ "type": "array",
97
+ "items": _python_type_to_json_schema(item_type)
98
+ }
99
+ else:
100
+ # Bare list without type parameter
101
+ return {
102
+ "type": "array",
103
+ "items": {"type": "string"} # Default to string items
104
+ }
105
+
106
+ # Handle Dict[K, V] - basic object schema
107
+ if origin is dict or (origin is None and py_type is dict):
108
+ return {"type": "object"}
109
+
110
+ # Handle basic types
111
+ type_map = {
112
+ str: "string",
113
+ int: "integer",
114
+ float: "number",
115
+ bool: "boolean",
116
+ Any: "string", # Default to string for Any
117
+ }
118
+
119
+ # Direct type match
120
+ json_type = type_map.get(py_type, "string")
121
+ return {"type": json_type}
122
+
123
+
124
+ def _extract_schema_from_function(func: Callable) -> Dict[str, Any]:
125
+ """
126
+ Extract JSON schema from function signature and docstring.
127
+
128
+ Args:
129
+ func: Function to extract schema from
130
+
131
+ Returns:
132
+ Dict containing input_schema and output_schema
133
+ """
134
+ # Parse function signature
135
+ sig = inspect.signature(func)
136
+ docstring = inspect.getdoc(func) or ""
137
+ parsed_doc = parse_docstring(docstring)
138
+
139
+ # Build parameter schemas
140
+ properties = {}
141
+ required = []
142
+
143
+ # Build mapping from param name to docstring description
144
+ param_descriptions = {}
145
+ if parsed_doc.params:
146
+ for param_doc in parsed_doc.params:
147
+ param_descriptions[param_doc.arg_name] = param_doc.description or ""
148
+
149
+ for param_name, param in sig.parameters.items():
150
+ # Skip 'ctx' parameter (Context is auto-injected)
151
+ if param_name == "ctx":
152
+ continue
153
+
154
+ # Get type annotation
155
+ param_type = param.annotation
156
+ if param_type == inspect.Parameter.empty:
157
+ param_type = Any
158
+
159
+ # Get description from docstring
160
+ description = param_descriptions.get(param_name, "")
161
+
162
+ # Build parameter schema using full JSON Schema conversion
163
+ param_schema = _python_type_to_json_schema(param_type)
164
+ param_schema["description"] = description
165
+
166
+ properties[param_name] = param_schema
167
+
168
+ # Check if required (no default value)
169
+ if param.default == inspect.Parameter.empty:
170
+ required.append(param_name)
171
+
172
+ # Build input schema
173
+ input_schema = {
174
+ "type": "object",
175
+ "properties": properties,
176
+ "required": required
177
+ }
178
+
179
+ # Extract return type for output schema (optional for basic tool functionality)
180
+ return_type = sig.return_annotation
181
+ output_schema = None
182
+ if return_type != inspect.Parameter.empty:
183
+ output_schema = _python_type_to_json_schema(return_type)
184
+
185
+ return {
186
+ "input_schema": input_schema,
187
+ "output_schema": output_schema
188
+ }
189
+
190
+
191
+ class Tool:
192
+ """
193
+ Represents a tool that agents can use.
194
+
195
+ Tools wrap functions with automatic schema extraction and provide
196
+ a structured interface for agent invocation.
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ name: str,
202
+ description: str,
203
+ handler: ToolHandler,
204
+ input_schema: Optional[Dict[str, Any]] = None,
205
+ confirmation: bool = False,
206
+ auto_schema: bool = False
207
+ ):
208
+ """
209
+ Initialize a Tool.
210
+
211
+ Args:
212
+ name: Tool name
213
+ description: Tool description for agents
214
+ handler: Function that implements the tool
215
+ input_schema: Manual JSON schema for input parameters
216
+ confirmation: Whether tool requires human confirmation before execution
217
+ auto_schema: Whether to automatically extract schema from handler
218
+ """
219
+ self.name = name
220
+ self.description = description
221
+ self.handler = handler
222
+ self.confirmation = confirmation
223
+
224
+ # Extract or use provided schema
225
+ if auto_schema:
226
+ schemas = _extract_schema_from_function(handler)
227
+ self.input_schema = schemas["input_schema"]
228
+ self.output_schema = schemas.get("output_schema")
229
+ else:
230
+ self.input_schema = input_schema or {"type": "object", "properties": {}}
231
+ self.output_schema = None
232
+
233
+ # Validate handler signature
234
+ self._validate_handler()
235
+
236
+ logger.debug(f"Created tool '{name}' with auto_schema={auto_schema}")
237
+
238
+ def _validate_handler(self) -> None:
239
+ """Validate that handler has correct signature."""
240
+ sig = inspect.signature(self.handler)
241
+ params = list(sig.parameters.values())
242
+
243
+ if not params:
244
+ raise ConfigurationError(
245
+ f"Tool handler '{self.name}' must have at least one parameter (ctx: Context)"
246
+ )
247
+
248
+ first_param = params[0]
249
+ if first_param.annotation != Context and first_param.annotation != inspect.Parameter.empty:
250
+ logger.warning(
251
+ f"Tool handler '{self.name}' first parameter should be 'ctx: Context', "
252
+ f"got '{first_param.annotation}'"
253
+ )
254
+
255
+ async def invoke(self, ctx: Context, **kwargs) -> Any:
256
+ """
257
+ Invoke the tool with given arguments.
258
+
259
+ Args:
260
+ ctx: Execution context
261
+ **kwargs: Tool arguments matching input_schema
262
+
263
+ Returns:
264
+ Tool execution result
265
+
266
+ Raises:
267
+ ConfigurationError: If tool requires confirmation (not yet implemented)
268
+
269
+ Note:
270
+ If memoization is enabled on the context, this method will check
271
+ the journal for cached results before executing and cache results
272
+ after successful execution.
273
+ """
274
+ # Check for memoization before tool execution
275
+ step_key = None
276
+ content_hash = None
277
+ memo = None
278
+
279
+ if hasattr(ctx, '_memo') and ctx._memo:
280
+ memo = ctx._memo
281
+ step_key, content_hash = memo.tool_call_key(self.name, kwargs)
282
+
283
+ # Check cache first - skip execution if cached
284
+ found, cached_result = await memo.get_cached_tool_result(step_key, content_hash)
285
+ if found:
286
+ logger.debug(f"Tool call {step_key} served from memoization cache")
287
+ return cached_result
288
+
289
+ if self.confirmation:
290
+ # TODO: Implement actual confirmation workflow
291
+ # For now, just log a warning
292
+ logger.warning(
293
+ f"Tool '{self.name}' requires confirmation but confirmation is not yet implemented"
294
+ )
295
+
296
+ # Emit checkpoint if called within a workflow context
297
+ from .context import get_workflow_context
298
+
299
+ workflow_ctx = get_workflow_context()
300
+ if workflow_ctx:
301
+ workflow_ctx._send_checkpoint("tool.invoked", {
302
+ "tool.name": self.name,
303
+ "tool.args": list(kwargs.keys()),
304
+ })
305
+
306
+ # Set context in task-local storage for automatic propagation to nested calls
307
+ token = set_current_context(ctx)
308
+ try:
309
+ try:
310
+ # Create span for tool execution (uses contextvar for async-safe parent-child linking)
311
+ from .tracing import create_span
312
+
313
+ logger.debug(f"Invoking tool '{self.name}' with args: {list(kwargs.keys())}")
314
+
315
+ # Create span with runtime_context for parent-child span linking
316
+ # contextvar handles proper nesting for parallel async operations
317
+ with create_span(
318
+ self.name,
319
+ "tool",
320
+ ctx._runtime_context if hasattr(ctx, "_runtime_context") else None,
321
+ {
322
+ "tool.name": self.name,
323
+ "tool.args": ",".join(kwargs.keys()),
324
+ "input.data": _serialize_for_span(kwargs),
325
+ },
326
+ ) as span:
327
+ # Handler is already async (validated in tool() decorator)
328
+ result = await self.handler(ctx, **kwargs)
329
+
330
+ # Add output data to span for trace visibility
331
+ span.set_attribute("output.data", _serialize_for_span(result))
332
+
333
+ logger.debug(f"Tool '{self.name}' completed successfully")
334
+
335
+ # Emit completion checkpoint
336
+ if workflow_ctx:
337
+ workflow_ctx._send_checkpoint("tool.completed", {
338
+ "tool.name": self.name,
339
+ "tool.success": True,
340
+ })
341
+
342
+ # Cache result for replay if memoization is enabled
343
+ if memo and step_key:
344
+ await memo.cache_tool_result(step_key, content_hash, result)
345
+
346
+ return result
347
+ except Exception as e:
348
+ # Emit error checkpoint for observability
349
+ if workflow_ctx:
350
+ workflow_ctx._send_checkpoint("tool.failed", {
351
+ "tool.name": self.name,
352
+ "error": str(e),
353
+ "error_type": type(e).__name__,
354
+ })
355
+ raise
356
+ finally:
357
+ # Always reset context to prevent leakage
358
+ from .context import _current_context
359
+ _current_context.reset(token)
360
+
361
+ def get_schema(self) -> Dict[str, Any]:
362
+ """
363
+ Get complete tool schema for agent consumption.
364
+
365
+ Returns:
366
+ Dict with name, description, and input_schema
367
+ """
368
+ return {
369
+ "name": self.name,
370
+ "description": self.description,
371
+ "input_schema": self.input_schema,
372
+ "requires_confirmation": self.confirmation
373
+ }
374
+
375
+
376
+ class ToolRegistry:
377
+ """Global registry for tools."""
378
+
379
+ _tools: Dict[str, Tool] = {}
380
+
381
+ @classmethod
382
+ def register(cls, tool: Tool) -> None:
383
+ """Register a tool."""
384
+ if tool.name in cls._tools:
385
+ logger.warning(f"Overwriting existing tool '{tool.name}'")
386
+ cls._tools[tool.name] = tool
387
+ logger.debug(f"Registered tool '{tool.name}'")
388
+
389
+ @classmethod
390
+ def get(cls, name: str) -> Optional[Tool]:
391
+ """Get a tool by name."""
392
+ return cls._tools.get(name)
393
+
394
+ @classmethod
395
+ def all(cls) -> Dict[str, Tool]:
396
+ """Get all registered tools."""
397
+ return cls._tools.copy()
398
+
399
+ @classmethod
400
+ def clear(cls) -> None:
401
+ """Clear all registered tools (for testing)."""
402
+ cls._tools.clear()
403
+ logger.debug("Cleared tool registry")
404
+
405
+ @classmethod
406
+ def list_names(cls) -> List[str]:
407
+ """Get list of all tool names."""
408
+ return list(cls._tools.keys())
409
+
410
+
411
+ def tool(
412
+ _func: Optional[Callable] = None,
413
+ *,
414
+ name: Optional[str] = None,
415
+ description: Optional[str] = None,
416
+ auto_schema: bool = True,
417
+ confirmation: bool = False,
418
+ input_schema: Optional[Dict[str, Any]] = None
419
+ ) -> Callable:
420
+ """
421
+ Decorator to mark a function as a tool with automatic schema extraction.
422
+
423
+ Args:
424
+ name: Tool name (defaults to function name)
425
+ description: Tool description (defaults to first line of docstring)
426
+ auto_schema: Automatically extract schema from type hints and docstring
427
+ confirmation: Whether tool requires confirmation before execution
428
+ input_schema: Manual schema (only if auto_schema=False)
429
+
430
+ Returns:
431
+ Decorated function that can be invoked as a tool
432
+
433
+ Example:
434
+ ```python
435
+ @tool(auto_schema=True)
436
+ def search_web(ctx: Context, query: str, max_results: int = 10) -> List[Dict]:
437
+ \"\"\"Search the web for information.
438
+
439
+ Args:
440
+ query: The search query string
441
+ max_results: Maximum number of results to return
442
+
443
+ Returns:
444
+ List of search results
445
+ \"\"\"
446
+ # Implementation
447
+ return results
448
+ ```
449
+ """
450
+ def decorator(func: Callable) -> Callable:
451
+ # Determine tool name
452
+ tool_name = name or func.__name__
453
+
454
+ # Extract description from docstring if not provided
455
+ tool_description = description
456
+ if tool_description is None:
457
+ docstring = inspect.getdoc(func)
458
+ if docstring:
459
+ parsed_doc = parse_docstring(docstring)
460
+ tool_description = parsed_doc.short_description or parsed_doc.long_description or ""
461
+ else:
462
+ tool_description = ""
463
+
464
+ # Validate function signature
465
+ sig = inspect.signature(func)
466
+ params = list(sig.parameters.values())
467
+
468
+ if not params:
469
+ raise ConfigurationError(
470
+ f"Tool function '{func.__name__}' must have at least one parameter (ctx: Context)"
471
+ )
472
+
473
+ first_param = params[0]
474
+ if first_param.annotation != Context and first_param.annotation != inspect.Parameter.empty:
475
+ raise ConfigurationError(
476
+ f"Tool function '{func.__name__}' first parameter must be 'ctx: Context', "
477
+ f"got '{first_param.annotation}'"
478
+ )
479
+
480
+ # Convert sync to async if needed
481
+ if not asyncio.iscoroutinefunction(func):
482
+ original_func = func
483
+
484
+ @functools.wraps(original_func)
485
+ async def async_wrapper(*args, **kwargs):
486
+ # Run sync function in thread pool to prevent blocking event loop
487
+ loop = asyncio.get_running_loop()
488
+ return await loop.run_in_executor(None, lambda: original_func(*args, **kwargs))
489
+
490
+ handler_func = async_wrapper
491
+ else:
492
+ handler_func = func
493
+
494
+ # Create Tool instance
495
+ tool_instance = Tool(
496
+ name=tool_name,
497
+ description=tool_description,
498
+ handler=handler_func,
499
+ input_schema=input_schema,
500
+ confirmation=confirmation,
501
+ auto_schema=auto_schema
502
+ )
503
+
504
+ # Register tool
505
+ ToolRegistry.register(tool_instance)
506
+
507
+ # Return wrapper that invokes tool
508
+ @functools.wraps(func)
509
+ async def tool_wrapper(*args, **kwargs) -> Any:
510
+ """Wrapper that invokes tool with context."""
511
+ # If called with Context as first arg, use tool.invoke
512
+ if args and isinstance(args[0], Context):
513
+ ctx = args[0]
514
+ return await tool_instance.invoke(ctx, **kwargs)
515
+
516
+ # Otherwise, direct call (for testing)
517
+ return await handler_func(*args, **kwargs)
518
+
519
+ # Attach tool instance to wrapper for inspection
520
+ tool_wrapper._tool = tool_instance
521
+
522
+ return tool_wrapper
523
+
524
+ if _func is None:
525
+ return decorator
526
+ return decorator(_func)
527
+
528
+
529
+ # ============================================================================
530
+ # Built-in Human-in-the-Loop Tools
531
+ # ============================================================================
532
+
533
+ class AskUserTool(Tool):
534
+ """
535
+ Built-in tool that agents can use to request text input from users.
536
+
537
+ This tool pauses the workflow execution and waits for the user to provide
538
+ a text response. The workflow resumes when the user submits their input.
539
+
540
+ Example:
541
+ ```python
542
+ from agnt5 import Agent, workflow, WorkflowContext
543
+ from agnt5.tool import AskUserTool
544
+
545
+ @workflow(chat=True)
546
+ async def agent_with_hitl(ctx: WorkflowContext, query: str) -> dict:
547
+ agent = Agent(
548
+ name="research_agent",
549
+ model="openai/gpt-4o-mini",
550
+ instructions="You are a research assistant.",
551
+ tools=[AskUserTool(ctx)]
552
+ )
553
+
554
+ result = await agent.run_sync(query, context=ctx)
555
+ return {"response": result.output}
556
+ ```
557
+ """
558
+
559
+ def __init__(self, context: Optional["WorkflowContext"] = None): # type: ignore
560
+ """
561
+ Initialize AskUserTool.
562
+
563
+ Args:
564
+ context: Optional workflow context with wait_for_user capability.
565
+ If not provided, will attempt to get from task-local contextvar.
566
+ """
567
+ # Import here to avoid circular dependency
568
+ from .workflow import WorkflowContext
569
+
570
+ if context is not None and not isinstance(context, WorkflowContext):
571
+ raise ConfigurationError(
572
+ "AskUserTool requires a WorkflowContext. "
573
+ "This tool can only be used within workflows."
574
+ )
575
+
576
+ super().__init__(
577
+ name="ask_user",
578
+ description="Ask the user a question and wait for their text response",
579
+ handler=self._handler,
580
+ auto_schema=True
581
+ )
582
+ self.context = context
583
+
584
+ async def _handler(self, ctx: Context, question: str) -> str:
585
+ """
586
+ Ask user a question and wait for their response.
587
+
588
+ Args:
589
+ ctx: Execution context (may contain WorkflowContext via contextvar)
590
+ question: Question to ask the user
591
+
592
+ Returns:
593
+ User's text response
594
+ """
595
+ # Import here to avoid circular dependency
596
+ from .workflow import WorkflowContext
597
+ from .context import get_current_context
598
+
599
+ # Use explicit context if provided during __init__
600
+ workflow_ctx = self.context
601
+
602
+ # If not provided, try to get from task-local contextvar
603
+ if workflow_ctx is None:
604
+ current = get_current_context()
605
+ if isinstance(current, WorkflowContext):
606
+ workflow_ctx = current
607
+ elif hasattr(current, '_workflow_entity'):
608
+ # Current context has workflow entity (is WorkflowContext)
609
+ workflow_ctx = current # type: ignore
610
+
611
+ if workflow_ctx is None:
612
+ raise ConfigurationError(
613
+ "AskUserTool requires WorkflowContext. "
614
+ "Either pass context to __init__ or ensure tool is used within a workflow."
615
+ )
616
+
617
+ return await workflow_ctx.wait_for_user(question, input_type="text")
618
+
619
+
620
+ class RequestApprovalTool(Tool):
621
+ """
622
+ Built-in tool that agents can use to request approval from users.
623
+
624
+ This tool pauses the workflow execution and presents an approval request
625
+ to the user with approve/reject options. The workflow resumes when the
626
+ user makes a decision.
627
+
628
+ Example:
629
+ ```python
630
+ from agnt5 import Agent, workflow, WorkflowContext
631
+ from agnt5.tool import RequestApprovalTool
632
+
633
+ @workflow(chat=True)
634
+ async def deployment_agent(ctx: WorkflowContext, changes: dict) -> dict:
635
+ agent = Agent(
636
+ name="deploy_agent",
637
+ model="openai/gpt-4o-mini",
638
+ instructions="You help deploy code changes safely.",
639
+ tools=[RequestApprovalTool(ctx)]
640
+ )
641
+
642
+ result = await agent.run_sync(
643
+ f"Review and deploy these changes: {changes}",
644
+ context=ctx
645
+ )
646
+ return {"response": result.output}
647
+ ```
648
+ """
649
+
650
+ def __init__(self, context: Optional["WorkflowContext"] = None): # type: ignore
651
+ """
652
+ Initialize RequestApprovalTool.
653
+
654
+ Args:
655
+ context: Optional workflow context with wait_for_user capability.
656
+ If not provided, will attempt to get from task-local contextvar.
657
+ """
658
+ # Import here to avoid circular dependency
659
+ from .workflow import WorkflowContext
660
+
661
+ if context is not None and not isinstance(context, WorkflowContext):
662
+ raise ConfigurationError(
663
+ "RequestApprovalTool requires a WorkflowContext. "
664
+ "This tool can only be used within workflows."
665
+ )
666
+
667
+ super().__init__(
668
+ name="request_approval",
669
+ description="Request user approval for an action before proceeding",
670
+ handler=self._handler,
671
+ auto_schema=True
672
+ )
673
+ self.context = context
674
+
675
+ async def _handler(self, ctx: Context, action: str, details: str = "") -> str:
676
+ """
677
+ Request approval from user for an action.
678
+
679
+ Args:
680
+ ctx: Execution context (may contain WorkflowContext via contextvar)
681
+ action: The action requiring approval
682
+ details: Additional details about the action
683
+
684
+ Returns:
685
+ "approve" or "reject" based on user's decision
686
+ """
687
+ # Import here to avoid circular dependency
688
+ from .workflow import WorkflowContext
689
+ from .context import get_current_context
690
+
691
+ # Use explicit context if provided during __init__
692
+ workflow_ctx = self.context
693
+
694
+ # If not provided, try to get from task-local contextvar
695
+ if workflow_ctx is None:
696
+ current = get_current_context()
697
+ if isinstance(current, WorkflowContext):
698
+ workflow_ctx = current
699
+ elif hasattr(current, '_workflow_entity'):
700
+ # Current context has workflow entity (is WorkflowContext)
701
+ workflow_ctx = current # type: ignore
702
+
703
+ if workflow_ctx is None:
704
+ raise ConfigurationError(
705
+ "RequestApprovalTool requires WorkflowContext. "
706
+ "Either pass context to __init__ or ensure tool is used within a workflow."
707
+ )
708
+
709
+ question = f"Action: {action}"
710
+ if details:
711
+ question += f"\n\nDetails:\n{details}"
712
+ question += "\n\nDo you approve?"
713
+
714
+ return await workflow_ctx.wait_for_user(
715
+ question,
716
+ input_type="approval",
717
+ options=[
718
+ {"id": "approve", "label": "Approve"},
719
+ {"id": "reject", "label": "Reject"}
720
+ ]
721
+ )