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