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