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.
- agent_framework_declarative/__init__.py +71 -0
- agent_framework_declarative/_loader.py +868 -0
- agent_framework_declarative/_models.py +1154 -0
- agent_framework_declarative/_workflows/__init__.py +167 -0
- agent_framework_declarative/_workflows/_declarative_base.py +1226 -0
- agent_framework_declarative/_workflows/_declarative_builder.py +1057 -0
- agent_framework_declarative/_workflows/_errors.py +38 -0
- agent_framework_declarative/_workflows/_executors_agents.py +1025 -0
- agent_framework_declarative/_workflows/_executors_basic.py +574 -0
- agent_framework_declarative/_workflows/_executors_control_flow.py +461 -0
- agent_framework_declarative/_workflows/_executors_external_input.py +243 -0
- agent_framework_declarative/_workflows/_executors_http.py +417 -0
- agent_framework_declarative/_workflows/_executors_mcp.py +549 -0
- agent_framework_declarative/_workflows/_executors_tools.py +660 -0
- agent_framework_declarative/_workflows/_factory.py +808 -0
- agent_framework_declarative/_workflows/_http_handler.py +237 -0
- agent_framework_declarative/_workflows/_mcp_handler.py +581 -0
- agent_framework_declarative/_workflows/_powerfx_functions.py +498 -0
- agent_framework_declarative/_workflows/_state.py +650 -0
- agent_framework_declarative-1.0.0.dist-info/METADATA +49 -0
- agent_framework_declarative-1.0.0.dist-info/RECORD +23 -0
- agent_framework_declarative-1.0.0.dist-info/WHEEL +4 -0
- agent_framework_declarative-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|