|
66 | 66 | from agentex.lib.core.tracing.tracer import AsyncTracer |
67 | 67 | from agentex.types.task_message_delta import TextDelta, ToolRequestDelta, ReasoningContentDelta, ReasoningSummaryDelta |
68 | 68 | from agentex.types.task_message_update import StreamTaskMessageFull, StreamTaskMessageDelta |
69 | | -from agentex.types.task_message_content import TextContent, ReasoningContent, ToolRequestContent |
| 69 | +from agentex.types.task_message_content import TextContent, ReasoningContent, ToolRequestContent, ToolResponseContent |
70 | 70 | from agentex.lib.adk.utils._modules.client import create_async_agentex_client |
71 | 71 | from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( |
72 | 72 | streaming_task_id, |
@@ -123,6 +123,90 @@ def _serialize_item(item: Any) -> dict[str, Any]: |
123 | 123 | return item_dict |
124 | 124 |
|
125 | 125 |
|
| 126 | +# Responses-API output items for server-side / hosted tools. These execute inside |
| 127 | +# the Responses API, so they never become function_call items AND the SDK's |
| 128 | +# RunHooks (on_tool_start/on_tool_end) never fire for them. The streaming loop |
| 129 | +# must surface them explicitly, as a tool request + response pair, when the item |
| 130 | +# completes (by then it carries the full query/result). |
| 131 | +_HOSTED_TOOL_TYPES = frozenset( |
| 132 | + { |
| 133 | + "web_search_call", |
| 134 | + "file_search_call", |
| 135 | + "code_interpreter_call", |
| 136 | + "image_generation_call", |
| 137 | + "mcp_call", |
| 138 | + "computer_call", |
| 139 | + "local_shell_call", |
| 140 | + } |
| 141 | +) |
| 142 | + |
| 143 | +# Cap on the rendered hosted-tool result string (UI / trace readability). |
| 144 | +_HOSTED_TOOL_RESULT_CAP = 2000 |
| 145 | + |
| 146 | + |
| 147 | +def _coerce_args(raw: Any) -> dict[str, Any]: |
| 148 | + """Best-effort coerce a hosted-tool's arguments to a dict for the UI.""" |
| 149 | + if raw is None: |
| 150 | + return {} |
| 151 | + if isinstance(raw, dict): |
| 152 | + return raw |
| 153 | + if isinstance(raw, str): |
| 154 | + try: |
| 155 | + parsed = json.loads(raw) |
| 156 | + return parsed if isinstance(parsed, dict) else {"value": parsed} |
| 157 | + except (json.JSONDecodeError, ValueError): |
| 158 | + return {"raw": raw} |
| 159 | + serialized = _serialize_item(raw) |
| 160 | + return serialized if isinstance(serialized, dict) else {"value": str(raw)} |
| 161 | + |
| 162 | + |
| 163 | +def _hosted_tool_request(item: Any) -> tuple[str, str, dict[str, Any]]: |
| 164 | + """Extract (call_id, display_name, arguments) from a hosted-tool item.""" |
| 165 | + itype = getattr(item, "type", "") or "" |
| 166 | + call_id = ( |
| 167 | + getattr(item, "id", "") |
| 168 | + or getattr(item, "call_id", "") |
| 169 | + or f"hosted_{uuid.uuid4().hex[:8]}" |
| 170 | + ) |
| 171 | + name = itype[:-5] if itype.endswith("_call") else itype # web_search_call -> web_search |
| 172 | + args: dict[str, Any] = {} |
| 173 | + if itype == "web_search_call": |
| 174 | + action = getattr(item, "action", None) |
| 175 | + if action is not None: |
| 176 | + args = _coerce_args(action) |
| 177 | + elif itype == "file_search_call": |
| 178 | + args = {"queries": list(getattr(item, "queries", []) or [])} |
| 179 | + elif itype == "code_interpreter_call": |
| 180 | + args = {"code": getattr(item, "code", "") or ""} |
| 181 | + elif itype == "mcp_call": |
| 182 | + mcp_name = getattr(item, "name", None) or "mcp" |
| 183 | + server = getattr(item, "server_label", None) |
| 184 | + name = f"{server}.{mcp_name}" if server else mcp_name |
| 185 | + args = _coerce_args(getattr(item, "arguments", None)) |
| 186 | + return call_id, name, args |
| 187 | + |
| 188 | + |
| 189 | +def _hosted_tool_result(item: Any) -> str: |
| 190 | + """Extract a short result string from a completed hosted-tool item.""" |
| 191 | + itype = getattr(item, "type", "") or "" |
| 192 | + if itype == "mcp_call": |
| 193 | + err = getattr(item, "error", None) |
| 194 | + if err: |
| 195 | + return f"error: {err}" |
| 196 | + out = getattr(item, "output", None) |
| 197 | + if out: |
| 198 | + return str(out) |
| 199 | + elif itype == "code_interpreter_call": |
| 200 | + outputs = getattr(item, "outputs", None) |
| 201 | + if outputs: |
| 202 | + return json.dumps([_serialize_item(o) for o in outputs])[:_HOSTED_TOOL_RESULT_CAP] |
| 203 | + elif itype == "file_search_call": |
| 204 | + results = getattr(item, "results", None) |
| 205 | + if results: |
| 206 | + return json.dumps([_serialize_item(r) for r in results])[:_HOSTED_TOOL_RESULT_CAP] |
| 207 | + return str(getattr(item, "status", "completed") or "completed") |
| 208 | + |
| 209 | + |
126 | 210 | class TemporalStreamingModel(Model): |
127 | 211 | """Custom model implementation with streaming support.""" |
128 | 212 |
|
@@ -481,6 +565,31 @@ def _convert_tool_choice(self, tool_choice: Any) -> Any: |
481 | 565 | # Pass through as-is for other types |
482 | 566 | return tool_choice |
483 | 567 |
|
| 568 | + async def _post_tool_message(self, task_id: str, content: Any) -> None: |
| 569 | + """Post a one-shot tool request/response message (no deltas). |
| 570 | +
|
| 571 | + Used for hosted/server-side tool calls (web_search, file_search, |
| 572 | + code_interpreter, image generation, server-side mcp, ...) that execute |
| 573 | + inside the Responses API and so never produce function_call items or fire |
| 574 | + RunHooks. Each completed hosted tool is surfaced as a ToolRequestContent + |
| 575 | + ToolResponseContent pair. Posting full (no deltas) means the coalescing |
| 576 | + path that the streamed reasoning/text contexts use does not apply here. |
| 577 | + """ |
| 578 | + try: |
| 579 | + async with adk.streaming.streaming_task_message_context( |
| 580 | + task_id=task_id, |
| 581 | + initial_content=content, |
| 582 | + ) as ctx: |
| 583 | + await ctx.stream_update( |
| 584 | + StreamTaskMessageFull( |
| 585 | + parent_task_message=ctx.task_message, |
| 586 | + content=content, |
| 587 | + type="full", |
| 588 | + ) |
| 589 | + ) |
| 590 | + except Exception as e: # noqa: BLE001 - UI surfacing must never break a turn |
| 591 | + logger.warning(f"[TemporalStreamingModel] failed to post hosted-tool message: {e}") |
| 592 | + |
484 | 593 | @override |
485 | 594 | async def get_response( |
486 | 595 | self, |
@@ -942,6 +1051,33 @@ async def get_response( |
942 | 1051 | finally: |
943 | 1052 | call_data['context'] = None |
944 | 1053 |
|
| 1054 | + elif item and getattr(item, 'type', None) in _HOSTED_TOOL_TYPES: |
| 1055 | + # Hosted / server-side tool call (web_search, file_search, |
| 1056 | + # code_interpreter, image generation, server-side mcp, ...). |
| 1057 | + # These run inside the Responses API: no function_call item |
| 1058 | + # and no RunHooks fire, so surface the completed call as a |
| 1059 | + # tool request + response pair (it carries the full |
| 1060 | + # query/result by the time it's done). |
| 1061 | + call_id, name, args = _hosted_tool_request(item) |
| 1062 | + await self._post_tool_message( |
| 1063 | + task_id, |
| 1064 | + ToolRequestContent( |
| 1065 | + author="agent", |
| 1066 | + tool_call_id=call_id, |
| 1067 | + name=name, |
| 1068 | + arguments=args, |
| 1069 | + ), |
| 1070 | + ) |
| 1071 | + await self._post_tool_message( |
| 1072 | + task_id, |
| 1073 | + ToolResponseContent( |
| 1074 | + author="agent", |
| 1075 | + tool_call_id=call_id, |
| 1076 | + name=name, |
| 1077 | + content={"result": _hosted_tool_result(item)[:_HOSTED_TOOL_RESULT_CAP]}, |
| 1078 | + ), |
| 1079 | + ) |
| 1080 | + |
945 | 1081 | elif isinstance(event, ResponseReasoningSummaryPartAddedEvent): |
946 | 1082 | # New reasoning part/summary started - reset accumulator |
947 | 1083 | part = getattr(event, 'part', None) |
|
0 commit comments