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,417 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """Executor for the ``HttpRequestAction`` declarative action.
4
+
5
+ Mirrors the .NET ``HttpRequestExecutor``: dispatches an HTTP request through the
6
+ configured :class:`HttpRequestHandler`, parses the response body, and assigns
7
+ the parsed body and response headers to the declared state paths.
8
+
9
+ Security note: response bodies can echo secrets and may be very large. Diagnostic
10
+ messages produced for non-2xx responses truncate the body to 256 characters and
11
+ collapse CR/LF/TAB to spaces (parity with .NET ``FormatBodyForDiagnostics``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from collections.abc import Mapping
19
+ from typing import Any
20
+
21
+ import httpx
22
+ from agent_framework import (
23
+ Message,
24
+ WorkflowContext,
25
+ handler,
26
+ )
27
+
28
+ from ._declarative_base import (
29
+ ActionComplete,
30
+ DeclarativeActionExecutor,
31
+ DeclarativeWorkflowState,
32
+ )
33
+ from ._errors import DeclarativeActionError
34
+ from ._http_handler import HttpRequestHandler, HttpRequestInfo, HttpRequestResult
35
+
36
+ __all__ = [
37
+ "HTTP_ACTION_EXECUTORS",
38
+ "HttpRequestActionExecutor",
39
+ ]
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ _MAX_BODY_DIAGNOSTIC_LENGTH = 256
44
+ _BODY_TRUNCATION_SUFFIX = " \u2026 [truncated]"
45
+
46
+
47
+ # Body discriminator aliases. Long forms match the .NET object-model type
48
+ # names so YAML produced by .NET round-trips. Short forms are the .NET YAML
49
+ # convention used in test fixtures.
50
+ _BODY_KIND_JSON = {"json", "JsonRequestContent"}
51
+ _BODY_KIND_RAW = {"raw", "RawRequestContent"}
52
+ _BODY_KIND_NONE = {"none", "NoRequestContent"}
53
+
54
+
55
+ def _get_path(action_def: Mapping[str, Any], key: str) -> str | None:
56
+ """Extract a state path from ``response``/``responseHeaders`` field.
57
+
58
+ Supports two YAML shapes (matches .NET serialization round-trips):
59
+
60
+ - ``response: Local.MyVar`` (plain string).
61
+ - ``response: { path: Local.MyVar }`` (object form).
62
+ """
63
+ value = action_def.get(key)
64
+ if isinstance(value, str):
65
+ return value or None
66
+ if isinstance(value, Mapping):
67
+ path = value.get("path") # type: ignore[reportUnknownMemberType, reportUnknownVariableType]
68
+ return path if isinstance(path, str) and path else None
69
+ return None
70
+
71
+
72
+ def _format_body_for_diagnostics(body: str | None) -> str:
73
+ """Truncate and sanitise a response body for inclusion in error messages.
74
+
75
+ Mirrors the .NET ``FormatBodyForDiagnostics`` helper:
76
+
77
+ - Empty/None -> empty string.
78
+ - Replaces CR/LF/TAB with spaces.
79
+ - Truncates to 256 chars with a unicode-ellipsis ``[truncated]`` suffix.
80
+ """
81
+ if not body:
82
+ return ""
83
+
84
+ truncated = len(body) > _MAX_BODY_DIAGNOSTIC_LENGTH
85
+ head = body[:_MAX_BODY_DIAGNOSTIC_LENGTH] if truncated else body
86
+ sanitized = head.replace("\r", " ").replace("\n", " ").replace("\t", " ")
87
+ return sanitized + _BODY_TRUNCATION_SUFFIX if truncated else sanitized
88
+
89
+
90
+ def _parse_response_body(body: str | None) -> Any:
91
+ """Parse an HTTP response body the same way the .NET executor does.
92
+
93
+ JSON-first: if the body parses as JSON, the parsed value is returned. Other
94
+ bodies are returned as the raw string. Empty/None bodies return ``None``.
95
+ """
96
+ if body is None or body == "":
97
+ return None
98
+ try:
99
+ return json.loads(body)
100
+ except json.JSONDecodeError:
101
+ return body
102
+
103
+
104
+ def _format_query_value(value: Any) -> str | None:
105
+ """Format a query-parameter value for URL inclusion.
106
+
107
+ Mirrors .NET ``FormatQueryValue``: ``None`` is dropped, ``bool`` becomes
108
+ lower-case ``"true"``/``"false"``, numerics use invariant ``str()``, and
109
+ other values fall through to ``str()``.
110
+ """
111
+ if value is None:
112
+ return None
113
+ if isinstance(value, bool):
114
+ return "true" if value else "false"
115
+ if isinstance(value, str):
116
+ return value
117
+ return str(value)
118
+
119
+
120
+ def _get_messages_path(state: DeclarativeWorkflowState, conversation_id_expr: str | None) -> str | None:
121
+ """Return the configured conversation messages path, if any.
122
+
123
+ Returns ``System.conversations.{evaluated_id}.messages`` when a
124
+ ``conversation_id_expr`` is configured and evaluates to a non-empty value.
125
+ Returns ``None`` when no conversation id expression is configured or when
126
+ the expression evaluates to ``None`` or an empty string (matches .NET
127
+ ``GetConversationId`` behaviour where empty becomes ``null`` and the
128
+ response is not appended).
129
+ """
130
+ if not conversation_id_expr:
131
+ return None
132
+ evaluated = state.eval_if_expression(conversation_id_expr)
133
+ if evaluated is None or (isinstance(evaluated, str) and not evaluated):
134
+ return None
135
+ return f"System.conversations.{evaluated}.messages"
136
+
137
+
138
+ class HttpRequestActionExecutor(DeclarativeActionExecutor):
139
+ """Executor for the ``HttpRequestAction`` declarative action.
140
+
141
+ Dispatches through the supplied :class:`HttpRequestHandler` and:
142
+
143
+ - Parses the response body (JSON-first, raw string fall-back).
144
+ - Assigns the parsed body to ``response`` path (if configured).
145
+ - Folds multi-value response headers (comma-joined) and assigns them to
146
+ ``responseHeaders`` path (if configured).
147
+ - On 2xx with non-empty body and a configured ``conversationId``, appends
148
+ an Assistant :class:`agent_framework.Message` to
149
+ ``System.conversations.{id}.messages``.
150
+ - On non-2xx, still publishes ``responseHeaders`` (diagnostic) and raises
151
+ :class:`DeclarativeActionError` with a status-coded message containing a
152
+ truncated/sanitised body preview.
153
+
154
+ Transport errors (``httpx.TimeoutException``, ``TimeoutError``,
155
+ ``httpx.HTTPError``) become :class:`DeclarativeActionError`. ``CancelledError``
156
+ is intentionally NOT caught so that workflow cancellation propagates.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ action_def: dict[str, Any],
162
+ *,
163
+ id: str | None = None,
164
+ http_request_handler: HttpRequestHandler,
165
+ ) -> None:
166
+ """Create an HTTP request action executor.
167
+
168
+ Args:
169
+ action_def: Parsed ``HttpRequestAction`` YAML dict.
170
+ id: Optional executor id (defaults to action id or generated).
171
+ http_request_handler: Handler used to dispatch HTTP requests.
172
+ Required: the builder enforces presence at workflow-build time.
173
+ """
174
+ super().__init__(action_def, id=id)
175
+ self._http_request_handler = http_request_handler
176
+
177
+ @handler
178
+ async def handle_action(
179
+ self,
180
+ trigger: Any,
181
+ ctx: WorkflowContext[ActionComplete],
182
+ ) -> None:
183
+ """Execute the HTTP request action."""
184
+ state = await self._ensure_state_initialized(ctx, trigger)
185
+
186
+ method = self._get_method(state)
187
+ url = self._get_url(state)
188
+ headers = self._get_headers(state)
189
+ query_parameters = self._get_query_parameters(state)
190
+ body, body_content_type = self._get_body(state)
191
+ timeout_ms = self._get_timeout_ms(state)
192
+ conversation_id_expr = self._action_def.get("conversationId")
193
+ connection_name = self._get_connection_name(state)
194
+
195
+ info = HttpRequestInfo(
196
+ method=method,
197
+ url=url,
198
+ headers=headers or {},
199
+ query_parameters=query_parameters or {},
200
+ body=body,
201
+ body_content_type=body_content_type,
202
+ timeout_ms=timeout_ms,
203
+ connection_name=connection_name,
204
+ )
205
+
206
+ try:
207
+ result = await self._http_request_handler.send(info)
208
+ except (httpx.TimeoutException, TimeoutError) as exc:
209
+ raise DeclarativeActionError(f"HTTP request to '{url}' timed out.") from exc
210
+ except DeclarativeActionError:
211
+ raise
212
+ except httpx.HTTPError as exc:
213
+ raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
214
+ except Exception as exc:
215
+ # Custom HttpRequestHandler implementations may raise arbitrary
216
+ # exception types. Wrap them in DeclarativeActionError so workflow
217
+ # error handling stays uniform regardless of transport. Note that
218
+ # ``asyncio.CancelledError`` is a ``BaseException`` (not
219
+ # ``Exception``) and so still propagates unmodified, preserving
220
+ # workflow-cancellation semantics.
221
+ raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
222
+
223
+ if result.is_success_status_code:
224
+ self._assign_response(state, result)
225
+ self._assign_response_headers(state, result)
226
+ self._append_response_to_conversation(state, conversation_id_expr, result.body)
227
+ await ctx.send_message(ActionComplete())
228
+ return
229
+
230
+ # Non-success path: still publish headers diagnostically, then raise.
231
+ self._assign_response_headers(state, result)
232
+ body_preview = _format_body_for_diagnostics(result.body)
233
+ if body_preview:
234
+ message = f"HTTP request to '{url}' failed with status code {result.status_code}. Body: '{body_preview}'"
235
+ else:
236
+ message = f"HTTP request to '{url}' failed with status code {result.status_code}."
237
+ raise DeclarativeActionError(message)
238
+
239
+ # ----- Field resolution ----------------------------------------------------
240
+
241
+ def _get_method(self, state: DeclarativeWorkflowState) -> str:
242
+ method = self._action_def.get("method")
243
+ evaluated = state.eval_if_expression(method) if method is not None else None
244
+ if not evaluated:
245
+ return "GET"
246
+ return str(evaluated).upper()
247
+
248
+ def _get_url(self, state: DeclarativeWorkflowState) -> str:
249
+ raw = self._action_def.get("url")
250
+ if raw is None:
251
+ raise ValueError("HttpRequestAction requires a 'url' field.")
252
+ evaluated = state.eval_if_expression(raw)
253
+ if not isinstance(evaluated, str) or not evaluated:
254
+ raise ValueError("HttpRequestAction 'url' evaluated to an empty value.")
255
+ return evaluated
256
+
257
+ def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
258
+ raw_headers = self._action_def.get("headers")
259
+ if not isinstance(raw_headers, Mapping) or not raw_headers:
260
+ return None
261
+ result: dict[str, str] = {}
262
+ for key, value in raw_headers.items(): # type: ignore[reportUnknownVariableType]
263
+ if not isinstance(key, str) or not key:
264
+ continue
265
+ evaluated = state.eval_if_expression(value)
266
+ if evaluated is None:
267
+ continue
268
+ text = str(evaluated)
269
+ if not text:
270
+ continue
271
+ result[key] = text
272
+ return result or None
273
+
274
+ def _get_query_parameters(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
275
+ raw_params = self._action_def.get("queryParameters")
276
+ if not isinstance(raw_params, Mapping) or not raw_params:
277
+ return None
278
+ result: dict[str, str] = {}
279
+ for key, value in raw_params.items(): # type: ignore[reportUnknownVariableType]
280
+ if not isinstance(key, str) or not key or value is None:
281
+ continue
282
+ evaluated = state.eval_if_expression(value)
283
+ formatted = _format_query_value(evaluated)
284
+ if formatted is not None:
285
+ result[key] = formatted
286
+ return result or None
287
+
288
+ def _get_body(self, state: DeclarativeWorkflowState) -> tuple[str | None, str | None]:
289
+ raw_body = self._action_def.get("body")
290
+ if raw_body is None:
291
+ return None, None
292
+ if not isinstance(raw_body, Mapping):
293
+ raise ValueError(
294
+ "HttpRequestAction 'body' must be a mapping with a 'kind' field (json, raw) or omitted entirely."
295
+ )
296
+
297
+ kind_value: Any = raw_body.get("kind") or raw_body.get("$kind") # type: ignore[reportUnknownMemberType]
298
+ if kind_value is None:
299
+ raise ValueError(
300
+ "HttpRequestAction 'body' is missing 'kind'. Use 'json', 'raw', or omit 'body' for no request body."
301
+ )
302
+ if not isinstance(kind_value, str):
303
+ raise ValueError(f"HttpRequestAction 'body.kind' must be a string, got {kind_value!r}.")
304
+
305
+ if kind_value in _BODY_KIND_NONE:
306
+ return None, None
307
+
308
+ if kind_value in _BODY_KIND_JSON:
309
+ content_expr: Any = raw_body.get("content") # type: ignore[reportUnknownMemberType]
310
+ if content_expr is None:
311
+ return None, None
312
+ evaluated = state.eval_if_expression(content_expr)
313
+ try:
314
+ body_text = json.dumps(evaluated, default=str)
315
+ except (TypeError, ValueError) as exc:
316
+ raise ValueError(f"HttpRequestAction 'body.content' could not be serialised as JSON: {exc}") from exc
317
+ return body_text, "application/json"
318
+
319
+ if kind_value in _BODY_KIND_RAW:
320
+ content_expr = raw_body.get("content") # type: ignore[reportUnknownMemberType]
321
+ content_type_expr: Any = raw_body.get("contentType") # type: ignore[reportUnknownMemberType]
322
+ content: str | None = None
323
+ if content_expr is not None:
324
+ evaluated = state.eval_if_expression(content_expr)
325
+ content = None if evaluated is None else str(evaluated)
326
+ content_type: str | None = None
327
+ if content_type_expr is not None:
328
+ ct_eval = state.eval_if_expression(content_type_expr)
329
+ ct_text = None if ct_eval is None else str(ct_eval)
330
+ content_type = ct_text or None
331
+ # Match .NET RawRequestContent semantics: when a raw body is sent
332
+ # without an explicit content type, default to text/plain so the
333
+ # request is interpretable by servers.
334
+ if content is not None and not content_type:
335
+ content_type = "text/plain"
336
+ return content, content_type
337
+
338
+ raise ValueError(
339
+ f"HttpRequestAction 'body.kind' has unsupported value '{kind_value}'. "
340
+ "Expected one of: json, raw, JsonRequestContent, RawRequestContent, "
341
+ "NoRequestContent."
342
+ )
343
+
344
+ def _get_timeout_ms(self, state: DeclarativeWorkflowState) -> int | None:
345
+ raw = self._action_def.get("requestTimeoutInMilliseconds")
346
+ if raw is None:
347
+ return None
348
+ evaluated = state.eval_if_expression(raw)
349
+ if evaluated is None:
350
+ return None
351
+ try:
352
+ value = int(evaluated)
353
+ except (TypeError, ValueError):
354
+ logger.debug(
355
+ "HttpRequestAction: ignoring non-numeric requestTimeoutInMilliseconds=%r",
356
+ evaluated,
357
+ )
358
+ return None
359
+ return value if value > 0 else None
360
+
361
+ def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
362
+ connection = self._action_def.get("connection")
363
+ if not isinstance(connection, Mapping):
364
+ return None
365
+ name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
366
+ if name_expr is None:
367
+ return None
368
+ evaluated = state.eval_if_expression(name_expr)
369
+ if evaluated is None:
370
+ return None
371
+ text = str(evaluated)
372
+ return text or None
373
+
374
+ # ----- Result handling -----------------------------------------------------
375
+
376
+ def _assign_response(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
377
+ path = _get_path(self._action_def, "response")
378
+ if path is None:
379
+ return
380
+ state.set(path, _parse_response_body(result.body))
381
+
382
+ def _assign_response_headers(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
383
+ path = _get_path(self._action_def, "responseHeaders")
384
+ if path is None:
385
+ return
386
+ if not result.headers:
387
+ state.set(path, None)
388
+ return
389
+ # Fold multi-value headers with commas (standard HTTP folding) only at
390
+ # assignment time. The raw multi-value dict on HttpRequestResult.headers
391
+ # is left untouched so callers/tests can inspect duplicates.
392
+ flattened: dict[str, str] = {}
393
+ for key, values in result.headers.items():
394
+ flattened[key] = ",".join(values)
395
+ state.set(path, flattened)
396
+
397
+ def _append_response_to_conversation(
398
+ self,
399
+ state: DeclarativeWorkflowState,
400
+ conversation_id_expr: str | None,
401
+ body: str,
402
+ ) -> None:
403
+ if not body:
404
+ return
405
+ messages_path = _get_messages_path(state, conversation_id_expr)
406
+ if messages_path is None:
407
+ return
408
+ # Mirrors InvokeAzureAgentExecutor: rely on state.append to lazily
409
+ # create the conversation entry. Avoids re-parsing the id back out
410
+ # of the dotted path string.
411
+ message = Message(role="assistant", contents=[body])
412
+ state.append(messages_path, message)
413
+
414
+
415
+ HTTP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
416
+ "HttpRequestAction": HttpRequestActionExecutor,
417
+ }