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,549 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Executor for the ``InvokeMcpTool`` declarative action.
|
|
4
|
+
|
|
5
|
+
Mirrors the .NET ``InvokeMcpToolExecutor``: dispatches an MCP tool call through
|
|
6
|
+
the configured :class:`MCPToolHandler`, parses tool outputs, and routes
|
|
7
|
+
results to the configured ``output.{result, messages, autoSend}`` paths and
|
|
8
|
+
optional conversation history. Supports a human-in-loop approval flow via
|
|
9
|
+
``ctx.request_info()`` / :func:`@response_handler` for ``requireApproval=true``.
|
|
10
|
+
|
|
11
|
+
Security notes:
|
|
12
|
+
|
|
13
|
+
- Approval requests surface header NAMES only; header values are not echoed,
|
|
14
|
+
matching the posture of :mod:`._executors_http`.
|
|
15
|
+
- :class:`MCPToolApprovalRequest` carries the values the resume handler will
|
|
16
|
+
use; header values are re-evaluated on resume to keep secrets out of
|
|
17
|
+
checkpoint state.
|
|
18
|
+
- Tool outputs flow back into agent conversations through ``conversationId``
|
|
19
|
+
and through Tool-role messages emitted to ``output.messages``. They share
|
|
20
|
+
the same prompt-injection risk surface as ``HttpRequestAction``: workflow
|
|
21
|
+
authors must trust the MCP server they invoke.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import uuid
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
from agent_framework import (
|
|
33
|
+
Content,
|
|
34
|
+
Message,
|
|
35
|
+
WorkflowContext,
|
|
36
|
+
handler,
|
|
37
|
+
response_handler,
|
|
38
|
+
)
|
|
39
|
+
from agent_framework.exceptions import ToolExecutionException
|
|
40
|
+
|
|
41
|
+
from ._declarative_base import (
|
|
42
|
+
ActionComplete,
|
|
43
|
+
DeclarativeActionExecutor,
|
|
44
|
+
DeclarativeWorkflowState,
|
|
45
|
+
)
|
|
46
|
+
from ._executors_tools import ToolApprovalResponse
|
|
47
|
+
from ._mcp_handler import MCPToolHandler, MCPToolInvocation, MCPToolResult
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"MCP_ACTION_EXECUTORS",
|
|
51
|
+
"InvokeMcpToolActionExecutor",
|
|
52
|
+
"MCPToolApprovalRequest",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Request / state types
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class MCPToolApprovalRequest:
|
|
65
|
+
"""Approval request emitted before invoking an MCP tool.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
request_id: Identifier matching the framework's pending-request key.
|
|
69
|
+
tool_name: Evaluated tool name.
|
|
70
|
+
server_url: Evaluated MCP server URL.
|
|
71
|
+
server_label: Optional human-readable label.
|
|
72
|
+
arguments: Evaluated tool arguments.
|
|
73
|
+
header_names: Outbound header names (values withheld).
|
|
74
|
+
connection_name: Connection identifier the invocation will use.
|
|
75
|
+
metadata: Internal routing data pinned at approval-request time
|
|
76
|
+
(e.g. ``conversation_id``) for use by the resume handler.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
request_id: str
|
|
80
|
+
tool_name: str
|
|
81
|
+
server_url: str
|
|
82
|
+
server_label: str | None
|
|
83
|
+
arguments: dict[str, Any]
|
|
84
|
+
header_names: list[str] = field(default_factory=lambda: [])
|
|
85
|
+
connection_name: str | None = None
|
|
86
|
+
metadata: dict[str, Any] = field(default_factory=lambda: {})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Helpers
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _evaluate_conversation_id(state: DeclarativeWorkflowState, conversation_id_expr: Any) -> str | None:
|
|
95
|
+
"""Return the evaluated ``conversationId`` string, or None when empty/unset."""
|
|
96
|
+
if not isinstance(conversation_id_expr, str) or not conversation_id_expr:
|
|
97
|
+
return None
|
|
98
|
+
evaluated = state.eval_if_expression(conversation_id_expr)
|
|
99
|
+
if evaluated is None:
|
|
100
|
+
return None
|
|
101
|
+
text = str(evaluated)
|
|
102
|
+
return text or None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _get_output_path(action_def: Mapping[str, Any], key: str) -> str | None:
|
|
106
|
+
"""Extract a state path from ``output.{key}`` field.
|
|
107
|
+
|
|
108
|
+
Supports two YAML shapes:
|
|
109
|
+
|
|
110
|
+
- ``output: { result: Local.MyVar }`` — plain string.
|
|
111
|
+
- ``output: { result: { path: Local.MyVar } }`` — object form.
|
|
112
|
+
"""
|
|
113
|
+
output: Any = action_def.get("output")
|
|
114
|
+
if not isinstance(output, Mapping):
|
|
115
|
+
return None
|
|
116
|
+
value: Any = output.get(key) # type: ignore[reportUnknownMemberType]
|
|
117
|
+
if isinstance(value, str):
|
|
118
|
+
return value or None
|
|
119
|
+
if isinstance(value, Mapping):
|
|
120
|
+
path: Any = value.get("path") # type: ignore[reportUnknownMemberType]
|
|
121
|
+
return path if isinstance(path, str) and path else None
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _format_outputs_for_send(parsed_results: list[Any]) -> str:
|
|
126
|
+
"""Render parsed MCP outputs to a string for ``ctx.yield_output(...)``.
|
|
127
|
+
|
|
128
|
+
- Empty list → ``""``.
|
|
129
|
+
- All-string list → newline-joined.
|
|
130
|
+
- Single element (any type — scalar, dict, list) → JSON-dumped element.
|
|
131
|
+
This avoids surprising ``"[42]"`` / ``"[true]"`` / ``"[null]"`` when
|
|
132
|
+
an MCP tool returns a single scalar JSON value.
|
|
133
|
+
- Multi-element non-string list → JSON-dump the whole list.
|
|
134
|
+
"""
|
|
135
|
+
if not parsed_results:
|
|
136
|
+
return ""
|
|
137
|
+
if all(isinstance(item, str) for item in parsed_results):
|
|
138
|
+
return "\n".join(parsed_results)
|
|
139
|
+
if len(parsed_results) == 1:
|
|
140
|
+
return json.dumps(parsed_results[0], ensure_ascii=False)
|
|
141
|
+
return json.dumps(parsed_results, ensure_ascii=False)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Executor
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
|
150
|
+
"""Executor for the ``InvokeMcpTool`` declarative action.
|
|
151
|
+
|
|
152
|
+
Dispatches through the supplied :class:`MCPToolHandler` and:
|
|
153
|
+
|
|
154
|
+
- Evaluates ``serverUrl`` / ``toolName`` / ``serverLabel`` / ``arguments``
|
|
155
|
+
/ ``headers`` / ``connection.name`` from the action definition.
|
|
156
|
+
- When ``requireApproval=true``: emits a :class:`MCPToolApprovalRequest`
|
|
157
|
+
via ``ctx.request_info()`` and yields. On resume, the response is
|
|
158
|
+
checked; on rejection, ``output.result`` is set to ``"Error: ..."`` and
|
|
159
|
+
no tool call is made.
|
|
160
|
+
- On success: parses each :class:`agent_framework.Content` output (text →
|
|
161
|
+
JSON-first / data / uri → URI string) and assigns the parsed list to
|
|
162
|
+
``output.result``. Builds a single Tool-role :class:`Message`
|
|
163
|
+
containing all output contents and assigns it to ``output.messages``.
|
|
164
|
+
When ``output.autoSend`` is true (default), emits the rendered string
|
|
165
|
+
via ``ctx.yield_output(...)``. When ``conversationId`` is configured,
|
|
166
|
+
appends an Assistant-role :class:`Message` with the same contents to
|
|
167
|
+
``System.conversations.{id}.messages``.
|
|
168
|
+
- On error returned by the handler (``is_error=True``): assigns
|
|
169
|
+
``"Error: <message>"`` to ``output.result`` and completes normally
|
|
170
|
+
(parity with .NET ``AssignErrorAsync``).
|
|
171
|
+
|
|
172
|
+
.. note::
|
|
173
|
+
|
|
174
|
+
``output.messages`` receives a SINGLE Tool-role :class:`Message`
|
|
175
|
+
(containing the full tool output as ``contents``), unlike
|
|
176
|
+
:class:`agent_framework_declarative.InvokeFunctionToolExecutor` which
|
|
177
|
+
writes a list of two messages (assistant call + tool result). This
|
|
178
|
+
matches the .NET ``InvokeMcpToolExecutor`` output contract.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
action_def: dict[str, Any],
|
|
184
|
+
*,
|
|
185
|
+
id: str | None = None,
|
|
186
|
+
mcp_tool_handler: MCPToolHandler,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Create an MCP tool action executor.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
action_def: Parsed ``InvokeMcpTool`` YAML dict.
|
|
192
|
+
id: Optional executor id (defaults to action id or generated).
|
|
193
|
+
mcp_tool_handler: Handler used to dispatch MCP tool calls.
|
|
194
|
+
Required: the builder enforces presence at workflow-build
|
|
195
|
+
time.
|
|
196
|
+
"""
|
|
197
|
+
super().__init__(action_def, id=id)
|
|
198
|
+
self._mcp_tool_handler = mcp_tool_handler
|
|
199
|
+
|
|
200
|
+
# ----- Main handler --------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
@handler
|
|
203
|
+
async def handle_action(
|
|
204
|
+
self,
|
|
205
|
+
trigger: Any,
|
|
206
|
+
ctx: WorkflowContext[ActionComplete, str],
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Execute the MCP tool action."""
|
|
209
|
+
state = await self._ensure_state_initialized(ctx, trigger)
|
|
210
|
+
|
|
211
|
+
server_url = self._get_server_url(state)
|
|
212
|
+
tool_name = self._get_tool_name(state)
|
|
213
|
+
server_label = self._get_server_label(state)
|
|
214
|
+
arguments = self._get_arguments(state)
|
|
215
|
+
headers = self._get_headers(state)
|
|
216
|
+
connection_name = self._get_connection_name(state)
|
|
217
|
+
require_approval = self._get_require_approval(state)
|
|
218
|
+
auto_send = self._get_auto_send(state)
|
|
219
|
+
conversation_id_expr = self._action_def.get("conversationId")
|
|
220
|
+
output_messages_path = _get_output_path(self._action_def, "messages")
|
|
221
|
+
output_result_path = _get_output_path(self._action_def, "result")
|
|
222
|
+
|
|
223
|
+
if require_approval:
|
|
224
|
+
request_id = str(uuid.uuid4())
|
|
225
|
+
conversation_id = _evaluate_conversation_id(state, conversation_id_expr)
|
|
226
|
+
request = MCPToolApprovalRequest(
|
|
227
|
+
request_id=request_id,
|
|
228
|
+
tool_name=tool_name,
|
|
229
|
+
server_url=server_url,
|
|
230
|
+
server_label=server_label,
|
|
231
|
+
arguments=arguments,
|
|
232
|
+
header_names=sorted(headers.keys()),
|
|
233
|
+
connection_name=connection_name,
|
|
234
|
+
metadata={"conversation_id": conversation_id},
|
|
235
|
+
)
|
|
236
|
+
logger.info(
|
|
237
|
+
"%s: requesting approval for MCP tool '%s' on '%s'",
|
|
238
|
+
self.__class__.__name__,
|
|
239
|
+
tool_name,
|
|
240
|
+
server_url,
|
|
241
|
+
)
|
|
242
|
+
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
# No approval required - invoke directly.
|
|
246
|
+
invocation = MCPToolInvocation(
|
|
247
|
+
server_url=server_url,
|
|
248
|
+
tool_name=tool_name,
|
|
249
|
+
server_label=server_label,
|
|
250
|
+
arguments=arguments,
|
|
251
|
+
headers=headers,
|
|
252
|
+
connection_name=connection_name,
|
|
253
|
+
)
|
|
254
|
+
result = await self._invoke_with_narrow_catch(invocation)
|
|
255
|
+
await self._process_result(
|
|
256
|
+
ctx=ctx,
|
|
257
|
+
state=state,
|
|
258
|
+
result=result,
|
|
259
|
+
auto_send=auto_send,
|
|
260
|
+
conversation_id=_evaluate_conversation_id(state, conversation_id_expr),
|
|
261
|
+
output_messages_path=output_messages_path,
|
|
262
|
+
output_result_path=output_result_path,
|
|
263
|
+
)
|
|
264
|
+
await ctx.send_message(ActionComplete())
|
|
265
|
+
|
|
266
|
+
# ----- Approval response handler ------------------------------------------
|
|
267
|
+
|
|
268
|
+
@response_handler
|
|
269
|
+
async def handle_approval_response(
|
|
270
|
+
self,
|
|
271
|
+
original_request: MCPToolApprovalRequest,
|
|
272
|
+
response: ToolApprovalResponse,
|
|
273
|
+
ctx: WorkflowContext[ActionComplete, str],
|
|
274
|
+
) -> None:
|
|
275
|
+
"""Resume the invocation using the values pinned on ``original_request``."""
|
|
276
|
+
state = self._get_state(ctx.state)
|
|
277
|
+
|
|
278
|
+
tool_name = original_request.tool_name
|
|
279
|
+
metadata: dict[str, Any] = getattr(original_request, "metadata", None) or {}
|
|
280
|
+
raw_conversation_id = metadata.get("conversation_id")
|
|
281
|
+
conversation_id = raw_conversation_id if isinstance(raw_conversation_id, str) and raw_conversation_id else None
|
|
282
|
+
|
|
283
|
+
auto_send = self._get_auto_send(state)
|
|
284
|
+
output_messages_path = _get_output_path(self._action_def, "messages")
|
|
285
|
+
output_result_path = _get_output_path(self._action_def, "result")
|
|
286
|
+
|
|
287
|
+
if not response.approved:
|
|
288
|
+
logger.info(
|
|
289
|
+
"%s: MCP tool '%s' rejected: %s",
|
|
290
|
+
self.__class__.__name__,
|
|
291
|
+
tool_name,
|
|
292
|
+
response.reason,
|
|
293
|
+
)
|
|
294
|
+
self._assign_error(state, output_result_path, "MCP tool invocation was not approved by user.")
|
|
295
|
+
await ctx.send_message(ActionComplete())
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
invocation = MCPToolInvocation(
|
|
299
|
+
server_url=original_request.server_url,
|
|
300
|
+
tool_name=tool_name,
|
|
301
|
+
server_label=original_request.server_label,
|
|
302
|
+
arguments=original_request.arguments,
|
|
303
|
+
headers=self._evaluate_headers(state, self._action_def.get("headers")),
|
|
304
|
+
connection_name=getattr(original_request, "connection_name", None),
|
|
305
|
+
)
|
|
306
|
+
result = await self._invoke_with_narrow_catch(invocation)
|
|
307
|
+
await self._process_result(
|
|
308
|
+
ctx=ctx,
|
|
309
|
+
state=state,
|
|
310
|
+
result=result,
|
|
311
|
+
auto_send=auto_send,
|
|
312
|
+
conversation_id=conversation_id,
|
|
313
|
+
output_messages_path=output_messages_path,
|
|
314
|
+
output_result_path=output_result_path,
|
|
315
|
+
)
|
|
316
|
+
await ctx.send_message(ActionComplete())
|
|
317
|
+
|
|
318
|
+
# ----- Field resolution ----------------------------------------------------
|
|
319
|
+
|
|
320
|
+
def _get_server_url(self, state: DeclarativeWorkflowState) -> str:
|
|
321
|
+
raw = self._action_def.get("serverUrl")
|
|
322
|
+
if raw is None:
|
|
323
|
+
raise ValueError("InvokeMcpTool requires a 'serverUrl' field.")
|
|
324
|
+
evaluated = state.eval_if_expression(raw)
|
|
325
|
+
if not isinstance(evaluated, str) or not evaluated:
|
|
326
|
+
raise ValueError("InvokeMcpTool 'serverUrl' evaluated to an empty value.")
|
|
327
|
+
return evaluated
|
|
328
|
+
|
|
329
|
+
def _get_tool_name(self, state: DeclarativeWorkflowState) -> str:
|
|
330
|
+
raw = self._action_def.get("toolName")
|
|
331
|
+
if raw is None:
|
|
332
|
+
raise ValueError("InvokeMcpTool requires a 'toolName' field.")
|
|
333
|
+
evaluated = state.eval_if_expression(raw)
|
|
334
|
+
if not isinstance(evaluated, str) or not evaluated:
|
|
335
|
+
raise ValueError("InvokeMcpTool 'toolName' evaluated to an empty value.")
|
|
336
|
+
return evaluated
|
|
337
|
+
|
|
338
|
+
def _get_server_label(self, state: DeclarativeWorkflowState) -> str | None:
|
|
339
|
+
raw = self._action_def.get("serverLabel")
|
|
340
|
+
if raw is None:
|
|
341
|
+
return None
|
|
342
|
+
evaluated = state.eval_if_expression(raw)
|
|
343
|
+
if evaluated is None:
|
|
344
|
+
return None
|
|
345
|
+
text = str(evaluated)
|
|
346
|
+
return text or None
|
|
347
|
+
|
|
348
|
+
def _get_arguments(self, state: DeclarativeWorkflowState) -> dict[str, Any]:
|
|
349
|
+
"""Evaluate ``arguments`` map. Preserves ``None`` values (parity with .NET)."""
|
|
350
|
+
raw = self._action_def.get("arguments")
|
|
351
|
+
if raw is None:
|
|
352
|
+
return {}
|
|
353
|
+
if not isinstance(raw, Mapping) or not raw:
|
|
354
|
+
return {}
|
|
355
|
+
result: dict[str, Any] = {}
|
|
356
|
+
for key, value in raw.items(): # type: ignore[reportUnknownVariableType]
|
|
357
|
+
if not isinstance(key, str) or not key:
|
|
358
|
+
continue
|
|
359
|
+
result[key] = state.eval_if_expression(value)
|
|
360
|
+
return result
|
|
361
|
+
|
|
362
|
+
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str]:
|
|
363
|
+
return self._evaluate_headers(state, self._action_def.get("headers"))
|
|
364
|
+
|
|
365
|
+
@staticmethod
|
|
366
|
+
def _evaluate_headers(state: DeclarativeWorkflowState, headers_def: Any) -> dict[str, str]:
|
|
367
|
+
"""Evaluate the ``headers`` map. Empty string values are skipped."""
|
|
368
|
+
if not isinstance(headers_def, Mapping) or not headers_def:
|
|
369
|
+
return {}
|
|
370
|
+
result: dict[str, str] = {}
|
|
371
|
+
for key, value in headers_def.items(): # type: ignore[reportUnknownVariableType]
|
|
372
|
+
if not isinstance(key, str) or not key:
|
|
373
|
+
continue
|
|
374
|
+
evaluated = state.eval_if_expression(value)
|
|
375
|
+
if evaluated is None:
|
|
376
|
+
continue
|
|
377
|
+
text = str(evaluated)
|
|
378
|
+
if not text:
|
|
379
|
+
continue
|
|
380
|
+
result[key] = text
|
|
381
|
+
return result
|
|
382
|
+
|
|
383
|
+
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
|
|
384
|
+
connection = self._action_def.get("connection")
|
|
385
|
+
if not isinstance(connection, Mapping):
|
|
386
|
+
return None
|
|
387
|
+
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
|
|
388
|
+
if name_expr is None:
|
|
389
|
+
return None
|
|
390
|
+
evaluated = state.eval_if_expression(name_expr)
|
|
391
|
+
if evaluated is None:
|
|
392
|
+
return None
|
|
393
|
+
text = str(evaluated)
|
|
394
|
+
return text or None
|
|
395
|
+
|
|
396
|
+
def _get_require_approval(self, state: DeclarativeWorkflowState) -> bool:
|
|
397
|
+
raw = self._action_def.get("requireApproval")
|
|
398
|
+
if raw is None:
|
|
399
|
+
return False
|
|
400
|
+
evaluated = state.eval_if_expression(raw)
|
|
401
|
+
if isinstance(evaluated, bool):
|
|
402
|
+
return evaluated
|
|
403
|
+
if isinstance(evaluated, str):
|
|
404
|
+
return evaluated.strip().lower() in {"true", "1", "yes"}
|
|
405
|
+
return bool(evaluated)
|
|
406
|
+
|
|
407
|
+
def _get_auto_send(self, state: DeclarativeWorkflowState) -> bool:
|
|
408
|
+
output: Any = self._action_def.get("output")
|
|
409
|
+
if not isinstance(output, Mapping):
|
|
410
|
+
return True
|
|
411
|
+
raw: Any = output.get("autoSend") # type: ignore[reportUnknownMemberType]
|
|
412
|
+
if raw is None:
|
|
413
|
+
return True
|
|
414
|
+
evaluated = state.eval_if_expression(raw)
|
|
415
|
+
if isinstance(evaluated, bool):
|
|
416
|
+
return evaluated
|
|
417
|
+
if isinstance(evaluated, str):
|
|
418
|
+
return evaluated.strip().lower() in {"true", "1", "yes"}
|
|
419
|
+
return bool(evaluated)
|
|
420
|
+
|
|
421
|
+
# ----- Invocation + error handling ----------------------------------------
|
|
422
|
+
|
|
423
|
+
async def _invoke_with_narrow_catch(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
|
424
|
+
"""Invoke the handler with a narrow exception catch.
|
|
425
|
+
|
|
426
|
+
Only known transport / tool exceptions are normalised to an error
|
|
427
|
+
result. Programmer bugs (TypeError, ValueError from misuse, etc.)
|
|
428
|
+
propagate so they fail loudly.
|
|
429
|
+
|
|
430
|
+
``asyncio.CancelledError`` is a ``BaseException``, not ``Exception``,
|
|
431
|
+
so it is not caught here and propagates unchanged for workflow
|
|
432
|
+
cancellation.
|
|
433
|
+
"""
|
|
434
|
+
try:
|
|
435
|
+
return await self._mcp_tool_handler.invoke_tool(invocation)
|
|
436
|
+
except ToolExecutionException as exc:
|
|
437
|
+
message = str(exc) or type(exc).__name__
|
|
438
|
+
return MCPToolResult(
|
|
439
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
440
|
+
is_error=True,
|
|
441
|
+
error_message=message,
|
|
442
|
+
)
|
|
443
|
+
except httpx.HTTPError as exc:
|
|
444
|
+
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
|
445
|
+
return MCPToolResult(
|
|
446
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
447
|
+
is_error=True,
|
|
448
|
+
error_message=message,
|
|
449
|
+
)
|
|
450
|
+
except Exception as exc:
|
|
451
|
+
try:
|
|
452
|
+
from mcp.shared.exceptions import McpError
|
|
453
|
+
except ImportError: # pragma: no cover - mcp is a hard dep
|
|
454
|
+
raise
|
|
455
|
+
if isinstance(exc, McpError):
|
|
456
|
+
message = str(exc) or type(exc).__name__
|
|
457
|
+
return MCPToolResult(
|
|
458
|
+
outputs=[Content.from_text(f"Error: {message}")],
|
|
459
|
+
is_error=True,
|
|
460
|
+
error_message=message,
|
|
461
|
+
)
|
|
462
|
+
raise
|
|
463
|
+
|
|
464
|
+
# ----- Result handling -----------------------------------------------------
|
|
465
|
+
|
|
466
|
+
async def _process_result(
|
|
467
|
+
self,
|
|
468
|
+
*,
|
|
469
|
+
ctx: WorkflowContext[ActionComplete, str],
|
|
470
|
+
state: DeclarativeWorkflowState,
|
|
471
|
+
result: MCPToolResult,
|
|
472
|
+
auto_send: bool,
|
|
473
|
+
conversation_id: str | None,
|
|
474
|
+
output_messages_path: str | None,
|
|
475
|
+
output_result_path: str | None,
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Apply ``result`` to workflow state per the configured output paths."""
|
|
478
|
+
if result.is_error:
|
|
479
|
+
# Error path mirrors .NET ``AssignErrorAsync`` — only the result
|
|
480
|
+
# path is touched; messages / autoSend / conversation are not.
|
|
481
|
+
self._assign_error(
|
|
482
|
+
state,
|
|
483
|
+
output_result_path,
|
|
484
|
+
result.error_message or "MCP tool invocation failed.",
|
|
485
|
+
)
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
parsed_results = _parse_outputs(result.outputs)
|
|
489
|
+
if output_result_path is not None and parsed_results:
|
|
490
|
+
state.set(output_result_path, parsed_results)
|
|
491
|
+
|
|
492
|
+
# Single Tool-role message (matches .NET line 178 contract). Differs
|
|
493
|
+
# from InvokeFunctionTool's two-message [assistant call, tool result]
|
|
494
|
+
# convention.
|
|
495
|
+
tool_message = Message(role="tool", contents=list(result.outputs))
|
|
496
|
+
if output_messages_path is not None:
|
|
497
|
+
state.set(output_messages_path, tool_message)
|
|
498
|
+
|
|
499
|
+
if auto_send and parsed_results:
|
|
500
|
+
await ctx.yield_output(_format_outputs_for_send(parsed_results))
|
|
501
|
+
|
|
502
|
+
if conversation_id:
|
|
503
|
+
messages_path = f"System.conversations.{conversation_id}.messages"
|
|
504
|
+
assistant_message = Message(role="assistant", contents=list(result.outputs))
|
|
505
|
+
state.append(messages_path, assistant_message)
|
|
506
|
+
|
|
507
|
+
@staticmethod
|
|
508
|
+
def _assign_error(
|
|
509
|
+
state: DeclarativeWorkflowState,
|
|
510
|
+
output_result_path: str | None,
|
|
511
|
+
error_message: str,
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Mirror .NET ``AssignErrorAsync``: store ``"Error: <msg>"`` at the result path."""
|
|
514
|
+
if output_result_path is None:
|
|
515
|
+
return
|
|
516
|
+
state.set(output_result_path, f"Error: {error_message}")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _parse_outputs(outputs: list[Content]) -> list[Any]:
|
|
520
|
+
"""Parse :class:`Content` outputs into Python values for ``output.result``.
|
|
521
|
+
|
|
522
|
+
Mirrors .NET ``AssignResultAsync``:
|
|
523
|
+
|
|
524
|
+
- ``TextContent`` → JSON-parse text; on failure use the raw text.
|
|
525
|
+
- ``DataContent`` / ``UriContent`` → ``content.uri``.
|
|
526
|
+
- Other content kinds → ``str(content)``.
|
|
527
|
+
"""
|
|
528
|
+
parsed: list[Any] = []
|
|
529
|
+
for content in outputs:
|
|
530
|
+
kind = getattr(content, "type", None)
|
|
531
|
+
if kind == "text":
|
|
532
|
+
text_value = getattr(content, "text", None)
|
|
533
|
+
text_str = "" if text_value is None else str(text_value)
|
|
534
|
+
try:
|
|
535
|
+
parsed.append(json.loads(text_str))
|
|
536
|
+
except (json.JSONDecodeError, ValueError):
|
|
537
|
+
parsed.append(text_str)
|
|
538
|
+
continue
|
|
539
|
+
if kind in ("data", "uri"):
|
|
540
|
+
uri_value = getattr(content, "uri", None)
|
|
541
|
+
parsed.append("" if uri_value is None else str(uri_value))
|
|
542
|
+
continue
|
|
543
|
+
parsed.append(str(content))
|
|
544
|
+
return parsed
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
MCP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
|
548
|
+
"InvokeMcpTool": InvokeMcpToolActionExecutor,
|
|
549
|
+
}
|