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,1025 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Agent invocation executors for declarative workflows.
4
+
5
+ These executors handle invoking Microsoft Foundry agents and other AI agents,
6
+ supporting both streaming responses and human-in-loop patterns.
7
+
8
+ Aligned with .NET's InvokeAzureAgentExecutor behavior including:
9
+ - Structured input with arguments and messages
10
+ - External loop support for human-in-loop patterns
11
+ - Output with messages and responseObject (JSON parsing)
12
+ - AutoSend behavior control
13
+ """
14
+
15
+ import contextlib
16
+ import json
17
+ import logging
18
+ import uuid
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, cast
21
+
22
+ from agent_framework import (
23
+ Content,
24
+ Message,
25
+ WorkflowContext,
26
+ handler,
27
+ response_handler,
28
+ )
29
+ from agent_framework.exceptions import AgentInvalidRequestException, AgentInvalidResponseException
30
+
31
+ from ._declarative_base import (
32
+ ActionComplete,
33
+ DeclarativeActionExecutor,
34
+ DeclarativeWorkflowState,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ def _extract_json_from_response(text: str) -> Any:
41
+ r"""Extract and parse JSON from an agent response.
42
+
43
+ Agents often return JSON wrapped in markdown code blocks or with
44
+ explanatory text. This function attempts to extract and parse the
45
+ JSON content from various formats:
46
+
47
+ 1. Pure JSON: {"key": "value"}
48
+ 2. Markdown code block: ```json\n{"key": "value"}\n```
49
+ 3. Markdown code block (no language): ```\n{"key": "value"}\n```
50
+ 4. JSON with leading/trailing text: Here's the result: {"key": "value"}
51
+ 5. Multiple JSON objects: Returns the LAST valid JSON object
52
+
53
+ When multiple JSON objects are present (e.g., streaming agent responses
54
+ that emit partial then final results), this returns the last complete
55
+ JSON object, which is typically the final/complete result.
56
+
57
+ Args:
58
+ text: The raw text response from an agent
59
+
60
+ Returns:
61
+ Parsed JSON as a Python dict/list, or None if parsing fails
62
+
63
+ Raises:
64
+ json.JSONDecodeError: If no valid JSON can be extracted
65
+ """
66
+ import re
67
+
68
+ if not text:
69
+ return None
70
+
71
+ text = text.strip()
72
+
73
+ if not text:
74
+ return None
75
+
76
+ # Try parsing as pure JSON first
77
+ try:
78
+ return json.loads(text)
79
+ except json.JSONDecodeError:
80
+ pass
81
+
82
+ # Try extracting from markdown code blocks: ```json ... ``` or ``` ... ```
83
+ # Use the last code block if there are multiple
84
+ code_block_patterns = [
85
+ r"```json\s*\n?(.*?)\n?```", # ```json ... ```
86
+ r"```\s*\n?(.*?)\n?```", # ``` ... ```
87
+ ]
88
+ for pattern in code_block_patterns:
89
+ matches = list(re.finditer(pattern, text, re.DOTALL))
90
+ if matches:
91
+ # Try the last match first (most likely to be the final result)
92
+ for match in reversed(matches):
93
+ try:
94
+ return json.loads(match.group(1).strip())
95
+ except json.JSONDecodeError:
96
+ continue
97
+
98
+ # Find ALL JSON objects {...} or arrays [...] in the text and return the last valid one
99
+ # This handles cases where agents stream multiple JSON objects (partial, then final)
100
+ all_json_objects: list[Any] = []
101
+
102
+ pos = 0
103
+ while pos < len(text):
104
+ # Find next { or [
105
+ json_start = -1
106
+ bracket_char = None
107
+ for i in range(pos, len(text)):
108
+ if text[i] == "{":
109
+ json_start = i
110
+ bracket_char = "{"
111
+ break
112
+ if text[i] == "[":
113
+ json_start = i
114
+ bracket_char = "["
115
+ break
116
+
117
+ if json_start < 0:
118
+ break # No more JSON objects
119
+
120
+ # Find matching closing bracket
121
+ open_bracket = bracket_char
122
+ close_bracket = "}" if open_bracket == "{" else "]"
123
+ depth = 0
124
+ in_string = False
125
+ escape_next = False
126
+ found_end = False
127
+
128
+ for i in range(json_start, len(text)):
129
+ char = text[i]
130
+
131
+ if escape_next:
132
+ escape_next = False
133
+ continue
134
+
135
+ if char == "\\":
136
+ escape_next = True
137
+ continue
138
+
139
+ if char == '"' and not escape_next:
140
+ in_string = not in_string
141
+ continue
142
+
143
+ if in_string:
144
+ continue
145
+
146
+ if char == open_bracket:
147
+ depth += 1
148
+ elif char == close_bracket:
149
+ depth -= 1
150
+ if depth == 0:
151
+ # Found the end
152
+ potential_json = text[json_start : i + 1]
153
+ try:
154
+ parsed = json.loads(potential_json)
155
+ all_json_objects.append(parsed)
156
+ except json.JSONDecodeError:
157
+ pass
158
+ pos = i + 1
159
+ found_end = True
160
+ break
161
+
162
+ if not found_end:
163
+ # Malformed JSON, move past the start character
164
+ pos = json_start + 1
165
+
166
+ # Return the last valid JSON object (most likely to be the final/complete result)
167
+ if all_json_objects:
168
+ return all_json_objects[-1]
169
+
170
+ # Unable to extract JSON
171
+ raise json.JSONDecodeError("No valid JSON found in response", text, 0)
172
+
173
+
174
+ def _validate_conversation_history(messages: list[Message], agent_name: str) -> None:
175
+ """Validate that conversation history has matching tool calls and results.
176
+
177
+ This helps catch issues where tool call messages are stored without their
178
+ corresponding tool result messages, which would cause API errors.
179
+
180
+ Args:
181
+ messages: The conversation history to validate.
182
+ agent_name: Name of the agent for logging purposes.
183
+
184
+ Logs a warning if orphaned tool calls are found.
185
+ """
186
+ # Collect all tool call IDs and tool result IDs
187
+ tool_call_ids: set[str] = set()
188
+ tool_result_ids: set[str] = set()
189
+
190
+ for i, msg in enumerate(messages):
191
+ if not (contents := getattr(msg, "contents", None)):
192
+ continue
193
+ for content in contents:
194
+ if content.type == "function_call" and content.call_id:
195
+ tool_call_ids.add(content.call_id)
196
+ logger.debug(
197
+ "Agent '%s': Found tool call '%s' (id=%s) in message %d",
198
+ agent_name,
199
+ content.name,
200
+ content.call_id,
201
+ i,
202
+ )
203
+ elif content.type == "function_result" and content.call_id:
204
+ tool_result_ids.add(content.call_id)
205
+ logger.debug(
206
+ "Agent '%s': Found tool result for call_id=%s in message %d",
207
+ agent_name,
208
+ content.call_id,
209
+ i,
210
+ )
211
+
212
+ # Find orphaned tool calls (calls without results)
213
+ orphaned_calls = tool_call_ids - tool_result_ids
214
+ if orphaned_calls:
215
+ logger.warning(
216
+ "Agent '%s': Conversation history has %d orphaned tool call(s) without results: %s. "
217
+ "Total messages: %d, tool calls: %d, tool results: %d",
218
+ agent_name,
219
+ len(orphaned_calls),
220
+ orphaned_calls,
221
+ len(messages),
222
+ len(tool_call_ids),
223
+ len(tool_result_ids),
224
+ )
225
+ # Log message structure for debugging
226
+ for i, msg in enumerate(messages):
227
+ role = getattr(msg, "role", "unknown")
228
+ content_types = []
229
+ if hasattr(msg, "contents") and msg.contents:
230
+ content_types = [type(c).__name__ for c in msg.contents]
231
+ logger.warning(
232
+ "Agent '%s': Message %d - role=%s, contents=%s",
233
+ agent_name,
234
+ i,
235
+ role,
236
+ content_types,
237
+ )
238
+
239
+
240
+ # Keys for agent-related state
241
+ AGENT_REGISTRY_KEY = "_agent_registry"
242
+ TOOL_REGISTRY_KEY = "_tool_registry"
243
+ # Key to store external loop state for resumption
244
+ EXTERNAL_LOOP_STATE_KEY = "_external_loop_state"
245
+
246
+
247
+ @dataclass
248
+ class AgentResult:
249
+ """Result from an agent invocation."""
250
+
251
+ success: bool
252
+ response: str
253
+ agent_name: str
254
+ messages: list[Message] = field(default_factory=lambda: cast(list[Message], []))
255
+ tool_calls: list[Content] = field(default_factory=lambda: cast(list[Content], []))
256
+ error: str | None = None
257
+
258
+
259
+ @dataclass
260
+ class AgentExternalInputRequest:
261
+ """Request for external input during agent invocation.
262
+
263
+ Emitted when externalLoop.when condition evaluates to true,
264
+ signaling that the workflow should yield and wait for user input.
265
+
266
+ This is the request type used with ctx.request_info() to implement
267
+ the Yield/Resume pattern for human-in-loop workflows.
268
+
269
+ Examples:
270
+ .. code-block:: python
271
+
272
+ from agent_framework import run_context
273
+ from agent_framework_declarative import (
274
+ ExternalInputRequest,
275
+ ExternalInputResponse,
276
+ WorkflowFactory,
277
+ )
278
+
279
+ factory = WorkflowFactory()
280
+ workflow = factory.create_workflow_from_yaml_path("hitl_workflow.yaml")
281
+
282
+
283
+ async def run_with_hitl():
284
+ # Set up external input handler
285
+ async def on_request(request: AgentExternalInputRequest) -> ExternalInputResponse:
286
+ print(f"Agent '{request.agent_name}' needs input:")
287
+ print(f" Response: {request.agent_response}")
288
+ user_input = input("Your response: ")
289
+ return AgentExternalInputResponse(user_input=user_input)
290
+
291
+ async with run_context(request_handler=on_request) as ctx:
292
+ async for event in workflow.run(ctx=ctx, stream=True):
293
+ print(event)
294
+ """
295
+
296
+ request_id: str
297
+ agent_name: str
298
+ agent_response: str
299
+ iteration: int = 0
300
+ messages: list[Message] = field(default_factory=lambda: cast(list[Message], []))
301
+ function_calls: list[Content] = field(default_factory=lambda: cast(list[Content], []))
302
+
303
+
304
+ @dataclass
305
+ class AgentExternalInputResponse:
306
+ """Response to an ExternalInputRequest.
307
+
308
+ Provided by the caller to resume agent execution with new user input.
309
+ This is the response type expected by the response_handler.
310
+
311
+ Examples:
312
+ .. code-block:: python
313
+
314
+ from agent_framework_declarative import ExternalInputResponse
315
+
316
+ # Basic response with user text input
317
+ response = AgentExternalInputResponse(user_input="Yes, please proceed with the order.")
318
+
319
+ .. code-block:: python
320
+
321
+ from agent_framework_declarative import ExternalInputResponse
322
+
323
+ # Response with additional message history
324
+ response = AgentExternalInputResponse(
325
+ user_input="Approved",
326
+ messages=[], # Additional context messages if needed
327
+ )
328
+ """
329
+
330
+ user_input: str
331
+ messages: list[Message] = field(default_factory=lambda: cast(list[Message], []))
332
+ function_results: dict[str, Content] = field(default_factory=lambda: cast(dict[str, Content], {}))
333
+
334
+
335
+ @dataclass
336
+ class ExternalLoopState:
337
+ """State saved for external loop resumption.
338
+
339
+ Stored in workflow state to allow the response_handler to
340
+ continue the loop with the same configuration.
341
+ """
342
+
343
+ agent_name: str
344
+ iteration: int
345
+ external_loop_when: str
346
+ messages_var: str | None
347
+ response_obj_var: str | None
348
+ result_property: str | None
349
+ auto_send: bool
350
+ messages_path: str = "Conversation.messages"
351
+ max_iterations: int = 100
352
+
353
+
354
+ def _normalize_variable_path(variable: str) -> str:
355
+ """Normalize variable names to ensure they have a scope prefix.
356
+
357
+ Args:
358
+ variable: Variable name like 'Local.X' or 'System.ConversationId'
359
+
360
+ Returns:
361
+ The variable path with a scope prefix (defaults to Local if none provided)
362
+ """
363
+ if variable.startswith(("Local.", "System.", "Workflow.", "Agent.", "Conversation.")):
364
+ # Already has a proper namespace
365
+ return variable
366
+ if "." in variable:
367
+ # Has some namespace, use as-is
368
+ return variable
369
+ # Default to Local scope
370
+ return "Local." + variable
371
+
372
+
373
+ class InvokeAzureAgentExecutor(DeclarativeActionExecutor):
374
+ """Executor that invokes a Microsoft Foundry agent.
375
+
376
+ This executor supports both Python-style and .NET-style YAML schemas:
377
+
378
+ Python-style (simple):
379
+ kind: InvokeAzureAgent
380
+ agent: MenuAgent
381
+ input: =Local.userInput
382
+ resultProperty: Local.agentResponse
383
+
384
+ .NET-style (full featured):
385
+ kind: InvokeAzureAgent
386
+ agent:
387
+ name: AgentName
388
+ conversationId: =System.ConversationId
389
+ input:
390
+ arguments:
391
+ param1: =Local.value1
392
+ param2: literal value
393
+ messages: =Conversation.messages
394
+ externalLoop:
395
+ when: =Local.needsMoreInput
396
+ output:
397
+ messages: Local.ResponseMessages
398
+ responseObject: Local.StructuredResponse
399
+ autoSend: true
400
+
401
+ Features:
402
+ - Structured input with arguments and messages
403
+ - External loop support for human-in-loop patterns
404
+ - Output with messages and responseObject (JSON parsing)
405
+ - AutoSend behavior control for streaming output
406
+ """
407
+
408
+ def __init__(
409
+ self,
410
+ action_def: dict[str, Any],
411
+ *,
412
+ id: str | None = None,
413
+ agents: dict[str, Any] | None = None,
414
+ ):
415
+ """Initialize the agent executor.
416
+
417
+ Args:
418
+ action_def: The action definition from YAML
419
+ id: Optional executor ID
420
+ agents: Registry of agent instances by name
421
+ """
422
+ super().__init__(action_def, id=id)
423
+ self._agents = agents or {}
424
+
425
+ def _get_agent_name(self, state: Any) -> str | None:
426
+ """Extract agent name from action definition.
427
+
428
+ Supports both simple string and nested object formats.
429
+ """
430
+ agent_config = self._action_def.get("agent")
431
+
432
+ if isinstance(agent_config, str):
433
+ if agent_config.startswith("="):
434
+ evaluated = state.eval_if_expression(agent_config)
435
+ return str(evaluated) if evaluated is not None else None
436
+ return agent_config
437
+
438
+ if isinstance(agent_config, dict):
439
+ agent_dict = cast(dict[str, Any], agent_config)
440
+ name = agent_dict.get("name")
441
+ if name is not None and isinstance(name, str):
442
+ if name.startswith("="):
443
+ evaluated = state.eval_if_expression(name)
444
+ return str(evaluated) if evaluated is not None else None
445
+ return str(name)
446
+
447
+ agent_name = self._action_def.get("agentName")
448
+ if isinstance(agent_name, str):
449
+ if agent_name.startswith("="):
450
+ evaluated = state.eval_if_expression(agent_name)
451
+ return str(evaluated) if evaluated is not None else None
452
+ return agent_name
453
+ return None
454
+
455
+ def _get_input_config(self) -> tuple[dict[str, Any], Any, str | None, int]:
456
+ """Parse input configuration.
457
+
458
+ Returns:
459
+ Tuple of (arguments dict, messages expression, externalLoop.when expression, maxIterations)
460
+ """
461
+ input_config = self._action_def.get("input", {})
462
+
463
+ if not isinstance(input_config, dict):
464
+ # Simple input - treat as message directly
465
+ return {}, input_config, None, 100
466
+
467
+ input_dict = cast(dict[str, Any], input_config)
468
+ arguments: dict[str, Any] = cast(dict[str, Any], input_dict.get("arguments", {}))
469
+ messages: Any = input_dict.get("messages")
470
+
471
+ # Extract external loop configuration
472
+ external_loop_when: str | None = None
473
+ max_iterations: int = 100 # Default safety limit
474
+ external_loop = input_dict.get("externalLoop")
475
+ if isinstance(external_loop, dict):
476
+ loop_dict = cast(dict[str, Any], external_loop)
477
+ when_val = loop_dict.get("when")
478
+ external_loop_when = str(when_val) if when_val is not None else None
479
+ max_iter_val = loop_dict.get("maxIterations")
480
+ if max_iter_val is not None:
481
+ max_iterations = int(max_iter_val)
482
+
483
+ return arguments, messages, external_loop_when, max_iterations
484
+
485
+ def _get_output_config(self) -> tuple[str | None, str | None, str | None, bool]:
486
+ """Parse output configuration.
487
+
488
+ Returns:
489
+ Tuple of (messages var, responseObject var, resultProperty, autoSend)
490
+ """
491
+ output_config = self._action_def.get("output", {})
492
+
493
+ # Legacy Python-style
494
+ result_property: str | None = cast(str | None, self._action_def.get("resultProperty"))
495
+
496
+ if not isinstance(output_config, dict):
497
+ return None, None, result_property, True
498
+
499
+ output_dict = cast(dict[str, Any], output_config)
500
+ messages_var_val: Any = output_dict.get("messages")
501
+ messages_var: str | None = str(messages_var_val) if messages_var_val is not None else None
502
+ response_obj_val: Any = output_dict.get("responseObject")
503
+ response_obj_var: str | None = str(response_obj_val) if response_obj_val is not None else None
504
+ property_val: Any = output_dict.get("property")
505
+ property_var: str | None = str(property_val) if property_val is not None else None
506
+ auto_send_val: Any = output_dict.get("autoSend", True)
507
+ auto_send: bool = bool(auto_send_val)
508
+
509
+ return messages_var, response_obj_var, property_var or result_property, auto_send
510
+
511
+ def _get_conversation_id(self) -> str | None:
512
+ """Get the conversation ID expression from action definition.
513
+
514
+ Returns:
515
+ The conversationId expression/value, or None if not specified
516
+ """
517
+ return self._action_def.get("conversationId")
518
+
519
+ async def _get_conversation_messages_path(
520
+ self, state: DeclarativeWorkflowState, conversation_id_expr: str | None
521
+ ) -> str:
522
+ """Get the state path for conversation messages.
523
+
524
+ Args:
525
+ state: Workflow state for expression evaluation
526
+ conversation_id_expr: The conversationId expression from action definition
527
+
528
+ Returns:
529
+ State path for messages (e.g., "Conversation.messages" or "System.conversations.{id}.messages")
530
+ """
531
+ if not conversation_id_expr:
532
+ return "Conversation.messages"
533
+
534
+ # Evaluate the conversation ID expression
535
+ evaluated_id = state.eval_if_expression(conversation_id_expr)
536
+ if not evaluated_id:
537
+ return "Conversation.messages"
538
+
539
+ # Use conversation-specific messages path
540
+ return f"System.conversations.{evaluated_id}.messages"
541
+
542
+ async def _build_input_text(self, state: Any, arguments: dict[str, Any], messages_expr: Any) -> str:
543
+ """Build input text from arguments and messages.
544
+
545
+ Args:
546
+ state: Workflow state for expression evaluation
547
+ arguments: Input arguments to evaluate
548
+ messages_expr: Messages expression or direct input
549
+
550
+ Returns:
551
+ Input text for the agent
552
+ """
553
+ # Evaluate arguments
554
+ evaluated_args: dict[str, Any] = {}
555
+ for key, value in arguments.items():
556
+ evaluated_args[key] = state.eval_if_expression(value)
557
+
558
+ # Evaluate messages/input
559
+ if messages_expr:
560
+ evaluated_input: Any = state.eval_if_expression(messages_expr)
561
+ if isinstance(evaluated_input, str):
562
+ return evaluated_input
563
+ if isinstance(evaluated_input, list) and evaluated_input:
564
+ # Extract text from last message
565
+ last: Any = evaluated_input[-1] # type: ignore
566
+ if isinstance(last, str):
567
+ return last
568
+ if isinstance(last, dict):
569
+ last_dict = cast(dict[str, Any], last)
570
+ content_val: Any = last_dict.get("content", last_dict.get("text", ""))
571
+ return str(content_val) if content_val else ""
572
+ if last is not None and hasattr(last, "text"): # type: ignore
573
+ return str(getattr(last, "text", "")) # type: ignore
574
+ if evaluated_input:
575
+ return str(cast(Any, evaluated_input))
576
+ return ""
577
+
578
+ # Fallback chain for implicit input (like .NET conversationId pattern):
579
+ # 1. Local.input / Local.userInput (explicit turn state)
580
+ # 2. System.LastMessage.Text (previous agent's response)
581
+ # 3. Workflow.Inputs (first agent gets workflow inputs)
582
+ input_text: str = str(state.get("Local.input") or state.get("Local.userInput") or "")
583
+ if not input_text:
584
+ # Try System.LastMessage.Text (used by external loop and agent chaining)
585
+ last_message: Any = state.get("System.LastMessage")
586
+ if isinstance(last_message, dict):
587
+ last_msg_dict = cast(dict[str, Any], last_message)
588
+ text_val: Any = last_msg_dict.get("Text", "")
589
+ input_text = str(text_val) if text_val else ""
590
+ if not input_text:
591
+ # Fall back to workflow inputs (for first agent in chain)
592
+ inputs: Any = state.get("Workflow.Inputs")
593
+ if isinstance(inputs, dict):
594
+ inputs_dict = cast(dict[str, Any], inputs)
595
+ # If single input, use its value directly
596
+ if len(inputs_dict) == 1:
597
+ input_text = str(next(iter(inputs_dict.values())))
598
+ else:
599
+ # Multiple inputs - format as key: value pairs
600
+ input_text = "\n".join(f"{k}: {v}" for k, v in inputs_dict.items())
601
+ return input_text if input_text else ""
602
+
603
+ def _get_agent(self, agent_name: str, ctx: WorkflowContext[Any, Any]) -> Any:
604
+ """Get agent from registry (sync helper for response handler)."""
605
+ return self._agents.get(agent_name) if self._agents else None
606
+
607
+ async def _invoke_agent_and_store_results(
608
+ self,
609
+ agent: Any,
610
+ agent_name: str,
611
+ input_text: str,
612
+ state: DeclarativeWorkflowState,
613
+ ctx: WorkflowContext[ActionComplete, str],
614
+ messages_var: str | None,
615
+ response_obj_var: str | None,
616
+ result_property: str | None,
617
+ auto_send: bool,
618
+ messages_path: str = "Conversation.messages",
619
+ ) -> tuple[str, list[Any], list[Any]]:
620
+ """Invoke agent and store results in state.
621
+
622
+ Args:
623
+ agent: The agent instance to invoke
624
+ agent_name: Name of the agent for logging
625
+ input_text: User input text
626
+ state: Workflow state
627
+ ctx: Workflow context
628
+ messages_var: Output variable for messages
629
+ response_obj_var: Output variable for parsed response object
630
+ result_property: Output property for result
631
+ auto_send: Whether to auto-send output to context
632
+ messages_path: State path for conversation messages (default: "Conversation.messages")
633
+
634
+ Returns:
635
+ Tuple of (accumulated_response, all_messages, tool_calls)
636
+ """
637
+ accumulated_response = ""
638
+ all_messages: list[Message] = []
639
+ tool_calls: list[Content] = []
640
+
641
+ # Add user input to conversation history first (via state.append only)
642
+ if input_text:
643
+ user_message = Message(role="user", contents=[input_text])
644
+ state.append(messages_path, user_message)
645
+
646
+ # Get conversation history from state AFTER adding user message
647
+ # Note: We get a fresh copy to avoid mutation issues
648
+ conversation_history: list[Message] = state.get(messages_path) or []
649
+
650
+ # Build messages list for agent (use history if available, otherwise just input)
651
+ messages_for_agent: list[Message] | str = conversation_history if conversation_history else input_text
652
+
653
+ # Validate conversation history before invoking agent
654
+ if isinstance(messages_for_agent, list) and messages_for_agent:
655
+ _validate_conversation_history(messages_for_agent, agent_name)
656
+
657
+ # Retrieve kwargs passed to workflow.run() so they propagate to agent tools
658
+ from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
659
+
660
+ run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
661
+ options: dict[str, Any] | None = None
662
+ if run_kwargs:
663
+ # Merge caller-provided options to avoid duplicate keyword argument
664
+ options = dict(run_kwargs.get("options") or {})
665
+ options["additional_function_arguments"] = run_kwargs
666
+ # Exclude 'options' from splat to avoid TypeError on duplicate keyword
667
+ run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"}
668
+
669
+ # Use run() method to get properly structured messages (including tool calls and results)
670
+ # This is critical for multi-turn conversations where tool calls must be followed
671
+ # by their results in the message history
672
+ result: Any = await agent.run(messages_for_agent, options=options, **run_kwargs)
673
+ if hasattr(result, "text") and result.text:
674
+ accumulated_response = str(result.text)
675
+ if auto_send:
676
+ await ctx.yield_output(str(result.text))
677
+ elif isinstance(result, str):
678
+ accumulated_response = result
679
+ if auto_send:
680
+ await ctx.yield_output(result)
681
+
682
+ if not isinstance(result, str):
683
+ result_messages: Any = getattr(result, "messages", None)
684
+ if result_messages is not None:
685
+ all_messages = list(cast(list[Message], result_messages))
686
+ result_tool_calls: Any = getattr(result, "tool_calls", None)
687
+ if result_tool_calls is not None:
688
+ tool_calls = list(cast(list[Content], result_tool_calls))
689
+
690
+ # Add messages to conversation history
691
+ # We need to include ALL messages from the agent run (including tool calls and tool results)
692
+ # to maintain proper conversation state for the next agent invocation
693
+ if all_messages:
694
+ # Agent returned full message history - use it
695
+ logger.debug(
696
+ "Agent '%s': Storing %d messages to conversation history at '%s'",
697
+ agent_name,
698
+ len(all_messages),
699
+ messages_path,
700
+ )
701
+ for i, msg in enumerate(all_messages):
702
+ role = getattr(msg, "role", "unknown")
703
+ content_types = []
704
+ if hasattr(msg, "contents") and msg.contents:
705
+ content_types = [type(c).__name__ for c in msg.contents]
706
+ logger.debug(
707
+ "Agent '%s': Storing message %d - role=%s, contents=%s",
708
+ agent_name,
709
+ i,
710
+ role,
711
+ content_types,
712
+ )
713
+ state.append(messages_path, msg)
714
+ elif accumulated_response:
715
+ # No messages returned, create a simple assistant message
716
+ logger.debug(
717
+ "Agent '%s': No messages in response, creating simple assistant message",
718
+ agent_name,
719
+ )
720
+ assistant_message = Message(role="assistant", contents=[accumulated_response])
721
+ state.append(messages_path, assistant_message)
722
+
723
+ # Store results in state - support both schema formats:
724
+ # - Graph mode: Agent.response, Agent.name
725
+ # - Interpreter mode: Agent.text, Agent.messages, Agent.toolCalls
726
+ state.set("Agent.response", accumulated_response)
727
+ state.set("Agent.name", agent_name)
728
+ state.set("Agent.text", accumulated_response)
729
+ state.set("Agent.messages", all_messages if all_messages else [])
730
+ state.set("Agent.toolCalls", tool_calls if tool_calls else [])
731
+
732
+ # Store System.LastMessage for externalLoop.when condition evaluation
733
+ state.set("System.LastMessage", {"Text": accumulated_response})
734
+
735
+ # Store in output variables (.NET style)
736
+ if messages_var:
737
+ output_path = _normalize_variable_path(messages_var)
738
+ state.set(output_path, all_messages if all_messages else accumulated_response)
739
+
740
+ if response_obj_var:
741
+ output_path = _normalize_variable_path(response_obj_var)
742
+ # Try to extract and parse JSON from the response
743
+ try:
744
+ parsed = _extract_json_from_response(accumulated_response) if accumulated_response else None
745
+ logger.debug(f"InvokeAzureAgent: parsed responseObject for '{output_path}': type={type(parsed)}")
746
+ state.set(output_path, parsed)
747
+ except (json.JSONDecodeError, TypeError) as e:
748
+ logger.warning(f"InvokeAzureAgent: failed to parse JSON for '{output_path}': {e}, storing as string")
749
+ state.set(output_path, accumulated_response)
750
+
751
+ # Store in result property (Python style)
752
+ if result_property:
753
+ state.set(result_property, accumulated_response)
754
+
755
+ return accumulated_response, all_messages, tool_calls
756
+
757
+ @handler
758
+ async def handle_action(
759
+ self,
760
+ trigger: Any,
761
+ ctx: WorkflowContext[ActionComplete, str],
762
+ ) -> None:
763
+ """Handle the agent invocation with full .NET feature parity.
764
+
765
+ When externalLoop.when is configured and evaluates to true after agent response,
766
+ this method emits an ExternalInputRequest via ctx.request_info() and returns.
767
+ The workflow will yield, and when the caller provides a response via
768
+ run(responses=..., stream=True), the handle_external_input_response handler
769
+ will continue the loop.
770
+ """
771
+ state = await self._ensure_state_initialized(ctx, trigger)
772
+
773
+ # Parse configuration
774
+ agent_name = self._get_agent_name(state)
775
+ if not agent_name:
776
+ logger.warning("InvokeAzureAgent action missing 'agent' or 'agent.name' property")
777
+ await ctx.send_message(ActionComplete())
778
+ return
779
+
780
+ logger.debug("handle_action: starting agent '%s'", agent_name)
781
+
782
+ arguments, messages_expr, external_loop_when, max_iterations = self._get_input_config()
783
+ messages_var, response_obj_var, result_property, auto_send = self._get_output_config()
784
+
785
+ # Get conversation-specific messages path if conversationId is specified
786
+ conversation_id_expr = self._get_conversation_id()
787
+ messages_path = await self._get_conversation_messages_path(state, conversation_id_expr)
788
+ logger.debug("handle_action: agent='%s', messages_path='%s'", agent_name, messages_path)
789
+
790
+ # Build input
791
+ input_text = await self._build_input_text(state, arguments, messages_expr)
792
+
793
+ # Get agent from registry
794
+ agent: Any = self._agents.get(agent_name) if self._agents else None
795
+ if agent is None:
796
+ try:
797
+ agent_registry: dict[str, Any] | None = ctx.state.get(AGENT_REGISTRY_KEY)
798
+ except KeyError:
799
+ agent_registry = {}
800
+ agent = agent_registry.get(agent_name) if agent_registry else None
801
+
802
+ if agent is None:
803
+ error_msg = f"Agent '{agent_name}' not found in registry"
804
+ logger.error(f"InvokeAzureAgent: {error_msg}")
805
+ state.set("Agent.error", error_msg)
806
+ if result_property:
807
+ state.set(result_property, {"error": error_msg})
808
+ raise AgentInvalidRequestException(f"Agent '{agent_name}' invocation failed: not found in registry")
809
+
810
+ iteration = 0
811
+
812
+ try:
813
+ accumulated_response, all_messages, tool_calls = await self._invoke_agent_and_store_results(
814
+ agent=agent,
815
+ agent_name=agent_name,
816
+ input_text=input_text,
817
+ state=state,
818
+ ctx=ctx,
819
+ messages_var=messages_var,
820
+ response_obj_var=response_obj_var,
821
+ result_property=result_property,
822
+ auto_send=auto_send,
823
+ messages_path=messages_path,
824
+ )
825
+ except (AgentInvalidRequestException, AgentInvalidResponseException):
826
+ raise # Re-raise our own errors
827
+ except Exception as e:
828
+ logger.error(f"InvokeAzureAgent: error invoking agent '{agent_name}': {e}")
829
+ state.set("Agent.error", str(e))
830
+ if result_property:
831
+ state.set(result_property, {"error": str(e)})
832
+ raise AgentInvalidResponseException(f"Agent '{agent_name}' invocation failed: {e}") from e
833
+
834
+ # Check external loop condition
835
+ if external_loop_when:
836
+ should_continue = state.eval(external_loop_when)
837
+ should_continue = bool(should_continue) if should_continue is not None else False
838
+
839
+ logger.debug(
840
+ f"InvokeAzureAgent: external loop condition '{str(external_loop_when)[:50]}' = "
841
+ f"{should_continue} (iteration {iteration})"
842
+ )
843
+
844
+ if should_continue:
845
+ # Save loop state for resumption
846
+ loop_state = ExternalLoopState(
847
+ agent_name=agent_name,
848
+ iteration=iteration + 1,
849
+ external_loop_when=external_loop_when,
850
+ messages_var=messages_var,
851
+ response_obj_var=response_obj_var,
852
+ result_property=result_property,
853
+ auto_send=auto_send,
854
+ messages_path=messages_path,
855
+ max_iterations=max_iterations,
856
+ )
857
+ ctx.state.set(EXTERNAL_LOOP_STATE_KEY, loop_state)
858
+
859
+ # Emit request for external input - workflow will yield here
860
+ request = AgentExternalInputRequest(
861
+ request_id=str(uuid.uuid4()),
862
+ agent_name=agent_name,
863
+ agent_response=accumulated_response,
864
+ iteration=iteration,
865
+ messages=all_messages,
866
+ function_calls=tool_calls,
867
+ )
868
+ logger.info(f"InvokeAzureAgent: yielding for external input (iteration {iteration})")
869
+ await ctx.request_info(request, AgentExternalInputResponse)
870
+ # Return without sending ActionComplete - workflow yields
871
+ return
872
+
873
+ # No external loop or condition is false - complete the action
874
+ await ctx.send_message(ActionComplete())
875
+
876
+ @response_handler
877
+ async def handle_external_input_response(
878
+ self,
879
+ original_request: AgentExternalInputRequest,
880
+ response: AgentExternalInputResponse,
881
+ ctx: WorkflowContext[ActionComplete, str],
882
+ ) -> None:
883
+ """Handle response to an ExternalInputRequest and continue the loop.
884
+
885
+ This is called when the workflow resumes after yielding for external input.
886
+ It continues the agent invocation loop with the user's new input.
887
+ """
888
+ logger.debug(
889
+ "handle_external_input_response: resuming with user_input='%s'",
890
+ response.user_input[:100] if response.user_input else None,
891
+ )
892
+ state = self._get_state(ctx.state)
893
+
894
+ # Retrieve saved loop state
895
+ loop_state: ExternalLoopState | None = ctx.state.get(EXTERNAL_LOOP_STATE_KEY)
896
+ if loop_state is None:
897
+ logger.error("InvokeAzureAgent: external loop state not found, cannot resume")
898
+ await ctx.send_message(ActionComplete())
899
+ return
900
+
901
+ agent_name = loop_state.agent_name
902
+ iteration = loop_state.iteration
903
+ external_loop_when = loop_state.external_loop_when
904
+ max_iterations = loop_state.max_iterations
905
+ messages_path = loop_state.messages_path
906
+
907
+ logger.debug(
908
+ "handle_external_input_response: agent='%s', iteration=%d, messages_path='%s'",
909
+ agent_name,
910
+ iteration,
911
+ messages_path,
912
+ )
913
+
914
+ # Get the user's new input
915
+ input_text = response.user_input
916
+
917
+ # Store the user input in state for condition evaluation
918
+ state.set("Local.userInput", input_text)
919
+ state.set("System.LastMessage", {"Text": input_text})
920
+
921
+ # Check if we should continue BEFORE invoking the agent
922
+ # This matches .NET behavior where the condition checks the user's input
923
+ should_continue = state.eval(external_loop_when)
924
+ should_continue = bool(should_continue) if should_continue is not None else False
925
+
926
+ logger.debug(
927
+ f"InvokeAzureAgent: external loop condition '{str(external_loop_when)[:50]}' = "
928
+ f"{should_continue} (iteration {iteration}) for input '{input_text[:30]}...'"
929
+ )
930
+
931
+ if not should_continue:
932
+ # User input caused loop to exit - clean up and complete
933
+ with contextlib.suppress(KeyError):
934
+ ctx.state.delete(EXTERNAL_LOOP_STATE_KEY)
935
+ await ctx.send_message(ActionComplete())
936
+ return
937
+
938
+ # Get agent from registry
939
+ agent: Any = self._agents.get(agent_name) if self._agents else None
940
+ if agent is None:
941
+ try:
942
+ agent_registry: dict[str, Any] | None = ctx.state.get(AGENT_REGISTRY_KEY)
943
+ except KeyError:
944
+ agent_registry = {}
945
+ agent = agent_registry.get(agent_name) if agent_registry else None
946
+
947
+ if agent is None:
948
+ logger.error(f"InvokeAzureAgent: agent '{agent_name}' not found during loop resumption")
949
+ raise AgentInvalidRequestException(
950
+ f"Agent '{agent_name}' invocation failed: not found during loop resumption"
951
+ )
952
+
953
+ try:
954
+ accumulated_response, all_messages, tool_calls = await self._invoke_agent_and_store_results(
955
+ agent=agent,
956
+ agent_name=agent_name,
957
+ input_text=input_text,
958
+ state=state,
959
+ ctx=ctx,
960
+ messages_var=loop_state.messages_var,
961
+ response_obj_var=loop_state.response_obj_var,
962
+ result_property=loop_state.result_property,
963
+ auto_send=loop_state.auto_send,
964
+ messages_path=loop_state.messages_path,
965
+ )
966
+ except (AgentInvalidRequestException, AgentInvalidResponseException):
967
+ raise # Re-raise our own errors
968
+ except Exception as e:
969
+ logger.error(f"InvokeAzureAgent: error invoking agent '{agent_name}' during loop: {e}")
970
+ state.set("Agent.error", str(e))
971
+ raise AgentInvalidResponseException(f"Agent '{agent_name}' invocation failed: {e}") from e
972
+
973
+ # Re-evaluate the condition AFTER the agent responds
974
+ # This is critical: the agent's response may have set NeedsTicket=true or IsResolved=true
975
+ should_continue = state.eval(external_loop_when)
976
+ should_continue = bool(should_continue) if should_continue is not None else False
977
+
978
+ logger.debug(
979
+ f"InvokeAzureAgent: external loop condition after response '{str(external_loop_when)[:50]}' = "
980
+ f"{should_continue} (iteration {iteration})"
981
+ )
982
+
983
+ if not should_continue:
984
+ # Agent response caused loop to exit (e.g., NeedsTicket=true or IsResolved=true)
985
+ logger.info(
986
+ "InvokeAzureAgent: external loop exited due to condition=false "
987
+ "(sending ActionComplete to continue workflow)"
988
+ )
989
+ with contextlib.suppress(KeyError):
990
+ ctx.state.delete(EXTERNAL_LOOP_STATE_KEY)
991
+ await ctx.send_message(ActionComplete())
992
+ return
993
+
994
+ # Continue the loop - condition still true
995
+ if iteration < max_iterations:
996
+ # Update loop state for next iteration
997
+ loop_state.iteration = iteration + 1
998
+ ctx.state.set(EXTERNAL_LOOP_STATE_KEY, loop_state)
999
+
1000
+ # Emit another request for external input
1001
+ request = AgentExternalInputRequest(
1002
+ request_id=str(uuid.uuid4()),
1003
+ agent_name=agent_name,
1004
+ agent_response=accumulated_response,
1005
+ iteration=iteration,
1006
+ messages=all_messages,
1007
+ function_calls=tool_calls,
1008
+ )
1009
+ logger.info(f"InvokeAzureAgent: yielding for external input (iteration {iteration})")
1010
+ await ctx.request_info(request, AgentExternalInputResponse)
1011
+ return
1012
+
1013
+ logger.warning(f"InvokeAzureAgent: external loop exceeded max iterations ({max_iterations})")
1014
+
1015
+ # Loop complete - clean up and send completion
1016
+ with contextlib.suppress(KeyError):
1017
+ ctx.state.delete(EXTERNAL_LOOP_STATE_KEY)
1018
+
1019
+ await ctx.send_message(ActionComplete())
1020
+
1021
+
1022
+ # Mapping of agent action kinds to executor classes
1023
+ AGENT_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
1024
+ "InvokeAzureAgent": InvokeAzureAgentExecutor,
1025
+ }