agent-framework-declarative 1.0.0__py3-none-any.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.
@@ -0,0 +1,660 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Tool invocation executors for declarative workflows.
4
+
5
+ Provides base abstractions and concrete executors for invoking various tool types
6
+ (functions, APIs, MCP servers, etc.) with support for approval flows and structured output.
7
+
8
+ This module is designed for extensibility:
9
+ - BaseToolExecutor provides common patterns (registry lookup, approval flow, output formatting)
10
+ - Concrete executors (InvokeFunctionToolExecutor) implement tool-specific invocation logic
11
+ - New tool types can be added by subclassing BaseToolExecutor
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ import uuid
17
+ from abc import abstractmethod
18
+ from collections.abc import Callable, Mapping
19
+ from dataclasses import dataclass, field
20
+ from inspect import isawaitable
21
+ from typing import Any, cast
22
+
23
+ from agent_framework import (
24
+ Content,
25
+ Message,
26
+ WorkflowContext,
27
+ handler,
28
+ response_handler,
29
+ )
30
+
31
+ from ._declarative_base import (
32
+ ActionComplete,
33
+ DeclarativeActionExecutor,
34
+ DeclarativeWorkflowState,
35
+ )
36
+ from ._executors_agents import TOOL_REGISTRY_KEY
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # Registry key for function tools in State - reuse existing key so functions registered
41
+ # at runtime are discoverable by both agent-based and function-based tool executors.
42
+ FUNCTION_TOOL_REGISTRY_KEY = TOOL_REGISTRY_KEY
43
+
44
+
45
+ # ============================================================================
46
+ # Request/Response Types for Approval Flow
47
+ # ============================================================================
48
+
49
+
50
+ @dataclass
51
+ class ToolApprovalRequest:
52
+ """Request for approval before invoking a tool.
53
+
54
+ Emitted when requireApproval=true, signaling that the workflow should yield
55
+ and wait for user approval before invoking the tool.
56
+
57
+ This follows the same pattern as AgentExternalInputRequest from _executors_agents.py,
58
+ allowing consistent handling of human-in-loop scenarios across agents and tools.
59
+
60
+ Attributes:
61
+ request_id: Unique identifier for this approval request.
62
+ function_name: Evaluated function name to be invoked.
63
+ arguments: Evaluated arguments to be passed to the function.
64
+ """
65
+
66
+ request_id: str
67
+ function_name: str
68
+ arguments: dict[str, Any]
69
+
70
+
71
+ @dataclass
72
+ class ToolApprovalResponse:
73
+ """Response to a ToolApprovalRequest.
74
+
75
+ Provided by the caller to approve or reject tool invocation.
76
+
77
+ Attributes:
78
+ approved: Whether the tool invocation was approved.
79
+ reason: Optional reason for rejection.
80
+ """
81
+
82
+ approved: bool
83
+ reason: str | None = None
84
+
85
+
86
+ # ============================================================================
87
+ # Result Types
88
+ # ============================================================================
89
+
90
+
91
+ @dataclass
92
+ class ToolInvocationResult:
93
+ """Result from a tool invocation.
94
+
95
+ Attributes:
96
+ success: Whether the invocation succeeded.
97
+ result: The return value from the tool (if successful).
98
+ error: Error message (if failed).
99
+ messages: Message list format for conversation history.
100
+ rejected: Whether the invocation was rejected during approval.
101
+ rejection_reason: Reason for rejection.
102
+ """
103
+
104
+ success: bool
105
+ result: Any = None
106
+ error: str | None = None
107
+ messages: list[Message] = field(default_factory=cast(Callable[..., list[Message]], list))
108
+ rejected: bool = False
109
+ rejection_reason: str | None = None
110
+
111
+
112
+ # ============================================================================
113
+ # Helper Functions
114
+ # ============================================================================
115
+
116
+
117
+ def _normalize_variable_path(variable: str) -> str:
118
+ """Normalize variable names to ensure they have a scope prefix.
119
+
120
+ Args:
121
+ variable: Variable name like 'Local.X' or 'weatherResult'
122
+
123
+ Returns:
124
+ The variable path with a scope prefix (defaults to Local if none provided)
125
+ """
126
+ if variable.startswith(("Local.", "System.", "Workflow.", "Agent.", "Conversation.")):
127
+ return variable
128
+ if "." in variable:
129
+ return variable
130
+ return "Local." + variable
131
+
132
+
133
+ # ============================================================================
134
+ # Base Tool Executor (Abstract)
135
+ # ============================================================================
136
+
137
+
138
+ class BaseToolExecutor(DeclarativeActionExecutor):
139
+ """Base class for tool invocation executors.
140
+
141
+ Provides common functionality for all tool-like executors:
142
+ - Tool registry lookup (State + WorkflowFactory registration)
143
+ - Approval flow (request_info pattern with yield/resume)
144
+ - Output formatting (messages as Message list + result variable)
145
+ - Error handling (stores error in output, doesn't raise)
146
+
147
+ Subclasses must implement:
148
+ - _invoke_tool(): Perform the actual tool invocation
149
+
150
+ YAML Schema (common fields):
151
+ kind: <ToolKind>
152
+ id: unique_id
153
+ functionName: function_to_call # required, supports =expression syntax
154
+ requireApproval: true # optional, default=false
155
+ arguments: # optional dictionary
156
+ param1: value1
157
+ param2: =Local.dynamicValue
158
+ output:
159
+ messages: Local.toolCallMessages # Message list
160
+ result: Local.toolResult
161
+ autoSend: true # optional, default=true
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ action_def: dict[str, Any],
167
+ *,
168
+ id: str | None = None,
169
+ tools: dict[str, Any] | None = None,
170
+ ):
171
+ """Initialize the tool executor.
172
+
173
+ Args:
174
+ action_def: The action definition from YAML
175
+ id: Optional executor ID
176
+ tools: Registry of tool instances by name (from WorkflowFactory)
177
+ """
178
+ super().__init__(action_def, id=id)
179
+ self._tools = tools or {}
180
+
181
+ @abstractmethod
182
+ async def _invoke_tool(
183
+ self,
184
+ tool: Any,
185
+ function_name: str,
186
+ arguments: dict[str, Any],
187
+ state: DeclarativeWorkflowState,
188
+ ) -> Any:
189
+ """Invoke the tool with the given arguments.
190
+
191
+ Args:
192
+ tool: The tool instance to invoke
193
+ function_name: Function/method name to call
194
+ arguments: Arguments to pass
195
+ state: Workflow state
196
+
197
+ Returns:
198
+ The result from the tool invocation
199
+
200
+ Raises:
201
+ Any exception from the tool invocation
202
+ """
203
+ pass
204
+
205
+ def _get_tool(
206
+ self,
207
+ function_name: str,
208
+ ctx: WorkflowContext[Any, Any],
209
+ ) -> Any | None:
210
+ """Get tool from registry.
211
+
212
+ Checks both WorkflowFactory registry (self._tools) and State registry.
213
+
214
+ Args:
215
+ function_name: Name of the function
216
+ ctx: Workflow context
217
+
218
+ Returns:
219
+ The tool/function, or None if not found
220
+ """
221
+ # Check WorkflowFactory registry first (passed in constructor)
222
+ tool = self._tools.get(function_name)
223
+ if tool is not None:
224
+ return tool
225
+
226
+ # Check State registry (for runtime registration)
227
+ try:
228
+ tool_registry: dict[str, Any] | None = ctx.state.get(FUNCTION_TOOL_REGISTRY_KEY)
229
+ if tool_registry:
230
+ return tool_registry.get(function_name)
231
+ except KeyError:
232
+ logger.debug(
233
+ "%s: tool registry key '%s' not found in state "
234
+ "(this is normal if tools are only registered via WorkflowFactory)",
235
+ self.__class__.__name__,
236
+ FUNCTION_TOOL_REGISTRY_KEY,
237
+ )
238
+
239
+ return None
240
+
241
+ def _get_output_config(self) -> tuple[str | None, str | None, bool]:
242
+ """Parse output configuration from action definition.
243
+
244
+ Returns:
245
+ Tuple of (messages_var, result_var, auto_send)
246
+ """
247
+ output_config: dict[str, str | bool] = self._action_def.get("output", {})
248
+
249
+ if not isinstance(output_config, Mapping):
250
+ return None, None, True
251
+
252
+ messages_var = output_config.get("messages")
253
+ result_var = output_config.get("result")
254
+ auto_send = bool(output_config.get("autoSend", True))
255
+ return (
256
+ str(messages_var) if messages_var else None,
257
+ str(result_var) if result_var else None,
258
+ auto_send,
259
+ )
260
+
261
+ def _store_result(
262
+ self,
263
+ result: ToolInvocationResult,
264
+ state: DeclarativeWorkflowState,
265
+ messages_var: str | None,
266
+ result_var: str | None,
267
+ ) -> None:
268
+ """Store tool invocation result in workflow state.
269
+
270
+ Args:
271
+ result: The tool invocation result
272
+ state: Workflow state
273
+ messages_var: Variable path for messages output
274
+ result_var: Variable path for result output
275
+ """
276
+ # Store messages if variable specified
277
+ if messages_var:
278
+ path = _normalize_variable_path(messages_var)
279
+ state.set(path, result.messages)
280
+
281
+ # Store result if variable specified
282
+ if result_var:
283
+ path = _normalize_variable_path(result_var)
284
+ if result.rejected:
285
+ state.set(
286
+ path,
287
+ {
288
+ "approved": False,
289
+ "rejected": True,
290
+ "reason": result.rejection_reason,
291
+ },
292
+ )
293
+ elif result.success:
294
+ state.set(path, result.result)
295
+ else:
296
+ state.set(
297
+ path,
298
+ {
299
+ "error": result.error,
300
+ },
301
+ )
302
+
303
+ async def _format_messages(
304
+ self,
305
+ function_name: str,
306
+ arguments: dict[str, Any],
307
+ result: Any,
308
+ ) -> list[Message]:
309
+ """Format tool invocation as Message list.
310
+
311
+ Creates tool call + tool result message pair for conversation history,
312
+ following the same format as agent tool calls.
313
+
314
+ Args:
315
+ function_name: Function name invoked
316
+ arguments: Arguments passed
317
+ result: Result from invocation
318
+
319
+ Returns:
320
+ List of Message objects [tool_call_message, tool_result_message]
321
+ """
322
+ call_id = str(uuid.uuid4())
323
+
324
+ # Safely serialize arguments to JSON
325
+ try:
326
+ arguments_str = json.dumps(arguments) if isinstance(arguments, dict) else str(arguments)
327
+ except (TypeError, ValueError) as e:
328
+ logger.warning(f"Failed to serialize arguments to JSON: {e}")
329
+ arguments_str = str(arguments)
330
+
331
+ # Tool call message (from assistant)
332
+ tool_call_content = Content.from_function_call(
333
+ call_id=call_id,
334
+ name=function_name,
335
+ arguments=arguments_str,
336
+ )
337
+ tool_call_message = Message(
338
+ role="assistant",
339
+ contents=[tool_call_content],
340
+ )
341
+
342
+ # Safely serialize result to JSON
343
+ try:
344
+ result_str = json.dumps(result) if not isinstance(result, str) else result
345
+ except (TypeError, ValueError) as e:
346
+ logger.warning(f"Failed to serialize result to JSON: {e}")
347
+ result_str = str(result)
348
+
349
+ tool_result_content = Content.from_function_result(
350
+ call_id=call_id,
351
+ result=result_str,
352
+ )
353
+ tool_result_message = Message(
354
+ role="tool",
355
+ contents=[tool_result_content],
356
+ )
357
+
358
+ return [tool_call_message, tool_result_message]
359
+
360
+ async def _execute_tool_invocation(
361
+ self,
362
+ function_name: str,
363
+ arguments: dict[str, Any],
364
+ state: DeclarativeWorkflowState,
365
+ ctx: WorkflowContext[Any, Any],
366
+ ) -> ToolInvocationResult:
367
+ """Execute the tool invocation.
368
+
369
+ Args:
370
+ function_name: Function to invoke
371
+ arguments: Arguments to pass
372
+ state: Workflow state
373
+ ctx: Workflow context
374
+
375
+ Returns:
376
+ ToolInvocationResult with outcome
377
+ """
378
+ # Get tool from registry
379
+ tool = self._get_tool(function_name, ctx)
380
+ if tool is None:
381
+ error_msg = f"Function '{function_name}' not found in registry"
382
+ logger.error(f"{self.__class__.__name__}: {error_msg}")
383
+ return ToolInvocationResult(
384
+ success=False,
385
+ error=error_msg,
386
+ )
387
+
388
+ try:
389
+ # Invoke the tool (subclass implements this)
390
+ result_value = await self._invoke_tool(
391
+ tool=tool,
392
+ function_name=function_name,
393
+ arguments=arguments,
394
+ state=state,
395
+ )
396
+
397
+ # Format as messages for conversation history
398
+ messages = await self._format_messages(
399
+ function_name=function_name,
400
+ arguments=arguments,
401
+ result=result_value,
402
+ )
403
+
404
+ return ToolInvocationResult(
405
+ success=True,
406
+ result=result_value,
407
+ messages=messages,
408
+ )
409
+
410
+ except Exception as e:
411
+ logger.error(
412
+ "%s: error invoking function '%s': %s: %s",
413
+ self.__class__.__name__,
414
+ function_name,
415
+ type(e).__name__,
416
+ e,
417
+ exc_info=True,
418
+ )
419
+ return ToolInvocationResult(
420
+ success=False,
421
+ error=f"{type(e).__name__}: {e}",
422
+ )
423
+
424
+ @handler
425
+ async def handle_action(
426
+ self,
427
+ trigger: Any,
428
+ ctx: WorkflowContext[ActionComplete, str],
429
+ ) -> None:
430
+ """Handle the tool invocation with optional approval flow.
431
+
432
+ When requireApproval=true:
433
+ 1. Saves invocation state to State (keyed by executor ID)
434
+ 2. Emits ToolApprovalRequest via ctx.request_info()
435
+ 3. Workflow yields (returns without ActionComplete)
436
+ 4. Resumes in handle_approval_response() when user responds
437
+ """
438
+ state = await self._ensure_state_initialized(ctx, trigger)
439
+
440
+ # Parse output configuration early so we can store errors
441
+ messages_var, result_var, auto_send = self._get_output_config()
442
+
443
+ # Get and evaluate function name (required)
444
+ function_name_expr = self._action_def.get("functionName")
445
+ if not function_name_expr:
446
+ error_msg = f"Action '{self.id}' is missing required 'functionName' field"
447
+ logger.error(f"{self.__class__.__name__}: {error_msg}")
448
+ if result_var:
449
+ state.set(_normalize_variable_path(result_var), {"error": error_msg})
450
+ await ctx.send_message(ActionComplete())
451
+ return
452
+
453
+ function_name = state.eval_if_expression(function_name_expr)
454
+ if not function_name:
455
+ error_msg = f"Action '{self.id}': functionName expression evaluated to empty"
456
+ logger.error(f"{self.__class__.__name__}: {error_msg}")
457
+ if result_var:
458
+ state.set(_normalize_variable_path(result_var), {"error": error_msg})
459
+ await ctx.send_message(ActionComplete())
460
+ return
461
+ function_name = str(function_name)
462
+
463
+ # Evaluate arguments
464
+ arguments_def = self._action_def.get("arguments", {})
465
+ arguments: dict[str, Any] = {}
466
+ if arguments_def is not None and not isinstance(arguments_def, dict):
467
+ logger.warning(
468
+ "%s: 'arguments' must be a dictionary, got %s - ignoring",
469
+ self.__class__.__name__,
470
+ type(arguments_def).__name__,
471
+ )
472
+ elif isinstance(arguments_def, dict):
473
+ for key, value in arguments_def.items(): # type: ignore[reportUnknownVariableType]
474
+ arguments[key] = state.eval_if_expression(value)
475
+
476
+ # Check if approval is required
477
+ require_approval = self._action_def.get("requireApproval", False)
478
+
479
+ if require_approval:
480
+ # Emit approval request - the request payload is the source of
481
+ # truth for resumed invocation; no side-channel state is written.
482
+ request_id = str(uuid.uuid4())
483
+ request = ToolApprovalRequest(
484
+ request_id=request_id,
485
+ function_name=function_name,
486
+ arguments=arguments,
487
+ )
488
+ logger.info(f"{self.__class__.__name__}: requesting approval for '{function_name}'")
489
+ await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
490
+ # Workflow yields - will resume in handle_approval_response
491
+ return
492
+
493
+ # No approval required - invoke directly
494
+ result = await self._execute_tool_invocation(
495
+ function_name=function_name,
496
+ arguments=arguments,
497
+ state=state,
498
+ ctx=ctx,
499
+ )
500
+
501
+ self._store_result(result, state, messages_var, result_var)
502
+ if auto_send and result.success and result.result is not None:
503
+ await ctx.yield_output(str(result.result))
504
+ await ctx.send_message(ActionComplete())
505
+
506
+ @response_handler
507
+ async def handle_approval_response(
508
+ self,
509
+ original_request: ToolApprovalRequest,
510
+ response: ToolApprovalResponse,
511
+ ctx: WorkflowContext[ActionComplete, str],
512
+ ) -> None:
513
+ """Handle response to a ToolApprovalRequest.
514
+
515
+ Resumes after the workflow yielded for approval. The invocation
516
+ ``function_name`` and ``arguments`` are sourced from
517
+ ``original_request`` (the payload the reviewer approved); output
518
+ configuration is re-derived from the executor's action definition.
519
+ """
520
+ state = self._get_state(ctx.state)
521
+
522
+ function_name = original_request.function_name
523
+ arguments = original_request.arguments
524
+ messages_var, result_var, auto_send = self._get_output_config()
525
+
526
+ # Check if approved
527
+ if not response.approved:
528
+ logger.info(f"{self.__class__.__name__}: tool invocation rejected: {response.reason}")
529
+
530
+ # Store rejection status (don't raise error)
531
+ result = ToolInvocationResult(
532
+ success=False,
533
+ rejected=True,
534
+ rejection_reason=response.reason,
535
+ messages=[
536
+ Message(
537
+ role="assistant",
538
+ contents=[
539
+ f"Function '{function_name}' was rejected: {response.reason or 'No reason provided'}"
540
+ ],
541
+ )
542
+ ],
543
+ )
544
+ self._store_result(result, state, messages_var, result_var)
545
+ await ctx.send_message(ActionComplete())
546
+ return
547
+
548
+ # Approved - execute the invocation
549
+ result = await self._execute_tool_invocation(
550
+ function_name=function_name,
551
+ arguments=arguments,
552
+ state=state,
553
+ ctx=ctx,
554
+ )
555
+
556
+ self._store_result(result, state, messages_var, result_var)
557
+ if auto_send and result.success and result.result is not None:
558
+ await ctx.yield_output(str(result.result))
559
+ await ctx.send_message(ActionComplete())
560
+
561
+
562
+ # ============================================================================
563
+ # Function Tool Executor (Concrete)
564
+ # ============================================================================
565
+
566
+
567
+ class InvokeFunctionToolExecutor(BaseToolExecutor):
568
+ """Executor that invokes a Python function as a tool.
569
+
570
+ This executor supports invoking registered Python functions with:
571
+ - Expression evaluation for functionName and arguments
572
+ - Optional approval flow (yield/resume pattern)
573
+ - Async function support
574
+ - Message list output for conversation history
575
+
576
+ YAML Schema:
577
+ kind: InvokeFunctionTool
578
+ id: invoke_function_example
579
+ functionName: get_weather # required, supports =expression syntax
580
+ requireApproval: true # optional, default=false
581
+ arguments: # optional dictionary
582
+ location: =Local.location
583
+ unit: F
584
+ output:
585
+ messages: Local.weatherToolCallItems # Message list
586
+ result: Local.WeatherInfo
587
+ autoSend: true # optional, default=true
588
+
589
+ Tool Registration:
590
+ Tools can be registered via:
591
+ 1. WorkflowFactory.register_tool("name", func) - preferred
592
+ 2. Setting FUNCTION_TOOL_REGISTRY_KEY in State at runtime
593
+
594
+ Examples:
595
+ .. code-block:: python
596
+
597
+ from agent_framework_declarative import WorkflowFactory
598
+
599
+
600
+ def get_weather(location: str, unit: str = "F") -> dict:
601
+ return {"temp": 72, "unit": unit, "location": location}
602
+
603
+
604
+ async def fetch_data(url: str) -> dict:
605
+ # async function example
606
+ return {"data": "..."}
607
+
608
+
609
+ factory = (
610
+ WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data)
611
+ )
612
+
613
+ workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
614
+ """
615
+
616
+ async def _invoke_tool(
617
+ self,
618
+ tool: Any,
619
+ function_name: str,
620
+ arguments: dict[str, Any],
621
+ state: DeclarativeWorkflowState,
622
+ ) -> Any:
623
+ """Invoke the function tool.
624
+
625
+ Supports:
626
+ - Direct callable functions
627
+ - Async functions (via inspect.isawaitable)
628
+
629
+ Args:
630
+ tool: The tool/function to invoke
631
+ function_name: Name of the function (for error messages)
632
+ arguments: Arguments to pass to the function
633
+ state: Workflow state (not used for function tools)
634
+
635
+ Returns:
636
+ The result from the function invocation
637
+
638
+ Raises:
639
+ ValueError: If the tool is not callable
640
+ """
641
+ if not callable(tool):
642
+ raise ValueError(f"Function '{function_name}' is not callable")
643
+
644
+ # Invoke the function
645
+ result = tool(**arguments)
646
+
647
+ # Handle async functions
648
+ if isawaitable(result):
649
+ result = await result
650
+
651
+ return result
652
+
653
+
654
+ # ============================================================================
655
+ # Executor Registry Export
656
+ # ============================================================================
657
+
658
+ TOOL_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
659
+ "InvokeFunctionTool": InvokeFunctionToolExecutor,
660
+ }