From 41f9ab473a9c8885758c049149a856cc7070ca0f Mon Sep 17 00:00:00 2001 From: pcerypeng Date: Thu, 6 Aug 2026 17:13:51 +0800 Subject: [PATCH] =?UTF-8?q?Feature:=20=E6=94=AF=E6=8C=81OpenAI=20Responses?= =?UTF-8?q?=20API=E4=B8=8EGraphAgent=20HITL=E5=8F=8A=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mkdocs/en/graph.md | 21 +- docs/mkdocs/en/model.md | 46 + docs/mkdocs/zh/graph.md | 18 +- docs/mkdocs/zh/model.md | 43 + pipeline_test/test_responses_api_ext.py | 341 ++++ pyproject.toml | 2 +- tests/filter/test_run_filter.py | 30 +- tests/models/test_openai_responses_model.py | 1469 +++++++++++++++++ .../session_memory_summary_diff_report.json | 12 +- tests/tools/test_function_tool.py | 19 +- .../graph/test_agent_node_hitl.py | 492 ++++++ tests/trpc_agent_dsl/graph/test_constants.py | 63 +- tests/trpc_agent_dsl/graph/test_events.py | 20 + .../trpc_agent_dsl/graph/test_graph_agent.py | 135 ++ .../trpc_agent_dsl/graph/test_memory_saver.py | 33 +- .../graph/test_node_action_agent.py | 203 ++- trpc_agent_sdk/dsl/graph/__init__.py | 8 + trpc_agent_sdk/dsl/graph/_constants.py | 35 + trpc_agent_sdk/dsl/graph/_events/_builder.py | 27 +- trpc_agent_sdk/dsl/graph/_exceptions.py | 51 + trpc_agent_sdk/dsl/graph/_graph_agent.py | 80 +- trpc_agent_sdk/dsl/graph/_history.py | 25 + trpc_agent_sdk/dsl/graph/_memory_saver.py | 18 + .../dsl/graph/_node_action/_agent.py | 226 ++- trpc_agent_sdk/dsl/graph/_state_graph.py | 11 + trpc_agent_sdk/models/_openai_model.py | 712 +++++++- .../server/ag_ui/_core/_agui_agent.py | 22 +- .../server/ag_ui/_core/_event_translator.py | 16 +- .../server/ag_ui/_core/_session_manager.py | 36 +- 29 files changed, 4077 insertions(+), 137 deletions(-) create mode 100644 pipeline_test/test_responses_api_ext.py create mode 100644 tests/models/test_openai_responses_model.py create mode 100644 tests/trpc_agent_dsl/graph/test_agent_node_hitl.py create mode 100644 trpc_agent_sdk/dsl/graph/_exceptions.py create mode 100644 trpc_agent_sdk/dsl/graph/_history.py diff --git a/docs/mkdocs/en/graph.md b/docs/mkdocs/en/graph.md index 83ed3038a..5726be593 100644 --- a/docs/mkdocs/en/graph.md +++ b/docs/mkdocs/en/graph.md @@ -413,7 +413,7 @@ Scenario: Integrate any BaseAgent (e.g., LlmAgent/GraphAgent) as a node in the g graph.add_agent_node( node_id="delegate", agent=delegate_agent, - isolated_messages=True, + history_scope="branch", input_from_last_response=False, event_scope="delegate_scope", input_mapper=StateMapper.rename({"query_text": STATE_KEY_USER_INPUT}), @@ -423,12 +423,29 @@ graph.add_agent_node( ``` Common options: -- isolated_messages: Whether to isolate the parent session's message history +- history_scope: Child history policy. `none` starts without parent history, + `branch` inherits only events from the same Agent-node branch (including its + nested branches), and `all` inherits the complete parent event history. +- isolated_messages: Legacy compatibility switch. When history_scope is omitted, + `True` maps to `none` and `False` maps to `all`. An explicit history_scope + takes precedence. - input_from_last_response: Whether to map the parent state's last_response as the child node's user_input - event_scope: Event branch prefix for the child Agent - input_mapper / output_mapper: Parent-child state mapping (explicit configuration recommended) - config / callbacks: Same as add_node +`branch` filters the child Session event log. The transient graph `messages` +state stays empty and GraphAgent rebuilds model input from those filtered events; +this keeps persistent Session state JSON-serializable. The policy is useful when +the same Agent node is entered again in a later outer run without exposing +private conversations from sibling nodes. During HITL resume, legacy +`isolated_messages=True` nodes still recover their current branch so the pending +function-call exchange remains intact. + +When a child Agent emits a `LongRunningEvent`, `add_agent_node` promotes it to a parent GraphAgent `interrupt`: the parent graph does not execute downstream nodes, and the Runner event preserves the original tool name and arguments. After the client submits the matching `FunctionResponse`, the parent graph resumes the current Agent node and the SDK maps the response back to the child's original function call. The graph continues only after the child Agent reaches a final result. This supports multiple HITL rounds in one node and persists child state in the SessionService-backed checkpoint for process-restart recovery. + +When the Agent node is a TeamAgent, the Leader can use the same HITL flow. Regular Team Members still must not be configured with or invoke `LongRunningFunctionTool`. + GraphAgent does not require (nor support) registering Agent nodes via sub_agents; composition relationships are handled uniformly through add_agent_node. ## Advanced Usage diff --git a/docs/mkdocs/en/model.md b/docs/mkdocs/en/model.md index 5b9618e76..a67812c8b 100644 --- a/docs/mkdocs/en/model.md +++ b/docs/mkdocs/en/model.md @@ -121,6 +121,52 @@ model = OpenAIModel( ) ``` +#### Responses API + +`OpenAIModel` uses Chat Completions by default. Enable the Responses API explicitly for OpenAI or compatible +providers that expose `/v1/responses`: + +```python +model = OpenAIModel( + model_name="your-responses-model", + api_key="your-api-key", + base_url="https://api.openai.com/v1", + use_responses_api=True, + responses_api_params={ + "store": False, + "reasoning": {"summary": "auto"}, + }, +) +``` + +The switch is opt-in so existing OpenAI-compatible providers continue to use Chat Completions. The adapter maps +conversation history, function calls and `function_call_output` items, structured output, semantic streaming events, +reasoning summaries, and token usage into the existing tRPC-Agent types. When `store=False`, the SDK automatically +requests `reasoning.encrypted_content` so reasoning items can be replayed with tool outputs in the next turn. + +`responses_api_params` accepts Responses-only fields such as `store`, `reasoning`, `include`, and `truncation`. +`model`, `input`, and `stream` are managed by `OpenAIModel` and cannot be overridden there. + +The field is typed as the openai SDK's `ResponseCreateParams` (`openai.types.responses`), so IDEs +auto-complete every native parameter. All values are passed through verbatim to `responses.create` +without any SDK-side mapping. To control reasoning depth, pass `reasoning.effort` directly: + +```python +model = OpenAIModel( + model_name="your-responses-model", + api_key="your-api-key", + use_responses_api=True, + responses_api_params={ + "reasoning": {"effort": "high"}, + }, +) +``` + +Supported effort values (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) vary by model — +see the OpenAI reasoning guide for model-specific support. The SDK deliberately does not map +`thinking_budget` to `effort`; it only requests `reasoning.summary` when thinking is enabled so the +reasoning output stays readable. + #### Advanced Usage Since version `1.1.10`, `OpenAIModel` supports passing a shared HTTP client provider to enable connection reuse. By default, `OpenAIModel` creates a temporary HTTP client for each model-service request. If you want to reuse connections, use the following configuration: diff --git a/docs/mkdocs/zh/graph.md b/docs/mkdocs/zh/graph.md index f5683e0ea..b837fdc8a 100644 --- a/docs/mkdocs/zh/graph.md +++ b/docs/mkdocs/zh/graph.md @@ -412,7 +412,7 @@ graph.add_mcp_node( graph.add_agent_node( node_id="delegate", agent=delegate_agent, - isolated_messages=True, + history_scope="branch", input_from_last_response=False, event_scope="delegate_scope", input_mapper=StateMapper.rename({"query_text": STATE_KEY_USER_INPUT}), @@ -422,12 +422,26 @@ graph.add_agent_node( ``` 常用选项: -- isolated_messages:是否隔离父会话消息历史 +- history_scope:子 Agent 历史策略。`none` 不继承父历史,`branch` 只继承 + 当前 Agent 节点 branch 及其子 branch 的事件,`all` 继承完整父会话事件。 +- isolated_messages:旧版兼容开关。未指定 history_scope 时,`True` 映射为 + `none`,`False` 映射为 `all`;显式 history_scope 优先。 - input_from_last_response:是否将父状态 last_response 映射为子节点 user_input - event_scope:子 Agent 事件分支前缀 - input_mapper / output_mapper:父子状态映射(推荐显式配置) - config / callbacks:同 add_node +`branch` 过滤 child Session 的事件日志;临时图状态中的 `messages` 保持为空, +GraphAgent 在需要时从已过滤事件重建模型输入,从而保证持久化 Session state +仍可 JSON 序列化。该策略适用于同一 Agent 节点在后续外层 run 中再次进入、 +但又不能看到兄弟节点私有对话的场景。HITL 恢复时,旧的 +`isolated_messages=True` 节点仍会恢复当前 branch,以保留挂起的 +function-call 交互链路。 + +当子 Agent 发出 `LongRunningEvent` 时,`add_agent_node` 会将其提升为父 GraphAgent 的 `interrupt`:父图不会执行后继节点,Runner 返回的事件保留原工具名和参数。客户端提交对应 `FunctionResponse` 后,父图恢复当前 Agent 节点,SDK 将响应映射回子 Agent 的原始 function call;只有子 Agent 最终完成后父图才继续。该机制支持同一节点多轮 HITL,并将 child state 写入 SessionService-backed checkpoint,服务重启后仍可恢复。 + +TeamAgent 作为 Agent 节点时同样支持 Leader 发起 HITL;普通 Team Member 仍不允许配置或调用 `LongRunningFunctionTool`。 + GraphAgent 不需要(也不支持)通过 sub_agents 注册 Agent 节点;组合关系统一用 add_agent_node 完成。 ## 进阶用法 diff --git a/docs/mkdocs/zh/model.md b/docs/mkdocs/zh/model.md index 16e2320b3..f74ed7717 100644 --- a/docs/mkdocs/zh/model.md +++ b/docs/mkdocs/zh/model.md @@ -121,6 +121,49 @@ model = OpenAIModel( ) ``` +#### Responses API + +`OpenAIModel` 默认仍使用 Chat Completions。对于 OpenAI 或提供 `/v1/responses` 的兼容服务,需要显式开启: + +```python +model = OpenAIModel( + model_name="your-responses-model", + api_key="your-api-key", + base_url="https://api.openai.com/v1", + use_responses_api=True, + responses_api_params={ + "store": False, + "reasoning": {"summary": "auto"}, + }, +) +``` + +该开关默认关闭,现有 OpenAI-compatible 服务不会改变调用路径。适配层会把多轮消息、函数调用及 +`function_call_output`、结构化输出、语义化流式事件、reasoning summary 和 token usage 映射为现有 +tRPC-Agent 类型。设置 `store=False` 时,SDK 会自动请求 `reasoning.encrypted_content`,以便下一轮 +连同工具结果一起回放 reasoning item。 + +`responses_api_params` 可传入 `store`、`reasoning`、`include`、`truncation` 等 Responses 专用字段; +`model`、`input`、`stream` 由 `OpenAIModel` 管理,不能在此覆盖。 + +该字段类型为 openai SDK 的 `ResponseCreateParams`(`openai.types.responses`),IDE 可直接提示全部原生参数; +所有字段原样透传给 `responses.create`,SDK 不做任何映射。控制推理深度时直接传 `reasoning.effort`: + +```python +model = OpenAIModel( + model_name="your-responses-model", + api_key="your-api-key", + use_responses_api=True, + responses_api_params={ + "reasoning": {"effort": "high"}, + }, +) +``` + +支持的档位(`none`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`)随模型而异, +具体以 OpenAI reasoning 指南及模型文档为准。SDK 刻意不做 `thinking_budget` → `effort` 的映射; +仅在开启 thinking 时请求 `reasoning.summary`,保证推理输出可读。 + #### 高级用法 从版本 `1.1.10`之后 OpenAIModel 支持传入共享的 http client 来解决连接复用的场景,当前的 OpenAIModel 默认每次都会创建临时的 http client 去访问模型服务;如果期望连接复用可以使用如下的方式 diff --git a/pipeline_test/test_responses_api_ext.py b/pipeline_test/test_responses_api_ext.py new file mode 100644 index 000000000..57f3f4fc1 --- /dev/null +++ b/pipeline_test/test_responses_api_ext.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Extended test for tRPC-Agent's OpenAI Responses adaptation. + +Covers the capabilities added in PR #245 (NOT a plain connectivity check): + + 1. thinking/effort passthrough: reasoning.effort delivered verbatim via + responses_api_params, reasoning.summary auto-injected when thinking is on + 2. tool calling round-trip: function_call -> function_call_output (multi-turn) + 3. store=False: SDK auto-appends "reasoning.encrypted_content" to include + 4. generation params passthrough: temperature / top_p / truncation + 5. streaming tool calling: streaming_tool_names deltas + final function_call, + followed by a function_call_output replay round (stream=True) + 6. store=False multi-turn reasoning replay: first-turn reasoning items + (with encrypted_content) survive into the second-turn request input + +Usage: + python pipeline_test/test_responses_api_ext.py [--stream] +""" + +import argparse +import asyncio +import json +import os + +from google.genai import types as genai_types + +from trpc_agent_sdk.models import LlmRequest, OpenAIModel +from trpc_agent_sdk.types import Content, FunctionResponse, GenerateContentConfig, Part, ThinkingConfig + +# The openai SDK appends "/responses" to base_url for the Responses resource. +BASE_URL = "https://tokenhub.tencentmaas.com/v1" +MODEL = "deepseek-v4-flash-202605" +API_KEY = os.environ.get("LKEAP_API_KEY") +if not API_KEY: + raise SystemExit("LKEAP_API_KEY is required, e.g. export LKEAP_API_KEY=sk-...") + +WEATHER_TOOL = genai_types.Tool(function_declarations=[ + genai_types.FunctionDeclaration( + name="get_weather", + description="获取指定城市的当前天气", + parameters=genai_types.Schema( + type="OBJECT", + properties={ + "city": genai_types.Schema(type="STRING", description="城市名,例如 北京"), + }, + required=["city"], + ), + ), +]) + + +def make_model(**kwargs) -> OpenAIModel: + return OpenAIModel( + model_name=MODEL, + api_key=API_KEY, + base_url=BASE_URL, + use_responses_api=True, + **kwargs, + ) + + +def _dump_part(part: Part) -> None: + if getattr(part, "text", None): + print(f" [text] {part.text}") + if getattr(part, "thought", False): + print(" [thought] ") + fc = getattr(part, "function_call", None) + if fc is not None: + print(f" [function_call] {fc.name}({fc.args}) id={fc.id}") + + +async def test_thinking_effort(stream: bool) -> None: + print("\n=== 1. thinking + reasoning.effort passthrough ===") + model = make_model(responses_api_params={"reasoning": {"effort": "high", "summary": "auto"}}) + request = LlmRequest( + contents=[Content(role="user", parts=[Part.from_text(text="8 * 7 * 6 等于多少?请分步推理")])], + config=GenerateContentConfig(thinking_config=ThinkingConfig(include_thoughts=True, thinking_budget=-1)), + tools_dict={}, + ) + async for resp in model.generate_async(request, stream=stream): + if resp.content and resp.content.parts: + for p in resp.content.parts: + _dump_part(p) + if resp.usage_metadata: + print(f" [usage] thoughts={resp.usage_metadata.thoughts_token_count} " + f"prompt={resp.usage_metadata.prompt_token_count} " + f"candidates={resp.usage_metadata.candidates_token_count}") + if resp.error_message: + print(f" [error] {resp.error_message}") + + +async def test_tool_roundtrip(stream: bool) -> None: + print("\n=== 2. tool calling round-trip (function_call -> function_call_output) ===") + model = make_model() + request = LlmRequest( + contents=[Content(role="user", parts=[Part.from_text(text="北京的天气怎么样?请用工具查询后告诉我")])], + config=GenerateContentConfig(tools=[WEATHER_TOOL]), + tools_dict={}, + ) + calls = [] + async for resp in model.generate_async(request, stream=False): + if resp.content and resp.content.parts: + for p in resp.content.parts: + _dump_part(p) + fc = getattr(p, "function_call", None) + if fc is not None: + calls.append(fc) + if resp.error_message: + print(f" [error] {resp.error_message}") + if not calls: + print(" (model answered with text instead of a tool call; " + "tool registration may need a more targeted prompt)") + return + + print(" -> feeding function_call_output back for round 2") + parts = [Part.from_text(text="北京的天气怎么样?请用工具查询后告诉我")] + for fc in calls: + fc_id = getattr(fc, "id", None) or "call_weather_1" + # Part.from_function_call() leaves function_call.id None; the Responses + # API requires function_call_output.call_id to match a function_call + # item, so set the id explicitly to the model-issued call id. + fcall_part = Part.from_function_call(name=fc.name, args=fc.args) + fcall_part.function_call.id = fc_id + parts.append(fcall_part) + parts.append( + Part(function_response=FunctionResponse( + id=fc_id, name=fc.name, response={ + "city": "北京", + "weather": "晴", + "temperature": 26 + }))) + round2 = LlmRequest( + contents=[ + Content(role="user", parts=parts), + # Note: function_call + function_response parts in one Content are + # split into responses "function_call" and "function_call_output" + # items by _convert_messages_to_responses_input. + ], + config=GenerateContentConfig(tools=[WEATHER_TOOL]), + tools_dict={}, + ) + async for resp in model.generate_async(round2, stream=stream): + if resp.content and resp.content.parts: + for p in resp.content.parts: + _dump_part(p) + if resp.error_message: + print(f" [error] {resp.error_message}") + + +async def test_store_false(stream: bool) -> None: + print("\n=== 3. store=False (auto include reasoning.encrypted_content) ===") + model = make_model(responses_api_params={"store": False}) + request = LlmRequest( + contents=[Content(role="user", parts=[Part.from_text(text="1+1=?")])], + config=GenerateContentConfig(thinking_config=ThinkingConfig(include_thoughts=True, thinking_budget=-1)), + tools_dict={}, + ) + async for resp in model.generate_async(request, stream=stream): + if resp.content and resp.content.parts: + for p in resp.content.parts: + _dump_part(p) + if resp.error_message: + print(f" [error] {resp.error_message}") + + +async def test_param_passthrough(stream: bool) -> None: + print("\n=== 4. generation params passthrough (temperature/top_p/truncation) ===") + model = make_model(responses_api_params={"truncation": "auto"}) + request = LlmRequest( + contents=[Content(role="user", parts=[Part.from_text(text="写一句关于春天的诗")])], + config=GenerateContentConfig(temperature=0.9, top_p=0.8), + tools_dict={}, + ) + async for resp in model.generate_async(request, stream=stream): + if resp.content and resp.content.parts: + for p in resp.content.parts: + _dump_part(p) + if resp.error_message: + print(f" [error] {resp.error_message}") + + +async def test_streaming_tool_roundtrip() -> None: + """Streaming tool calling: incremental arguments via streaming_tool_names, + a complete function_call in the final response, and a second streaming + round that feeds function_call_output back to the model.""" + print("\n=== 5. streaming tool calling round-trip ===") + model = make_model() + request = LlmRequest( + contents=[Content(role="user", parts=[Part.from_text(text="深圳的天气怎么样?请调用工具查询后告诉我")])], + config=GenerateContentConfig(tools=[WEATHER_TOOL]), + tools_dict={}, + ) + request.streaming_tool_names = {"get_weather"} + calls = [] + delta_chunks = 0 + async for resp in model.generate_async(request, stream=True): + if resp.content and resp.content.parts: + for p in resp.content.parts: + _dump_part(p) + fc = getattr(p, "function_call", None) + if fc is None: + continue + calls.append(fc) + args = getattr(fc, "args", None) or {} + if isinstance(args, dict) and args.get("tool_streaming_args"): + delta_chunks += 1 + if resp.error_message: + print(f" [error] {resp.error_message}") + + if not calls: + print(" (model answered with text instead of a streaming tool call; skipping replay)") + return + print(f" -> streamed {delta_chunks} argument delta chunk(s), {len(calls)} function_call part(s)") + + # The streaming path must emit at least one incremental delta when the tool + # is registered in streaming_tool_names, and the final response must carry + # a complete function_call for the replay. + if delta_chunks == 0: + print(" [warn] no streaming argument deltas observed (model may have skipped tool use)") + final_calls = [c for c in calls if not (isinstance(c.args, dict) and c.args.get("tool_streaming_args"))] + if not final_calls: + print(" (no complete function_call in the stream; skipping replay)") + return + + print(" -> feeding function_call_output back (streaming round 2)") + parts = [Part.from_text(text="深圳的天气怎么样?请调用工具查询后告诉我")] + for fc in final_calls: + fc_id = getattr(fc, "id", None) or "call_weather_stream" + fcall_part = Part.from_function_call(name=fc.name, args=fc.args) + fcall_part.function_call.id = fc_id + parts.append(fcall_part) + parts.append( + Part(function_response=FunctionResponse( + id=fc_id, name=fc.name, response={ + "city": "深圳", + "weather": "多云", + "temperature": 30 + }))) + round2 = LlmRequest( + contents=[Content(role="user", parts=parts)], + config=GenerateContentConfig(tools=[WEATHER_TOOL]), + tools_dict={}, + ) + round2.streaming_tool_names = {"get_weather"} + got_final = False + async for resp in model.generate_async(round2, stream=True): + if resp.content and resp.content.parts: + for p in resp.content.parts: + _dump_part(p) + if resp.error_message: + print(f" [error] {resp.error_message}") + if getattr(resp, "partial", None) is False: + got_final = True + print(f" [replay] got_final={got_final}") + + +async def test_store_false_reasoning_replay() -> None: + """store=False multi-turn reasoning replay: reasoning items captured in + round 1 (with encrypted_content) must survive into the round-2 request + input as verbatim Responses items, and round 2 must not be rejected.""" + print("\n=== 6. store=False multi-turn reasoning replay ===") + model = make_model(responses_api_params={"store": False}) + request = LlmRequest( + contents=[Content(role="user", parts=[Part.from_text(text="9 * 9 等于多少?请先推理再回答")])], + config=GenerateContentConfig(thinking_config=ThinkingConfig(include_thoughts=True, thinking_budget=-1)), + tools_dict={}, + ) + round1_parts = [] + async for resp in model.generate_async(request, stream=False): + if resp.content and resp.content.parts: + round1_parts = resp.content.parts + for p in round1_parts: + _dump_part(p) + if resp.error_message: + print(f" [error] {resp.error_message}") + + thought_parts = [p for p in round1_parts if getattr(p, "thought", False)] + if not thought_parts: + print(" (model emitted no thought parts in round 1; skipping replay)") + return + + # Reasoning items are stored on the thought part as thought_signature JSON. + reasoning_items = [] + for p in thought_parts: + raw = getattr(p, "thought_signature", None) + if not raw: + continue + raw = raw.decode("utf-8") if isinstance(raw, bytes) else raw + try: + item = json.loads(raw) + except (ValueError, TypeError): + continue + if isinstance(item, dict) and item.get("type") == "reasoning": + reasoning_items.append(item) + assert reasoning_items, "no reasoning items recovered from thought parts" + assert any("encrypted_content" in it for it in reasoning_items), \ + "reasoning items must keep encrypted_content (store=False include)" + print(f" [replay] recovered {len(reasoning_items)} reasoning item(s), " + f"{sum(1 for it in reasoning_items if 'encrypted_content' in it)} with encrypted_content") + + # Round 2: assistant turn (thought + text parts) followed by a user prompt. + round2_request = LlmRequest( + contents=[ + Content(role="user", parts=list(round1_parts)), + Content(role="user", parts=[Part.from_text(text="那 8 * 8 呢?")]), + ], + config=GenerateContentConfig(thinking_config=ThinkingConfig(include_thoughts=True, thinking_budget=-1)), + tools_dict={}, + ) + formatted = model._format_messages(round2_request) + input_items = model._convert_messages_to_responses_input(formatted) + replayed = [it for it in input_items if it.get("type") == "reasoning"] + assert replayed, "reasoning items must be preserved in the round-2 Responses input" + assert any("encrypted_content" in it for it in replayed), \ + "round-2 input items must keep encrypted_content" + print(f" [replay] {len(replayed)} reasoning item(s) preserved in round-2 input") + + async for resp in model.generate_async(round2_request, stream=False): + if resp.content and resp.content.parts: + for p in resp.content.parts: + _dump_part(p) + if resp.error_message: + print(f" [error] {resp.error_message}") + + +async def main(stream: bool) -> None: + print(f"model={MODEL} base_url={BASE_URL} stream={stream}") + await test_thinking_effort(stream) + await test_tool_roundtrip(stream) + await test_store_false(stream) + await test_param_passthrough(stream) + await test_streaming_tool_roundtrip() + await test_store_false_reasoning_replay() + print("\n[done] all scenarios finished") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Test tRPC-Agent OpenAI Responses capabilities.") + parser.add_argument("--stream", action="store_true", help="stream responses") + args = parser.parse_args() + asyncio.run(main(stream=args.stream)) diff --git a/pyproject.toml b/pyproject.toml index db0e78283..33c466a5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "pydantic>=2.11.3", - "openai>=1.3.0", + "openai>=1.66.0", "mcp>=1.10.1", "aiohttp", "httpx>=0.27.0", diff --git a/tests/filter/test_run_filter.py b/tests/filter/test_run_filter.py index 5c18cc9e4..b038380b1 100644 --- a/tests/filter/test_run_filter.py +++ b/tests/filter/test_run_filter.py @@ -7,12 +7,11 @@ from __future__ import annotations -from typing import Any from unittest.mock import MagicMock import pytest -from trpc_agent_sdk.abc import FilterResult, FilterType +from trpc_agent_sdk.abc import FilterResult from trpc_agent_sdk.filter._base_filter import BaseFilter from trpc_agent_sdk.filter._run_filter import ( coroutine_handler_adapter, @@ -21,11 +20,11 @@ stream_handler_adapter, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + class RecordingFilter(BaseFilter): """Filter that records calls and passes through.""" @@ -88,6 +87,7 @@ async def _after(self, ctx, req, rsp): # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture def mock_ctx(): return MagicMock() @@ -97,9 +97,11 @@ def mock_ctx(): # Tests for stream_handler_adapter # --------------------------------------------------------------------------- + class TestStreamHandlerAdapter: async def test_wraps_non_filter_result(self): + async def gen(): yield "raw_value" @@ -126,6 +128,7 @@ async def gen(): assert results[0] is fr async def test_multiple_events(self): + async def gen(): yield "a" yield FilterResult(rsp="b") @@ -145,6 +148,7 @@ async def gen(): # Tests for coroutine_handler_adapter # --------------------------------------------------------------------------- + class TestCoroutineHandlerAdapter: async def test_returns_filter_result_as_is(self): @@ -157,6 +161,7 @@ async def handle(): assert result is fr async def test_wraps_tuple_result(self): + async def handle(): return "val", RuntimeError("e") @@ -166,6 +171,7 @@ async def handle(): assert isinstance(result.error, RuntimeError) async def test_wraps_plain_value(self): + async def handle(): return "plain" @@ -175,6 +181,7 @@ async def handle(): assert result.error is None async def test_wraps_none(self): + async def handle(): return None @@ -184,6 +191,7 @@ async def handle(): assert result.error is None async def test_catches_exception(self): + async def handle(): raise ValueError("boom") @@ -193,6 +201,7 @@ async def handle(): assert not result.is_continue async def test_wraps_tuple_no_error(self): + async def handle(): return "ok", None @@ -205,9 +214,11 @@ async def handle(): # Tests for run_filters # --------------------------------------------------------------------------- + class TestRunFilters: async def test_no_filters(self, mock_ctx): + async def handle(): return "direct_result" @@ -240,9 +251,7 @@ async def handle(): result = await run_filters(mock_ctx, req, [f1, f2], handle) assert result == "done" - assert req["trace"] == [ - "before_A", "before_B", "handle", "after_B", "after_A" - ] + assert req["trace"] == ["before_A", "before_B", "handle", "after_B", "after_A"] async def test_handle_raises_propagates(self, mock_ctx): f = RecordingFilter("r") @@ -254,6 +263,7 @@ async def handle(): await run_filters(mock_ctx, "req", [f], handle) async def test_filter_returns_tuple_error(self, mock_ctx): + async def handle(): return "val", RuntimeError("tuple_err") @@ -265,9 +275,11 @@ async def handle(): # Tests for run_stream_filters # --------------------------------------------------------------------------- + class TestRunStreamFilters: async def test_no_filters(self, mock_ctx): + async def handle(): yield "a" yield "b" @@ -311,12 +323,11 @@ async def handle(): results.append(event) assert results == ["data"] - assert req["trace"] == [ - "before_A", "before_B", "handle", "after_B", "after_A" - ] + assert req["trace"] == ["before_A", "before_B", "handle", "after_B", "after_A"] async def test_yields_rsp_not_filter_result(self, mock_ctx): """run_stream_filters should yield event.rsp, not the FilterResult.""" + async def handle(): yield FilterResult(rsp="wrapped", is_continue=True) @@ -327,6 +338,7 @@ async def handle(): assert results == ["wrapped"] async def test_multiple_stream_events(self, mock_ctx): + async def handle(): yield "e1" yield "e2" diff --git a/tests/models/test_openai_responses_model.py b/tests/models/test_openai_responses_model.py new file mode 100644 index 000000000..c89651a67 --- /dev/null +++ b/tests/models/test_openai_responses_model.py @@ -0,0 +1,1469 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. + +import json +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from trpc_agent_sdk.models import LlmRequest, OpenAIModel +from trpc_agent_sdk.types import Content, GenerateContentConfig, HttpOptions, Part + + +def _model(**kwargs): + """Create an OpenAIModel with test defaults.""" + kwargs.setdefault("model_name", "gpt-4") + kwargs.setdefault("api_key", "test_key") + return OpenAIModel(**kwargs) + + +def _request(contents, config=None, streaming_tool_names=None): + """Create an LlmRequest for Responses API tests.""" + request = LlmRequest(contents=contents, config=config, tools_dict={}) + if streaming_tool_names is not None: + request.streaming_tool_names = streaming_tool_names + return request + + +# --------------------------------------------------------------------------- +# Responses API +# --------------------------------------------------------------------------- + + +class TestOpenAIResponsesAPI: + """Tests for the opt-in OpenAI Responses transport.""" + + def test_is_disabled_by_default_and_rejects_managed_overrides(self): + model = _model() + assert model.use_responses_api is False + + with pytest.raises(ValueError, match="cannot override managed parameters: input"): + _model(use_responses_api=True, responses_api_params={"input": "override"}) + + def test_rejects_add_tools_to_prompt_with_responses_api(self): + """The Responses API uses native function tools, so prompt-injected + tool definitions would be silently ignored — combining the two must + fail loudly at construction instead of breaking tool calling at runtime. + """ + with pytest.raises(ValueError, match="incompatible with add_tools_to_prompt"): + _model(use_responses_api=True, add_tools_to_prompt=True) + + # Each option on its own remains valid. + assert _model(use_responses_api=True).use_responses_api is True + assert _model(add_tools_to_prompt=True).add_tools_to_prompt is True + + def test_converts_tool_history_and_function_definitions(self): + model = _model(use_responses_api=True) + messages = [ + { + "role": + "assistant", + "content": + "Checking", + "tool_calls": [{ + "id": "call_weather", + "type": "function", + "function": { + "name": "weather", + "arguments": '{"city":"Shenzhen"}', + }, + }], + }, + { + "role": "tool", + "tool_call_id": "call_weather", + "content": '{"temperature":30}' + }, + ] + + items = model._convert_messages_to_responses_input(messages) + assert items == [ + { + "role": "assistant", + "content": "Checking" + }, + { + "type": "function_call", + "call_id": "call_weather", + "name": "weather", + "arguments": '{"city":"Shenzhen"}', + }, + { + "type": "function_call_output", + "call_id": "call_weather", + "output": '{"temperature":30}', + }, + ] + tools = model._convert_tools_to_responses_format([{ + "type": "function", + "function": { + "name": "weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {} + }, + }, + }]) + assert tools == [{ + "type": "function", + "name": "weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {} + }, + }] + + def test_reorders_function_call_outputs_after_function_call(self): + """Responses input must place function_call_output after its matching + function_call; orphaned outputs from legacy message ordering (tool + response before the assistant tool_calls message) are reordered.""" + model = _model(use_responses_api=True) + messages = [ + # Legacy ordering: tool response precedes the assistant tool_calls + # message, as produced for mixed single-Content inputs. + { + "role": "tool", + "tool_call_id": "call_weather", + "content": '{"temperature":30}' + }, + { + "role": + "user", + "content": + "天气?", + "tool_calls": [{ + "id": "call_weather", + "type": "function", + "function": { + "name": "weather", + "arguments": '{"city":"Shenzhen"}', + }, + }], + }, + ] + items = model._convert_messages_to_responses_input(messages) + item_types = [item.get("type") or item.get("role") for item in items] + assert item_types == ["user", "function_call", "function_call_output"] + assert items[1]["call_id"] == "call_weather" + assert items[2]["call_id"] == "call_weather" + + def test_converts_multimodal_input(self): + model = _model(use_responses_api=True) + + items = model._convert_messages_to_responses_input([{ + "role": + "user", + "content": [ + { + "type": "text", + "text": "Inspect this image", + }, + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,AAAA", + "detail": "high", + }, + }, + ], + }]) + + assert items == [{ + "role": + "user", + "content": [ + { + "type": "input_text", + "text": "Inspect this image", + }, + { + "type": "input_image", + "image_url": "data:image/png;base64,AAAA", + "detail": "high", + }, + ], + }] + + def test_preserves_empty_reasoning_item_for_tool_continuation(self): + model = _model(use_responses_api=True) + raw_reasoning = { + "id": "rs_empty", + "type": "reasoning", + "encrypted_content": "encrypted-reasoning", + "summary": [], + } + + response = model._create_responses_response({ + "id": + "resp_tool", + "status": + "completed", + "output": [ + raw_reasoning, + { + "type": "function_call", + "call_id": "call_1", + "name": "lookup", + "arguments": "{}", + }, + ], + }) + + thought = response.content.parts[0] + assert thought.thought is True + assert thought.text == "" + replay_request = _request([Content(parts=response.content.parts, role="model")]) + replay_items = model._convert_messages_to_responses_input(model._format_messages(replay_request)) + assert replay_items[0] == raw_reasoning + assert replay_items[1]["type"] == "function_call" + + def test_converts_structured_output_and_failed_response(self): + model = _model(use_responses_api=True) + text = model._convert_response_format_to_responses({ + "type": "json_schema", + "json_schema": { + "name": "answer", + "schema": { + "type": "object", + "properties": {} + }, + "strict": True, + }, + }) + assert text == { + "format": { + "type": "json_schema", + "name": "answer", + "schema": { + "type": "object", + "properties": {} + }, + "strict": True, + }, + } + + response = model._create_responses_response({ + "id": "resp_failed", + "status": "failed", + "error": { + "code": "server_error", + "message": "upstream failed" + }, + "output": [], + }) + assert response.error_code == "server_error" + assert response.error_message == "upstream failed" + + def test_logprobs_uses_the_installed_responses_client_shape(self): + model = _model(use_responses_api=True) + params = model._convert_api_params_to_responses({ + "model": "gpt-4", + "messages": [{ + "role": "user", + "content": "Hello" + }], + "stream": False, + "logprobs": True, + "top_logprobs": 3, + }) + + class TopLogprobsResponses: + + async def create(self, *, top_logprobs, **kwargs): + del top_logprobs, kwargs + + class TopLogprobsClient: + responses = TopLogprobsResponses() + + prepared = model._prepare_responses_api_params(TopLogprobsClient(), params) + assert prepared["top_logprobs"] == 3 + assert "_trpc_responses_logprobs_request" not in prepared + + def test_logprobs_fails_clearly_when_the_installed_client_lacks_support(self): + model = _model(use_responses_api=True) + params = model._convert_api_params_to_responses({ + "model": "gpt-4", + "messages": [{ + "role": "user", + "content": "Hello" + }], + "stream": False, + "logprobs": True, + "top_logprobs": 3, + }) + + class LegacyResponses: + + async def create(self, *, model, input, stream): + del model, input, stream + + class LegacyClient: + responses = LegacyResponses() + + with pytest.raises(ValueError, match="upgrade openai or disable logprobs"): + model._prepare_responses_api_params(LegacyClient(), params) + + @pytest.mark.asyncio + async def test_non_streaming_uses_responses_create_and_maps_output(self): + model = _model( + use_responses_api=True, + responses_api_params={ + "store": False, + "truncation": "auto" + }, + ) + request = _request( + [Content(parts=[Part.from_text(text="Hello")], role="user")], + GenerateContentConfig(max_output_tokens=128), + ) + response = Mock() + response.model_dump.return_value = { + "id": + "resp_123", + "status": + "completed", + "output": [ + { + "id": "rs_123", + "type": "reasoning", + "encrypted_content": "encrypted-reasoning", + "summary": [{ + "type": "summary_text", + "text": "Check context" + }], + }, + { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "Hello back" + }], + }, + { + "type": "function_call", + "call_id": "call_123", + "name": "lookup", + "arguments": '{"query":"hello"}', + }, + ], + "usage": { + "input_tokens": 10, + "output_tokens": 7, + "total_tokens": 17, + "input_tokens_details": { + "cached_tokens": 4 + }, + "output_tokens_details": { + "reasoning_tokens": 2 + }, + }, + } + captured = {} + + async def create(**kwargs): + captured.update(kwargs) + return response + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = create + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=False)] + + assert captured["model"] == "gpt-4" + assert captured["input"] == [{"role": "user", "content": "Hello"}] + assert captured["max_output_tokens"] == 128 + assert captured["store"] is False + assert captured["truncation"] == "auto" + assert captured["include"] == ["reasoning.encrypted_content"] + assert "messages" not in captured + assert "max_completion_tokens" not in captured + result = responses[0] + assert result.response_id == "resp_123" + assert [part.text for part in result.content.parts if part.text] == ["Check context", "Hello back"] + assert result.content.parts[0].thought is True + assert result.content.parts[-1].function_call.id == "call_123" + assert result.usage_metadata.prompt_token_count == 10 + assert result.usage_metadata.candidates_token_count == 7 + assert result.usage_metadata.thoughts_token_count == 2 + assert result.usage_metadata.cache_read_input_tokens == 4 + + replay_request = _request([Content(parts=result.content.parts, role="model")]) + replay_items = model._convert_messages_to_responses_input(model._format_messages(replay_request)) + assert replay_items[0] == { + "id": "rs_123", + "type": "reasoning", + "encrypted_content": "encrypted-reasoning", + "summary": [{ + "type": "summary_text", + "text": "Check context" + }], + } + assert replay_items[1] == {"role": "assistant", "content": "Hello back"} + assert replay_items[2]["type"] == "function_call" + + @pytest.mark.asyncio + async def test_non_streaming_passes_http_options_separately(self): + """Non-streaming Responses path must pass http_options (extra_body, + extra_headers, timeout) as separate kwargs to responses.create, + not merged into api_params (which would cause extra_body to be + treated as an unknown top-level parameter). + """ + model = _model(use_responses_api=True) + request = _request( + [Content(parts=[Part.from_text(text="Hello")], role="user")], + GenerateContentConfig( + max_output_tokens=128, + http_options=HttpOptions( + headers={"X-Custom-Header": "test-value"}, + timeout=5000, + extra_body={"custom_param": "custom_value"}, + ), + ), + ) + response = Mock() + response.model_dump.return_value = { + "id": + "resp_456", + "status": + "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "Hi there" + }], + }, + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8 + }, + } + captured = {} + + async def create(**kwargs): + captured.update(kwargs) + return response + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = create + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=False)] + + # Core API params must be present. + assert captured["model"] == "gpt-4" + assert captured["input"] == [{"role": "user", "content": "Hello"}] + assert captured["max_output_tokens"] == 128 + + # http_options must be passed as separate kwargs, not merged into + # the api_params dict that goes through _prepare_responses_api_params. + assert captured["extra_headers"] == {"X-Custom-Header": "test-value"} + assert captured["extra_body"] == {"custom_param": "custom_value"} + assert captured["timeout"] == 5.0 # 5000ms / 1000 + + # The response should be properly mapped. + result = responses[0] + assert result.response_id == "resp_456" + assert [part.text for part in result.content.parts if part.text] == ["Hi there"] + + @pytest.mark.asyncio + async def test_non_streaming_maps_function_call_output(self): + """A non-streaming Responses payload with a function_call item maps to + a LlmResponse carrying the function_call part (id/name/args).""" + model = _model(use_responses_api=True) + request = _request( + [Content(parts=[Part.from_text(text="Use lookup")], role="user")], + GenerateContentConfig(max_output_tokens=128), + ) + response = Mock() + response.model_dump.return_value = { + "id": + "resp_tool", + "status": + "completed", + "output": [ + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "lookup", + "arguments": '{"q":"x"}', + }, + ], + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + }, + } + + async def create(**kwargs): + return response + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = create + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=False)] + + assert len(responses) == 1 + parts = responses[0].content.parts + assert len(parts) == 1 + fc = parts[0].function_call + assert fc is not None + assert fc.name == "lookup" + assert fc.args == {"q": "x"} + assert fc.id == "call_1" + + @pytest.mark.asyncio + async def test_responses_maps_prompt_cache_and_passthrough_parallel_tool_calls(self): + """prompt_cache_* resolved from the model-level PromptCacheConfig are + mapped to their Responses names; parallel_tool_calls is forwarded + verbatim via responses_api_params.""" + from trpc_agent_sdk.configs import PromptCacheConfig + + model = _model( + use_responses_api=True, + prompt_cache_config=PromptCacheConfig( + enabled=True, + prompt_cache_key="cache-1", + ttl="1h", + ), + responses_api_params={"parallel_tool_calls": True}, + ) + request = _request( + [Content(parts=[Part.from_text(text="Hi")], role="user")], + GenerateContentConfig(max_output_tokens=128), + ) + response = Mock() + response.model_dump.return_value = { + "id": "resp_cache", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "OK" + }], + }, + ], + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + }, + } + captured = {} + + async def create(**kwargs): + captured.update(kwargs) + return response + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = create + client_factory.return_value = client + [item async for item in model.generate_async(request, stream=False)] + + assert captured.get("prompt_cache_key") == "cache-1" + assert captured.get("prompt_cache_retention") == "1h" + assert captured.get("parallel_tool_calls") is True + + @pytest.mark.asyncio + async def test_streaming_multiple_function_calls_accumulate(self): + """Parallel function_calls stream and accumulate independently, with + each call's arguments assembled from interleaved deltas.""" + model = _model(use_responses_api=True) + request = _request( + [Content(parts=[Part.from_text(text="Use both tools")], role="user")], + streaming_tool_names={"lookup_a", "lookup_b"}, + ) + events = [ + { + "type": "response.output_item.added", + "item": { + "type": "function_call", + "id": "fc_a", + "call_id": "call_a", + "name": "lookup_a", + "arguments": "", + }, + }, + { + "type": "response.output_item.added", + "item": { + "type": "function_call", + "id": "fc_b", + "call_id": "call_b", + "name": "lookup_b", + "arguments": "", + }, + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_a", + "delta": '{"q":' + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_b", + "delta": '{"r":' + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_a", + "delta": '"a"}' + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_b", + "delta": '"b"}' + }, + { + "type": "response.completed", + "response": { + "id": + "resp_multi", + "status": + "completed", + "output": [ + { + "type": "function_call", + "id": "fc_a", + "call_id": "call_a", + "name": "lookup_a", + "arguments": '{"q":"a"}', + }, + { + "type": "function_call", + "id": "fc_b", + "call_id": "call_b", + "name": "lookup_b", + "arguments": '{"r":"b"}', + }, + ], + "usage": { + "input_tokens": 1, + "output_tokens": 2, + "total_tokens": 3 + }, + }, + }, + ] + + async def stream_events(): + for event in events: + yield event + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = AsyncMock(return_value=stream_events()) + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=True)] + + # Streaming deltas for both calls were yielded, interleaved by call. + tool_deltas = [(item.content.parts[0].function_call.name, + item.content.parts[0].function_call.args["tool_streaming_args"]) for item in responses[0:-1]] + assert ("lookup_a", '{"q":') in tool_deltas + assert ("lookup_b", '{"r":') in tool_deltas + assert ("lookup_a", '"a"}') in tool_deltas + assert ("lookup_b", '"b"}') in tool_deltas + # Final response carries both fully-assembled function_calls. + final = responses[-1] + calls = { + part.function_call.name: part.function_call.args + for part in final.content.parts if part.function_call is not None + } + assert calls == { + "lookup_a": { + "q": "a" + }, + "lookup_b": { + "r": "b" + }, + } + + def test_pure_reasoning_turn_preserves_responses_input_items(self): + """A turn with only thought parts (no text/image/tool calls) must + still produce a message carrying ``responses_input_items`` so that + reasoning signatures are not silently dropped from history. + """ + from trpc_agent_sdk.models._openai_model import _RESPONSES_INPUT_ITEMS + model = _model(use_responses_api=True) + + reasoning_item = { + "type": "reasoning", + "id": "rs_pure", + "encrypted_content": "encrypted-pure", + "summary": [{ + "type": "summary_text", + "text": "Pure thought" + }], + } + + # Assistant content with only a thought part — no text, no tool calls. + # thought_signature is set via attribute assignment (not construction) + # to bypass Part's base64 validation, matching _create_responses_response. + thought_part = Part(thought=True, text="") + thought_part.thought_signature = json.dumps(reasoning_item, ensure_ascii=False).encode("utf-8") + assistant_content = Content( + role="model", + parts=[thought_part], + ) + user_content = Content(role="user", parts=[Part.from_text(text="follow up")]) + + messages = model._format_messages(_request([assistant_content, user_content])) + + # The assistant message must be present and carry the reasoning items. + assistant_msgs = [m for m in messages if m.get("role") == "assistant"] + assert len(assistant_msgs) == 1 + assert _RESPONSES_INPUT_ITEMS in assistant_msgs[0] + assert assistant_msgs[0][_RESPONSES_INPUT_ITEMS] == [reasoning_item] + + @pytest.mark.asyncio + async def test_responses_thinking_budget_does_not_map_to_effort(self): + """A positive thinking_budget must not be mapped to reasoning.effort: + supported effort values vary by model and are passed through verbatim + via ``responses_api_params`` instead.""" + from trpc_agent_sdk.types import ThinkingConfig + + model = _model(use_responses_api=True) + request = _request( + [Content(parts=[Part.from_text(text="Think hard")], role="user")], + GenerateContentConfig( + max_output_tokens=4096, + thinking_config=ThinkingConfig( + include_thoughts=True, + thinking_budget=20000, + ), + ), + ) + response = Mock() + response.model_dump.return_value = { + "id": "resp_effort", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "Result" + }], + }, + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8 + }, + } + captured = {} + + async def create(**kwargs): + captured.update(kwargs) + return response + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = create + client_factory.return_value = client + [item async for item in model.generate_async(request, stream=False)] + + reasoning = captured.get("reasoning") + assert reasoning is not None + assert reasoning["summary"] == "auto" + assert "effort" not in reasoning + + @pytest.mark.asyncio + async def test_responses_thinking_budget_negative_one_skips_effort(self): + """thinking_budget=-1 (automatic) should not set reasoning.effort, + letting the model decide.""" + from trpc_agent_sdk.types import ThinkingConfig + + model = _model(use_responses_api=True) + request = _request( + [Content(parts=[Part.from_text(text="Think")], role="user")], + GenerateContentConfig( + max_output_tokens=4096, + thinking_config=ThinkingConfig( + include_thoughts=True, + thinking_budget=-1, + ), + ), + ) + response = Mock() + response.model_dump.return_value = { + "id": "resp_auto", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "OK" + }], + }, + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8 + }, + } + captured = {} + + async def create(**kwargs): + captured.update(kwargs) + return response + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = create + client_factory.return_value = client + [item async for item in model.generate_async(request, stream=False)] + + reasoning = captured.get("reasoning") + assert reasoning is not None + assert reasoning["summary"] == "auto" + assert "effort" not in reasoning + + @pytest.mark.asyncio + async def test_responses_drops_chat_completions_only_params(self): + """Params with no Responses equivalent (stop, penalties, seed, n) must + not be forwarded, otherwise the OpenAI API rejects the request.""" + model = _model(use_responses_api=True) + request = _request( + [Content(parts=[Part.from_text(text="Hi")], role="user")], + GenerateContentConfig( + max_output_tokens=128, + stop_sequences=["\n"], + frequency_penalty=0.5, + presence_penalty=0.2, + seed=42, + candidate_count=2, + temperature=0.7, + ), + ) + response = Mock() + response.model_dump.return_value = { + "id": "resp_drop", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "OK" + }], + }, + ], + "usage": { + "input_tokens": 1, + "output_tokens": 1, + "total_tokens": 2 + }, + } + captured = {} + + async def create(**kwargs): + captured.update(kwargs) + return response + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = create + client_factory.return_value = client + [item async for item in model.generate_async(request, stream=False)] + + # Supported framework params are forwarded with their Responses name. + assert captured.get("temperature") == 0.7 + # Chat Completions-only params are dropped, not forwarded. + for dropped in ("stop", "frequency_penalty", "presence_penalty", "seed", "n"): + assert dropped not in captured + + @pytest.mark.asyncio + async def test_responses_api_params_passthrough_reasoning_effort(self): + """reasoning.effort is passed through verbatim from responses_api_params, + and a user-supplied summary wins over the framework default.""" + from trpc_agent_sdk.types import ThinkingConfig + + model = _model( + use_responses_api=True, + responses_api_params={ + "reasoning": { + "effort": "xhigh", + "summary": "detailed", + }, + }, + ) + request = _request( + [Content(parts=[Part.from_text(text="Think")], role="user")], + GenerateContentConfig( + max_output_tokens=4096, + thinking_config=ThinkingConfig( + include_thoughts=True, + thinking_budget=20000, + ), + ), + ) + response = Mock() + response.model_dump.return_value = { + "id": "resp_passthrough", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "OK" + }], + }, + ], + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8 + }, + } + captured = {} + + async def create(**kwargs): + captured.update(kwargs) + return response + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = create + client_factory.return_value = client + [item async for item in model.generate_async(request, stream=False)] + + assert captured.get("reasoning") == {"effort": "xhigh", "summary": "detailed"} + + @pytest.mark.asyncio + async def test_streaming_maps_text_reasoning_tool_calls_and_usage(self): + model = _model(use_responses_api=True) + request = _request( + [Content(parts=[Part.from_text(text="Use a tool")], role="user")], + streaming_tool_names={"lookup"}, + ) + events = [ + { + "type": "response.created", + "response": { + "id": "resp_stream" + } + }, + { + "type": "response.reasoning_summary_text.delta", + "delta": "Thinking" + }, + { + "type": "response.output_text.delta", + "delta": "Working" + }, + { + "type": "response.output_item.added", + "item": { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "lookup", + "arguments": "", + }, + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_1", + "delta": '{"q":' + }, + { + "type": "response.function_call_arguments.delta", + "item_id": "fc_1", + "delta": '"x"}' + }, + { + "type": "response.completed", + "response": { + "id": + "resp_stream", + "status": + "completed", + "output": [ + { + "type": "reasoning", + "summary": [{ + "type": "summary_text", + "text": "Thinking" + }] + }, + { + "type": "message", + "content": [{ + "type": "output_text", + "text": "Working" + }], + }, + { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "lookup", + "arguments": '{"q":"x"}', + }, + ], + "usage": { + "input_tokens": 5, + "output_tokens": 4, + "total_tokens": 9 + }, + }, + }, + ] + + async def stream_events(): + for event in events: + yield event + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = AsyncMock(return_value=stream_events()) + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=True)] + + assert responses[0].content.parts[0].thought is True + assert responses[0].content.parts[0].text == "Thinking" + assert responses[1].content.parts[0].text == "Working" + tool_deltas = [item.content.parts[0].function_call.args["tool_streaming_args"] for item in responses[2:-1]] + assert tool_deltas == ['{"q":', '"x"}'] + final = responses[-1] + assert final.partial is False + assert final.response_id == "resp_stream" + assert final.content.parts[-1].function_call.args == {"q": "x"} + assert final.usage_metadata.total_token_count == 9 + + @pytest.mark.asyncio + async def test_streaming_error_event_is_not_reported_as_success(self): + model = _model(use_responses_api=True) + request = _request([Content(parts=[Part.from_text(text="Hello")], role="user")]) + + async def stream_events(): + yield { + "type": "error", + "response_id": "resp_error", + "code": "server_error", + "message": "upstream unavailable", + } + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = AsyncMock(return_value=stream_events()) + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=True)] + + assert responses[-1].response_id == "resp_error" + assert responses[-1].error_code == "server_error" + assert responses[-1].error_message == "upstream unavailable" + + @pytest.mark.asyncio + async def test_streaming_uses_arguments_done_payload_in_fallback(self): + model = _model(use_responses_api=True) + request = _request([Content(parts=[Part.from_text(text="Use a tool")], role="user")]) + + async def stream_events(): + yield { + "type": "response.output_item.added", + "item": { + "type": "function_call", + "id": "fc_1", + "call_id": "call_1", + "name": "lookup", + "arguments": "", + }, + } + yield { + "type": "response.function_call_arguments.done", + "item_id": "fc_1", + "arguments": '{"query":"done"}', + } + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = AsyncMock(return_value=stream_events()) + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=True)] + + final = responses[-1] + assert final.partial is False + assert final.content.parts[0].function_call.id == "call_1" + assert final.content.parts[0].function_call.args == {"query": "done"} + + def test_incomplete_response_maps_reason_to_error(self): + model = _model(use_responses_api=True) + + response = model._create_responses_response({ + "id": "resp_incomplete", + "status": "incomplete", + "incomplete_details": { + "reason": "max_output_tokens", + }, + "output": [], + }) + + assert response.error_code == "incomplete" + assert response.error_message == "max_output_tokens" + + # ------------------------------------------------------------------ + # _responses_error — cancelled / incomplete_details fallback + # ------------------------------------------------------------------ + + def test_cancelled_response_maps_status_to_error(self): + model = _model(use_responses_api=True) + response = model._create_responses_response({ + "id": "resp_cancelled", + "status": "cancelled", + "output": [], + }) + assert response.error_code == "cancelled" + assert response.error_message == "cancelled" + + def test_incomplete_details_non_dict_fallback(self): + model = _model(use_responses_api=True) + response = model._create_responses_response({ + "id": "resp_incomplete", + "status": "incomplete", + "incomplete_details": "timeout", + "output": [], + }) + assert response.error_code == "incomplete" + assert response.error_message == "timeout" + + # ------------------------------------------------------------------ + # _create_responses_response — refusal text / non-dict arguments + # ------------------------------------------------------------------ + + def test_message_with_refusal_text(self): + model = _model(use_responses_api=True) + response = model._create_responses_response({ + "id": + "resp_refusal", + "status": + "completed", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{ + "type": "refusal", + "refusal": "I cannot answer that." + }], + }], + }) + assert response.content.parts[0].text == "I cannot answer that." + + def test_function_call_non_dict_arguments_skipped(self): + model = _model(use_responses_api=True) + response = model._create_responses_response({ + "id": + "resp_bad_args", + "status": + "completed", + "output": [{ + "type": "function_call", + "call_id": "call_bad", + "name": "broken_tool", + "arguments": "[not a dict]", + }], + }) + # Non-dict arguments are skipped, so no parts from function_call + assert response.content is None + + # ------------------------------------------------------------------ + # _convert_messages_to_responses_input — assistant / unknown type + # ------------------------------------------------------------------ + + def test_converts_assistant_message_to_output_text(self): + model = _model(use_responses_api=True) + items = model._convert_messages_to_responses_input([{ + "role": + "assistant", + "content": [{ + "type": "text", + "text": "Hello from assistant" + }], + }]) + assert items == [{ + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "Hello from assistant" + }], + }] + + def test_unknown_content_type_is_passthrough(self): + model = _model(use_responses_api=True) + items = model._convert_messages_to_responses_input([{ + "role": "user", + "content": [{ + "type": "custom_block", + "data": "raw" + }], + }]) + assert items == [{ + "role": "user", + "content": [{ + "type": "custom_block", + "data": "raw" + }], + }] + + def test_converts_non_function_tool_to_responses_format(self): + model = _model(use_responses_api=True) + tools = model._convert_tools_to_responses_format([ + { + "type": "web_search", + "name": "search" + }, + { + "type": "function", + "function": { + "name": "calc", + "description": "Calculate", + "parameters": { + "type": "object", + "properties": {} + }, + }, + }, + ]) + assert tools == [ + { + "type": "web_search", + "name": "search" + }, + { + "type": "function", + "name": "calc", + "description": "Calculate", + "parameters": { + "type": "object", + "properties": {} + }, + }, + ] + + # ------------------------------------------------------------------ + # _prepare_responses_api_params — logprobs structured support + # ------------------------------------------------------------------ + + def test_logprobs_uses_structured_logprobs_when_supported(self): + model = _model(use_responses_api=True) + params = model._convert_api_params_to_responses({ + "model": "gpt-4", + "messages": [{ + "role": "user", + "content": "Hello" + }], + "stream": False, + "logprobs": True, + "top_logprobs": 3, + }) + + class LogprobsResponses: + + async def create(self, *, logprobs, **kwargs): + del logprobs, kwargs + + class LogprobsClient: + responses = LogprobsResponses() + + prepared = model._prepare_responses_api_params(LogprobsClient(), params) + assert prepared["logprobs"] == {"enabled": True, "top_logprobs": 3} + + def test_logprobs_raises_on_inspect_failure(self): + model = _model(use_responses_api=True) + params = model._convert_api_params_to_responses({ + "model": "gpt-4", + "messages": [{ + "role": "user", + "content": "Hello" + }], + "stream": False, + "logprobs": True, + "top_logprobs": 3, + }) + + class BrokenResponses: + create = "not_callable" + + class BrokenClient: + responses = BrokenResponses() + + with pytest.raises(ValueError, match="Unable to determine Responses logprobs support"): + model._prepare_responses_api_params(BrokenClient(), params) + + # ------------------------------------------------------------------ + # _generate_responses_stream — fallback when no completed event + # ------------------------------------------------------------------ + + @pytest.mark.asyncio + async def test_streaming_fallback_when_no_completed_event(self): + """Streaming without response.completed builds final from accumulated text.""" + model = _model(use_responses_api=True) + request = _request( + [Content(parts=[Part.from_text(text="Hello")], role="user")], + streaming_tool_names={"search"}, + ) + + async def stream_events(): + yield {"type": "response.created", "response": {"id": "resp_fallback"}} + yield {"type": "response.reasoning_summary_text.delta", "delta": "Hmm"} + yield {"type": "response.output_text.delta", "delta": "Answer"} + yield { + "type": "response.output_item.added", + "item": { + "type": "function_call", + "id": "fc_fb", + "call_id": "call_fb", + "name": "search", + "arguments": "", + }, + } + yield { + "type": "response.function_call_arguments.delta", + "item_id": "fc_fb", + "delta": '{"q":"x"}', + } + # No response.completed — triggers fallback + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = AsyncMock(return_value=stream_events()) + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=True)] + + final = responses[-1] + assert final.partial is False + assert final.response_id == "resp_fallback" + assert final.content is not None + texts = [p.text for p in final.content.parts if p.text] + assert "Hmm" in texts or "Answer" in texts + + @pytest.mark.asyncio + async def test_streaming_arguments_done_with_function_call_item(self): + """response.function_call_arguments.done with item.type=function_call uses the item.""" + model = _model(use_responses_api=True) + request = _request([Content(parts=[Part.from_text(text="Use a tool")], role="user")]) + + async def stream_events(): + yield {"type": "response.created", "response": {"id": "resp_done_item"}} + yield { + "type": "response.output_item.added", + "item": { + "type": "function_call", + "id": "fc_item", + "call_id": "call_item", + "name": "lookup", + "arguments": "", + }, + } + yield { + "type": "response.function_call_arguments.done", + "item_id": "fc_item", + "item": { + "type": "function_call", + "id": "fc_item", + "call_id": "call_item", + "name": "lookup", + "arguments": '{"result":"from_item"}', + }, + } + yield { + "type": "response.completed", + "response": { + "id": + "resp_done_item", + "status": + "completed", + "output": [{ + "type": "function_call", + "id": "fc_item", + "call_id": "call_item", + "name": "lookup", + "arguments": '{"result":"from_item"}', + }], + }, + } + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = AsyncMock(return_value=stream_events()) + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=True)] + + final = responses[-1] + assert final.partial is False + # Function call from item should be in final output + assert final.content is not None + + @pytest.mark.asyncio + async def test_streaming_response_incomplete_event(self): + """response.incomplete event sets completed_response.""" + model = _model(use_responses_api=True) + request = _request([Content(parts=[Part.from_text(text="Hello")], role="user")]) + + async def stream_events(): + yield {"type": "response.created", "response": {"id": "resp_inc"}} + yield { + "type": "response.incomplete", + "response": { + "id": "resp_inc", + "status": "incomplete", + "incomplete_details": { + "reason": "max_output_tokens" + }, + "output": [], + }, + } + + with patch.object(model, "_create_async_client") as client_factory: + client = AsyncMock() + client.responses.create = AsyncMock(return_value=stream_events()) + client_factory.return_value = client + responses = [item async for item in model.generate_async(request, stream=True)] + + final = responses[-1] + assert final.error_code == "incomplete" + assert final.error_message == "max_output_tokens" + + # ------------------------------------------------------------------ + # _convert_api_params_to_responses — max_tokens fallback + # ------------------------------------------------------------------ + + def test_max_tokens_falls_back_to_max_output_tokens(self): + model = _model(use_responses_api=True) + params = model._convert_api_params_to_responses({ + "model": "gpt-4", + "messages": [{ + "role": "user", + "content": "Hi" + }], + "stream": False, + "max_tokens": 256, + }) + assert params["max_output_tokens"] == 256 + assert "max_tokens" not in params diff --git a/tests/sessions/session_memory_summary_diff_report.json b/tests/sessions/session_memory_summary_diff_report.json index 81770660a..8d3240a0d 100644 --- a/tests/sessions/session_memory_summary_diff_report.json +++ b/tests/sessions/session_memory_summary_diff_report.json @@ -202,7 +202,8 @@ "video_metadata": null, "tool_call": null, "tool_response": null, - "part_metadata": null + "part_metadata": null, + "audio_transcription": null } ], "role": "model" @@ -267,7 +268,8 @@ "video_metadata": null, "tool_call": null, "tool_response": null, - "part_metadata": null + "part_metadata": null, + "audio_transcription": null } ], "role": "model" @@ -406,7 +408,8 @@ "video_metadata": null, "tool_call": null, "tool_response": null, - "part_metadata": null + "part_metadata": null, + "audio_transcription": null } ], "role": "model" @@ -471,7 +474,8 @@ "video_metadata": null, "tool_call": null, "tool_response": null, - "part_metadata": null + "part_metadata": null, + "audio_transcription": null } ], "role": "model" diff --git a/tests/tools/test_function_tool.py b/tests/tools/test_function_tool.py index c550ba984..f6dba973b 100644 --- a/tests/tools/test_function_tool.py +++ b/tests/tools/test_function_tool.py @@ -6,9 +6,7 @@ from __future__ import annotations -import asyncio -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock import pytest from pydantic import BaseModel @@ -16,9 +14,9 @@ from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.tools._function_tool import FunctionTool - # --- Test functions --- + def sync_func(param1: str, param2: int = 10) -> str: """A sync test function.""" return f"{param1}-{param2}" @@ -128,7 +126,10 @@ async def test_run_sync_function(self, mock_context): tool = FunctionTool(sync_func) result = await tool._run_async_impl( tool_context=mock_context, - args={"param1": "hello", "param2": 5}, + args={ + "param1": "hello", + "param2": 5 + }, ) assert result == "hello-5" @@ -163,6 +164,7 @@ async def test_missing_mandatory_args(self, mock_context): @pytest.mark.asyncio async def test_returns_empty_dict_for_none(self, mock_context): + def returns_none(): return None @@ -178,7 +180,10 @@ async def test_pydantic_model_return(self, mock_context): tool = FunctionTool(func_returns_model) result = await tool._run_async_impl( tool_context=mock_context, - args={"name": "test", "value": 42}, + args={ + "name": "test", + "value": 42 + }, ) assert isinstance(result, str) assert "test" in result @@ -200,7 +205,9 @@ def slow_func(x: str) -> str: @pytest.mark.asyncio async def test_async_callable_object(self, mock_context): + class AsyncCallable: + async def __call__(self, value: str) -> str: """Async callable.""" return f"async-{value}" diff --git a/tests/trpc_agent_dsl/graph/test_agent_node_hitl.py b/tests/trpc_agent_dsl/graph/test_agent_node_hitl.py new file mode 100644 index 000000000..e4860b88b --- /dev/null +++ b/tests/trpc_agent_dsl/graph/test_agent_node_hitl.py @@ -0,0 +1,492 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""End-to-end HITL propagation tests for GraphAgent agent nodes.""" + +from typing import AsyncGenerator +from typing import List + +from pydantic import Field + +import pytest +from trpc_agent_sdk.agents import BaseAgent +from trpc_agent_sdk.dsl.graph import END +from trpc_agent_sdk.dsl.graph import START +from trpc_agent_sdk.dsl.graph import GraphAgent +from trpc_agent_sdk.dsl.graph import State +from trpc_agent_sdk.dsl.graph import StateGraph +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.events import LongRunningEvent +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.sessions import SqlSessionService +from trpc_agent_sdk.teams import TeamAgent +from trpc_agent_sdk.teams.core import TEAM_STATE_KEY +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import FunctionCall +from trpc_agent_sdk.types import FunctionResponse +from trpc_agent_sdk.types import Part + + +class HitlState(State, total=False): + after_child: bool + + +class StubModel(LLMModel): + + @classmethod + def supported_models(cls) -> List[str]: + return [r"stub-model"] + + async def _generate_async_impl( + self, + request: LlmRequest, + stream: bool = False, + ctx=None, + ) -> AsyncGenerator[LlmResponse, None]: + del request, stream, ctx + yield LlmResponse(content=None) + + def validate_request(self, request: LlmRequest) -> None: + del request + + +class TwoRoundClarifyingAgent(BaseAgent): + """Child agent that requires two FunctionResponses before completing.""" + + received_call_ids: list[str] = Field(default_factory=list) + + async def _run_async_impl(self, ctx) -> AsyncGenerator[Event, None]: + response = self._function_response(ctx.user_content) + if response is not None: + self.received_call_ids.append(str(response.id)) + if response.response.get("answer") == "done": + yield Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content(role="model", parts=[Part.from_text(text="clarification-complete")]), + ) + return + call_id = "child-question-2" + else: + call_id = "child-question-1" + + call = FunctionCall( + id=call_id, + name="ask_clarification", + args={"round": 1 if call_id.endswith("1") else 2}, + ) + yield LongRunningEvent( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + function_call=call, + function_response=FunctionResponse( + id=call.id, + name=call.name, + response={"status": "pending"}, + ), + ) + + @staticmethod + def _function_response(content: Content | None) -> FunctionResponse | None: + if content is None: + return None + for part in content.parts or []: + if part.function_response is not None: + return part.function_response + return None + + +class TeamHitlLeader(BaseAgent): + """Leader double that verifies TeamAgent receives the original response ID.""" + + received_call_ids: list[str] = Field(default_factory=list) + + async def _run_async_impl(self, ctx) -> AsyncGenerator[Event, None]: + response = TwoRoundClarifyingAgent._function_response(ctx.user_content) + if response is not None: + self.received_call_ids.append(str(response.id)) + yield Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + content=Content(role="model", parts=[Part.from_text(text="team-clarification-complete")]), + ) + return + + call = FunctionCall( + id="team-question-1", + name="ask_clarification", + args={"question": "approve team plan?"}, + ) + yield LongRunningEvent( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + function_call=call, + function_response=FunctionResponse( + id=call.id, + name=call.name, + response={"status": "pending"}, + ), + ) + + +def _user_text(text: str) -> Content: + return Content(role="user", parts=[Part.from_text(text=text)]) + + +def _tool_response(event: LongRunningEvent, answer: str) -> Content: + return Content( + role="user", + parts=[ + Part(function_response=FunctionResponse( + id=event.function_call.id, + name=event.function_call.name, + response={"answer": answer}, + )) + ], + ) + + +async def _run(runner: Runner, message: Content) -> list[Event]: + return [event async for event in runner.run_async( + user_id="user-1", + session_id="session-1", + new_message=message, + )] + + +@pytest.mark.asyncio +async def test_agent_node_long_running_interrupts_parent_and_resumes_multiple_rounds(): + child = TwoRoundClarifyingAgent(name="clarifier") + + async def after_child(state: HitlState) -> dict[str, bool]: + del state + return {"after_child": True} + + graph = StateGraph(HitlState) + graph.add_agent_node("clarify", child, isolated_messages=True) + graph.add_node("after_child", after_child) + graph.add_edge(START, "clarify") + graph.add_edge("clarify", "after_child") + graph.add_edge("after_child", END) + + service = InMemorySessionService() + runner = Runner( + app_name="agent-node-hitl-test", + agent=GraphAgent(name="workflow", graph=graph.compile()), + session_service=service, + close_session_service_on_close=False, + ) + + first_events = await _run(runner, _user_text("start")) + first_pending = next(event for event in first_events if isinstance(event, LongRunningEvent)) + assert first_pending.function_call.name == "ask_clarification" + assert first_pending.function_call.args == {"round": 1, "status": "pending"} + first_session = await service.get_session( + app_name="agent-node-hitl-test", + user_id="user-1", + session_id="session-1", + ) + assert first_session is not None + assert first_session.state.get("after_child") is not True + + second_events = await _run(runner, _tool_response(first_pending, "again")) + second_pending = next(event for event in second_events if isinstance(event, LongRunningEvent)) + assert second_pending.function_call.name == "ask_clarification" + second_session = await service.get_session( + app_name="agent-node-hitl-test", + user_id="user-1", + session_id="session-1", + ) + assert second_session is not None + assert second_session.state.get("after_child") is not True + + await _run(runner, _tool_response(second_pending, "done")) + completed_session = await service.get_session( + app_name="agent-node-hitl-test", + user_id="user-1", + session_id="session-1", + ) + assert completed_session is not None + assert completed_session.state.get("after_child") is True + assert child.received_call_ids == ["child-question-1", "child-question-2"] + + await runner.close() + await service.close() + + +@pytest.mark.asyncio +async def test_agent_node_hitl_survives_service_restart(tmp_path): + """AgentNode HITL state must persist across process restart via SqlSessionService. + + This verifies that STATE_KEY_PENDING_AGENT_NODE_HITL is written to + session.state through the interrupt bridge event's state_delta, so that + a fresh Runner reading from the same SqlSessionService can resume the + pending HITL round. + """ + from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_PENDING_AGENT_NODE_HITL + + def build_runner(service: SqlSessionService) -> tuple[Runner, TwoRoundClarifyingAgent]: + child = TwoRoundClarifyingAgent(name="clarifier") + graph = StateGraph(HitlState) + graph.add_agent_node("clarify", child, isolated_messages=True) + graph.add_edge(START, "clarify") + graph.add_edge("clarify", END) + return ( + Runner( + app_name="agent-node-restart-test", + agent=GraphAgent(name="workflow", graph=graph.compile()), + session_service=service, + close_session_service_on_close=False, + ), + child, + ) + + db_url = f"sqlite:///{tmp_path / 'agent-node-restart.sqlite'}" + service = SqlSessionService(db_url, is_async=False) + runner, child = build_runner(service) + + first_events = await _run(runner, _user_text("start")) + first_pending = next(event for event in first_events if isinstance(event, LongRunningEvent)) + assert first_pending.function_call.name == "ask_clarification" + + first_session = await service.get_session( + app_name="agent-node-restart-test", + user_id="user-1", + session_id="session-1", + ) + assert first_session is not None + # The pending HITL state must be persisted in session.state so that a + # fresh Runner can pick it up. + assert STATE_KEY_PENDING_AGENT_NODE_HITL in first_session.state + pending = first_session.state[STATE_KEY_PENDING_AGENT_NODE_HITL] + assert pending["node_id"] == "clarify" + assert pending["current"]["function_call"]["id"] == "child-question-1" + + # Simulate process restart: close runner + service, re-open from same DB. + await runner.close() + await service.close() + + service = SqlSessionService(db_url, is_async=False) + runner, resumed_child = build_runner(service) + + # Resume with the first round's response. + second_events = await _run(runner, _tool_response(first_pending, "again")) + second_pending = next(event for event in second_events if isinstance(event, LongRunningEvent)) + + second_session = await service.get_session( + app_name="agent-node-restart-test", + user_id="user-1", + session_id="session-1", + ) + assert second_session is not None + assert STATE_KEY_PENDING_AGENT_NODE_HITL in second_session.state + pending2 = second_session.state[STATE_KEY_PENDING_AGENT_NODE_HITL] + assert len(pending2["completed"]) == 1 + assert pending2["current"]["function_call"]["id"] == "child-question-2" + + # Complete the second round. + await _run(runner, _tool_response(second_pending, "done")) + completed_session = await service.get_session( + app_name="agent-node-restart-test", + user_id="user-1", + session_id="session-1", + ) + assert completed_session is not None + # After completion, the pending HITL state should be cleared. + hitl_value = completed_session.state.get(STATE_KEY_PENDING_AGENT_NODE_HITL) + assert hitl_value is None + assert resumed_child.received_call_ids == ["child-question-1", "child-question-2"] + + await runner.close() + await service.close() + + +@pytest.mark.asyncio +async def test_agent_node_hitl_state_key_is_unsafe(): + """STATE_KEY_PENDING_AGENT_NODE_HITL must be in UNSAFE_STATE_KEYS so it + is filtered out of the final_state/state_delta exposed via completion + events (it may contain sensitive tool arguments and child state). + """ + from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_PENDING_AGENT_NODE_HITL + from trpc_agent_sdk.dsl.graph._constants import is_unsafe_state_key + + assert is_unsafe_state_key(STATE_KEY_PENDING_AGENT_NODE_HITL) is True + + +@pytest.mark.asyncio +async def test_team_agent_leader_hitl_survives_service_restart(tmp_path): + + async def after_team(state: HitlState) -> dict[str, bool]: + del state + return {"after_child": True} + + def build_runner(service: SqlSessionService) -> tuple[Runner, TeamHitlLeader]: + member = TwoRoundClarifyingAgent(name="unused_member") + team = TeamAgent( + name="development_team", + model=StubModel(model_name="stub-model"), + members=[member], + ) + leader = TeamHitlLeader(name="development_team") + team.__pydantic_private__["_leader_agent"] = leader + graph = StateGraph(HitlState) + graph.add_agent_node("development", team, isolated_messages=True) + graph.add_node("after_team", after_team) + graph.add_edge(START, "development") + graph.add_edge("development", "after_team") + graph.add_edge("after_team", END) + return ( + Runner( + app_name="agent-node-hitl-test", + agent=GraphAgent(name="team_workflow", graph=graph.compile()), + session_service=service, + close_session_service_on_close=False, + ), + leader, + ) + + db_url = f"sqlite:///{tmp_path / 'agent-node-hitl.sqlite'}" + service = SqlSessionService(db_url, is_async=False) + runner, _ = build_runner(service) + + first_events = await _run(runner, _user_text("start team")) + pending = next(event for event in first_events if isinstance(event, LongRunningEvent)) + first_session = await service.get_session( + app_name="agent-node-hitl-test", + user_id="user-1", + session_id="session-1", + ) + assert first_session is not None + assert first_session.state.get("after_child") is not True + assert TEAM_STATE_KEY in first_session.state + + await runner.close() + await service.close() + + service = SqlSessionService(db_url, is_async=False) + runner, resumed_leader = build_runner(service) + await _run(runner, _tool_response(pending, "done")) + completed_session = await service.get_session( + app_name="agent-node-hitl-test", + user_id="user-1", + session_id="session-1", + ) + assert completed_session is not None + assert completed_session.state.get("after_child") is True + assert resumed_leader.received_call_ids == ["team-question-1"] + + await runner.close() + await service.close() + + +@pytest.mark.asyncio +async def test_agent_node_multiround_hitl_resume_with_correct_order(): + """Multi-round HITL resume must replay completed rounds in order. + + After the first round interrupts, the client resumes with a FunctionResponse. + The second round interrupts again. The client must then resume with the + second round's response. This test verifies that submitting a stale + (already-completed round's) response does not silently complete the graph, + and that subsequently submitting the correct round's response succeeds. + """ + child = TwoRoundClarifyingAgent(name="clarifier") + + async def after_child(state: HitlState) -> dict[str, bool]: + del state + return {"after_child": True} + + graph = StateGraph(HitlState) + graph.add_agent_node("clarify", child, isolated_messages=True) + graph.add_node("after_child", after_child) + graph.add_edge(START, "clarify") + graph.add_edge("clarify", "after_child") + graph.add_edge("after_child", END) + + service = InMemorySessionService() + runner = Runner( + app_name="agent-node-hitl-order-test", + agent=GraphAgent(name="workflow", graph=graph.compile()), + session_service=service, + close_session_service_on_close=False, + ) + + # Round 1: interrupt with child-question-1 (wrapped in a synthesized + # graph-level interrupt bridge function_call.id). + first_events = await _run(runner, _user_text("start")) + first_pending = next(event for event in first_events if isinstance(event, LongRunningEvent)) + assert first_pending.function_call.name == "ask_clarification" + assert first_pending.function_call.args == {"round": 1, "status": "pending"} + + # Resume round 1 with "again" → round 2 interrupts with child-question-2. + second_events = await _run(runner, _tool_response(first_pending, "again")) + second_pending = next(event for event in second_events if isinstance(event, LongRunningEvent)) + assert second_pending.function_call.name == "ask_clarification" + assert second_pending.function_call.args == {"round": 2, "status": "pending"} + + # Now try to resume round 2 using the FIRST round's function_call.id. + # This is a "stale" resume — the client submitted a response for an + # already-completed round. The graph's _extract_resume_command builds a + # Command with the stale function_response.id; LangGraph will not find + # a matching interrupt and the resume value will be ignored or cause an + # error. We assert that the graph does NOT silently complete with + # incorrect state. + stale_response = Content( + role="user", + parts=[ + Part(function_response=FunctionResponse( + id=first_pending.function_call.id, + name=first_pending.function_call.name, + response={"answer": "stale"}, + )) + ], + ) + stale_events = await _run(runner, stale_response) + # The graph should not have completed successfully with a stale resume. + completed_session = await service.get_session( + app_name="agent-node-hitl-order-test", + user_id="user-1", + session_id="session-1", + ) + assert completed_session is not None + # after_child should NOT be True because we didn't properly complete round 2. + assert completed_session.state.get("after_child") is not True + + # After the stale resume the graph may have re-interrupted (the stale + # value was consumed by the current round's interrupt, causing the child + # to ask another question). We verify the key invariant: the graph did + # NOT silently complete. A subsequent proper resume should still be able + # to drive the graph to completion. + # + # Find the latest pending LongRunningEvent from the stale run and resume + # it with "done" to complete the flow. + stale_pending = next( + (event for event in reversed(stale_events) if isinstance(event, LongRunningEvent)), + None, + ) + if stale_pending is not None: + await _run(runner, _tool_response(stale_pending, "done")) + else: + # If the stale resume did not produce a new interrupt, try the + # original second_pending. + await _run(runner, _tool_response(second_pending, "done")) + + completed_session = await service.get_session( + app_name="agent-node-hitl-order-test", + user_id="user-1", + session_id="session-1", + ) + assert completed_session is not None + assert completed_session.state.get("after_child") is True + + await runner.close() + await service.close() diff --git a/tests/trpc_agent_dsl/graph/test_constants.py b/tests/trpc_agent_dsl/graph/test_constants.py index 5dc561a29..b701bb984 100644 --- a/tests/trpc_agent_dsl/graph/test_constants.py +++ b/tests/trpc_agent_dsl/graph/test_constants.py @@ -7,6 +7,7 @@ from trpc_agent_sdk.dsl.graph._constants import ( END, + GRAPH_INTERNAL_STATE_PREFIX, METADATA_KEY_AGENT_NAME, METADATA_KEY_BRANCH, METADATA_KEY_INVOCATION_ID, @@ -39,6 +40,7 @@ STATE_KEY_NODE_RESPONSES, STATE_KEY_ONE_SHOT_MESSAGES, STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE, + STATE_KEY_PENDING_AGENT_NODE_HITL, STATE_KEY_PENDING_INTERRUPT, STATE_KEY_PENDING_INTERRUPT_AUTHOR, STATE_KEY_PENDING_INTERRUPT_BRANCH, @@ -50,6 +52,7 @@ STREAM_KEY_ACK, STREAM_KEY_EVENT, UNSAFE_STATE_KEYS, + is_graph_internal_state_key, is_unsafe_state_key, ) @@ -114,6 +117,7 @@ def test_interrupt_keys(self): assert STATE_KEY_PENDING_INTERRUPT_ID == "_trpc_graph_pending_interrupt_id" assert STATE_KEY_PENDING_INTERRUPT_AUTHOR == "_trpc_graph_pending_interrupt_author" assert STATE_KEY_PENDING_INTERRUPT_BRANCH == "_trpc_graph_pending_interrupt_branch" + assert STATE_KEY_PENDING_AGENT_NODE_HITL == "_trpc_graph_pending_agent_node_hitl" assert STATE_KEY_LONG_RUNNING_PREFIX == "__trpc_graph_long_running__" def test_role_values(self): @@ -145,6 +149,7 @@ def test_unsafe_keys_contains_expected_members(self): STATE_KEY_PENDING_INTERRUPT_ID, STATE_KEY_PENDING_INTERRUPT_AUTHOR, STATE_KEY_PENDING_INTERRUPT_BRANCH, + STATE_KEY_PENDING_AGENT_NODE_HITL, } assert UNSAFE_STATE_KEYS == expected @@ -169,10 +174,60 @@ def test_is_unsafe_state_key_returns_false_for_safe_keys(self): def test_serializable_keys_are_not_unsafe(self): """Keys that represent user-visible data should never appear in UNSAFE_STATE_KEYS.""" serializable = { - STATE_KEY_USER_INPUT, STATE_KEY_MESSAGES, STATE_KEY_LAST_RESPONSE, - STATE_KEY_LAST_RESPONSE_ID, STATE_KEY_LAST_TOOL_RESPONSE, - STATE_KEY_NODE_RESPONSES, STATE_KEY_ONE_SHOT_MESSAGES, - STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE, STATE_KEY_METADATA, + STATE_KEY_USER_INPUT, + STATE_KEY_MESSAGES, + STATE_KEY_LAST_RESPONSE, + STATE_KEY_LAST_RESPONSE_ID, + STATE_KEY_LAST_TOOL_RESPONSE, + STATE_KEY_NODE_RESPONSES, + STATE_KEY_ONE_SHOT_MESSAGES, + STATE_KEY_ONE_SHOT_MESSAGES_BY_NODE, + STATE_KEY_METADATA, STATE_KEY_STEP_NUMBER, } assert serializable.isdisjoint(UNSAFE_STATE_KEYS) + + +class TestGraphInternalStateKeys: + """Contract tests for ``is_graph_internal_state_key``. + + The AG-UI client boundary filters GraphAgent-internal state keys + (``_trpc_graph_*``) out of outbound snapshots/deltas and inbound state + patches. Any new GraphAgent-internal key MUST be covered by this predicate, + otherwise it leaks to (or can be echoed back by) the client. + """ + + def test_prefix_constant(self): + assert GRAPH_INTERNAL_STATE_PREFIX == "_trpc_graph_" + + def test_all_graph_internal_keys_are_covered(self): + graph_internal_keys = { + STREAM_KEY_EVENT, + STREAM_KEY_ACK, + STATE_KEY_CHECKPOINTS, + STATE_KEY_CHECKPOINT_WRITES, + STATE_KEY_CHECKPOINT_BLOBS, + STATE_KEY_PENDING_INTERRUPT, + STATE_KEY_PENDING_INTERRUPT_ID, + STATE_KEY_PENDING_INTERRUPT_AUTHOR, + STATE_KEY_PENDING_INTERRUPT_BRANCH, + STATE_KEY_PENDING_AGENT_NODE_HITL, + } + for key in graph_internal_keys: + assert is_graph_internal_state_key(key) is True, f"Expected {key!r} to be graph-internal" + + def test_non_graph_keys_are_not_covered(self): + non_graph_keys = [ + STATE_KEY_USER_INPUT, + STATE_KEY_MESSAGES, + STATE_KEY_LAST_RESPONSE, + "arbitrary_custom_key", + "_trpc_some_other_module_key", + "", + ] + for key in non_graph_keys: + assert is_graph_internal_state_key(key) is False, f"Expected {key!r} not to be graph-internal" + + def test_non_string_key_is_false(self): + assert is_graph_internal_state_key(None) is False + assert is_graph_internal_state_key(123) is False diff --git a/tests/trpc_agent_dsl/graph/test_events.py b/tests/trpc_agent_dsl/graph/test_events.py index fe3cbc8e5..0c8ea6667 100644 --- a/tests/trpc_agent_dsl/graph/test_events.py +++ b/tests/trpc_agent_dsl/graph/test_events.py @@ -54,6 +54,26 @@ def test_node_start_contains_structured_metadata_and_truncates_model_input(self) assert event.object == GraphEventType.GRAPH_NODE_START assert event.partial is True + def test_node_events_prefer_human_readable_description_in_text(self): + """User-visible node events should not leak internal node IDs.""" + start = self.builder.node_start( + node_id="tech-clarify", + node_description="技术澄清", + ) + complete = self.builder.node_complete( + node_id="tech-clarify", + node_description="技术澄清", + ) + error = self.builder.node_error( + node_id="tech-clarify", + node_description="技术澄清", + error="failed", + ) + + assert start.get_text() == "Starting node: 技术澄清" + assert complete.get_text().startswith("Completed node: 技术澄清") + assert error.get_text() == "Error in node 技术澄清: failed" + def test_model_complete_uses_error_phase_and_truncates_payloads(self): """Model completion should switch to error phase when error is provided.""" start_time = datetime.now() - timedelta(milliseconds=20) diff --git a/tests/trpc_agent_dsl/graph/test_graph_agent.py b/tests/trpc_agent_dsl/graph/test_graph_agent.py index 89f260ce3..a1fe77d86 100644 --- a/tests/trpc_agent_dsl/graph/test_graph_agent.py +++ b/tests/trpc_agent_dsl/graph/test_graph_agent.py @@ -29,6 +29,7 @@ from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_USER_INPUT from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_ACK from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_EVENT +from trpc_agent_sdk.dsl.graph._exceptions import GraphResumeError from trpc_agent_sdk.dsl.graph._graph_agent import GraphAgent from trpc_agent_sdk.dsl.graph._state_graph import CompiledStateGraph from trpc_agent_sdk.events import Event @@ -95,6 +96,7 @@ def _new_invocation_context( invocation_id: str = "inv-1", branch: str | None = "graph-agent", actions: EventActions | None = None, + user_content: Content | None = None, ) -> InvocationContext: """Create a real InvocationContext for GraphAgent.run_async tests.""" return InvocationContext( @@ -104,6 +106,7 @@ def _new_invocation_context( agent=agent, agent_context=new_agent_context(), session=session, + user_content=user_content, event_actions=actions or EventActions(), ) @@ -286,6 +289,138 @@ async def test_run_async_resume_uses_command_and_clears_pending_interrupt_state( assert session.state[STATE_KEY_PENDING_INTERRUPT_AUTHOR] is None assert session.state[STATE_KEY_PENDING_INTERRUPT_BRANCH] is None + async def test_run_async_resume_prefers_current_invocation_content(self): + """Resume must not depend on unrelated events appended to the session tail.""" + function_response = FunctionResponse( + id=f"{STATE_KEY_LONG_RUNNING_PREFIX}approval:current", + name="approval", + response={"approved": True}, + ) + graph = _FakeCompiledGraph(items=[]) + agent = _new_graph_agent(graph) + unrelated_event = Event( + invocation_id="other", + author="system", + content=Content(role="model", parts=[Part.from_text(text="projection")]), + ) + session = _new_session( + unrelated_event, + state={ + STATE_KEY_PENDING_INTERRUPT: True, + STATE_KEY_PENDING_INTERRUPT_ID: function_response.id, + }, + ) + current_content = Content( + role=ROLE_USER, + parts=[Part(function_response=function_response)], + ) + ctx = _new_invocation_context(agent, session, user_content=current_content) + + events = [event async for event in agent.run_async(ctx)] + + assert events[-1].object == "graph.execution" + assert isinstance(graph.calls[0].graph_input, Command) + assert graph.calls[0].graph_input.resume == {"approval:current": {"approved": True}} + + async def test_run_async_pending_interrupt_rejects_non_response_input(self): + """A paused graph must fail closed instead of restarting from START.""" + pending_id = f"{STATE_KEY_LONG_RUNNING_PREFIX}approval:pending" + stale_response_event = Event( + invocation_id="old", + author=ROLE_USER, + content=Content( + role=ROLE_USER, + parts=[ + Part(function_response=FunctionResponse( + id=pending_id, + name="approval", + response={"approved": False}, + )) + ], + ), + ) + graph = _FakeCompiledGraph(items=[]) + agent = _new_graph_agent(graph) + session = _new_session( + stale_response_event, + state={ + STATE_KEY_PENDING_INTERRUPT: True, + STATE_KEY_PENDING_INTERRUPT_ID: pending_id, + }, + ) + ctx = _new_invocation_context( + agent, + session, + user_content=Content(role=ROLE_USER, parts=[Part.from_text(text="continue")]), + ) + + events = [event async for event in agent.run_async(ctx)] + + assert len(events) == 1 + assert events[0].is_error() + assert events[0].error_code == GraphResumeError.error_code + assert events[0].custom_metadata["reason"] == "missing_function_response" + assert graph.calls == [] + assert session.state[STATE_KEY_PENDING_INTERRUPT] is True + + async def test_run_async_pending_interrupt_rejects_mismatched_response(self): + """A response for another interrupt must not start a new graph run.""" + pending_id = f"{STATE_KEY_LONG_RUNNING_PREFIX}approval:pending" + response = FunctionResponse( + id=f"{STATE_KEY_LONG_RUNNING_PREFIX}approval:stale", + name="approval", + response={"approved": True}, + ) + graph = _FakeCompiledGraph(items=[]) + agent = _new_graph_agent(graph) + session = _new_session(state={ + STATE_KEY_PENDING_INTERRUPT: True, + STATE_KEY_PENDING_INTERRUPT_ID: pending_id, + }, ) + ctx = _new_invocation_context( + agent, + session, + user_content=Content(role=ROLE_USER, parts=[Part(function_response=response)]), + ) + + events = [event async for event in agent.run_async(ctx)] + + assert len(events) == 1 + assert events[0].is_error() + assert events[0].error_code == GraphResumeError.error_code + assert events[0].custom_metadata == { + "reason": "interrupt_id_mismatch", + "pending_interrupt_id": pending_id, + "response_id": response.id, + } + assert graph.calls == [] + assert session.state[STATE_KEY_PENDING_INTERRUPT] is True + + async def test_run_async_failed_resume_keeps_pending_marker(self): + """Checkpoint failures after a valid response must remain retryable.""" + response = FunctionResponse( + id=f"{STATE_KEY_LONG_RUNNING_PREFIX}approval:retry", + name="approval", + response={"approved": True}, + ) + graph = _FakeCompiledGraph(error=RuntimeError("checkpoint unavailable")) + agent = _new_graph_agent(graph) + session = _new_session(state={ + STATE_KEY_PENDING_INTERRUPT: True, + STATE_KEY_PENDING_INTERRUPT_ID: response.id, + }, ) + ctx = _new_invocation_context( + agent, + session, + user_content=Content(role=ROLE_USER, parts=[Part(function_response=response)]), + ) + + events = [event async for event in agent.run_async(ctx)] + + assert events[-1].actions.state_delta["phase"] == "error" + assert session.state[STATE_KEY_PENDING_INTERRUPT] is True + assert session.state[STATE_KEY_PENDING_INTERRUPT_ID] == response.id + async def test_run_async_reports_stream_errors_in_completion_event(self): """Stream errors should surface in graph execution completion metadata.""" graph = _FakeCompiledGraph(error=RuntimeError("stream failed")) diff --git a/tests/trpc_agent_dsl/graph/test_memory_saver.py b/tests/trpc_agent_dsl/graph/test_memory_saver.py index a099243a7..86471cb1d 100644 --- a/tests/trpc_agent_dsl/graph/test_memory_saver.py +++ b/tests/trpc_agent_dsl/graph/test_memory_saver.py @@ -8,13 +8,14 @@ from types import SimpleNamespace from unittest.mock import AsyncMock -import pytest from langgraph.checkpoint.base import empty_checkpoint from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_CHECKPOINTS from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_CHECKPOINT_BLOBS from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_CHECKPOINT_WRITES +from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_PENDING_INTERRUPT from trpc_agent_sdk.dsl.graph._memory_saver import MemorySaver from trpc_agent_sdk.dsl.graph._memory_saver import has_graph_internal_checkpoint_state +from trpc_agent_sdk.dsl.graph._memory_saver import has_graph_resume_state from trpc_agent_sdk.dsl.graph._memory_saver import strip_graph_internal_checkpoint_state @@ -39,6 +40,36 @@ def test_has_and_strip_graph_internal_checkpoint_state(self): assert stripped == {"visible": "keep"} assert STATE_KEY_CHECKPOINTS in state + def test_has_graph_resume_state_only_tracks_outstanding_interrupt(self): + """Resume detection must key off the outstanding-interrupt marker, not + checkpoint storage. A graph that ran to completion still carries + checkpoint keys; treating those as "resumable" would preserve the + session from cleanup forever and suppress client state sync (the + original bug). Only an outstanding interrupt should count. + """ + # Empty / no marker. + assert has_graph_resume_state({}) is False + + # Completed graph: checkpoint keys linger but no pending interrupt. + completed = { + STATE_KEY_CHECKPOINTS: { + "t": {} + }, + STATE_KEY_CHECKPOINT_BLOBS: { + "t": {} + }, + STATE_KEY_CHECKPOINT_WRITES: { + "t": {} + }, + } + assert has_graph_resume_state(completed) is False + + # Marker reset to False on resume must not count. + assert has_graph_resume_state({STATE_KEY_PENDING_INTERRUPT: False}) is False + + # Only an outstanding interrupt counts as resumable. + assert has_graph_resume_state({STATE_KEY_PENDING_INTERRUPT: True}) is True + class TestMemorySaverStorage: """Tests for MemorySaver public put/get/list/write/delete behavior.""" diff --git a/tests/trpc_agent_dsl/graph/test_node_action_agent.py b/tests/trpc_agent_dsl/graph/test_node_action_agent.py index 588ae13c1..40bccbabf 100644 --- a/tests/trpc_agent_dsl/graph/test_node_action_agent.py +++ b/tests/trpc_agent_dsl/graph/test_node_action_agent.py @@ -5,26 +5,30 @@ # tRPC-Agent-Python is licensed under Apache-2.0. """Execution-path tests for AgentNodeAction.""" +import json from typing import Any import pytest -from google.genai.types import Content -from google.genai.types import Part -from trpc_agent_sdk.dsl.graph._callbacks import NodeCallbackContext -from trpc_agent_sdk.dsl.graph._callbacks import NodeCallbacks -from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_LAST_RESPONSE -from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_MESSAGES -from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_NODE_RESPONSES -from trpc_agent_sdk.dsl.graph._constants import STATE_KEY_USER_INPUT -from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_ACK -from trpc_agent_sdk.dsl.graph._constants import STREAM_KEY_EVENT -from trpc_agent_sdk.dsl.graph._event_writer import AsyncEventWriter -from trpc_agent_sdk.dsl.graph._event_writer import EventWriter +from google.genai.types import Content, Part + +from trpc_agent_sdk.dsl.graph._callbacks import NodeCallbackContext, NodeCallbacks +from trpc_agent_sdk.dsl.graph._constants import ( + STATE_KEY_LAST_RESPONSE, + STATE_KEY_MESSAGES, + STATE_KEY_NODE_RESPONSES, + STATE_KEY_PENDING_AGENT_NODE_HITL, + STATE_KEY_USER_INPUT, + STREAM_KEY_ACK, + STREAM_KEY_EVENT, +) +from trpc_agent_sdk.dsl.graph._event_writer import AsyncEventWriter, EventWriter from trpc_agent_sdk.dsl.graph._node_action._agent import AgentNodeAction from trpc_agent_sdk.dsl.graph._node_config import NodeConfig +from trpc_agent_sdk.dsl.graph._state_graph import StateGraph from trpc_agent_sdk.events import Event from trpc_agent_sdk.sessions import Session from trpc_agent_sdk.types import EventActions +from trpc_agent_sdk.types import State class _AckingWriter: @@ -82,6 +86,13 @@ def __init__(self, agent, session: Session, branch: str = "root"): self.callback_state = None self.override_messages = None + @property + def state(self) -> State: + # Mirror InvocationContext.state: a delta-aware view over session.state. + if self.callback_state is None: + self.callback_state = State(value=self.session.state, delta=self.event_actions.state_delta) + return self.callback_state + def model_copy(self, update: dict[str, Any], deep: bool = False): del deep clone = _FakeInvocationContext(self.agent, self.session, self.branch) @@ -98,6 +109,7 @@ def _build_action( ctx: _FakeInvocationContext | None = None, callbacks: NodeCallbacks | None = None, isolated_messages: bool = False, + history_scope: str | None = None, input_from_last_response: bool = False, event_scope: str | None = None, input_mapper=None, @@ -127,6 +139,7 @@ def _build_action( callback_ctx=NodeCallbackContext(node_id="node-1"), callbacks=callbacks, isolated_messages=isolated_messages, + history_scope=history_scope, input_from_last_response=input_from_last_response, event_scope=event_scope, input_mapper=input_mapper, @@ -368,6 +381,128 @@ async def test_execute_builds_child_history_respecting_isolated_messages(self): isolated_texts = [event.get_text() for event in isolated_agent.calls[0].session.events if event.content] assert isolated_texts[:1] == ["next"] + async def test_execute_branch_history_inherits_only_the_same_child_branch(self): + same_branch = Event( + invocation_id="inv-1", + author="child", + branch="root.child", + content=Content(role="model", parts=[Part.from_text(text="same")]), + ) + nested_branch = Event( + invocation_id="inv-1", + author="member", + branch="root.child.member", + content=Content(role="model", parts=[Part.from_text(text="nested")]), + ) + other_branch = Event( + invocation_id="inv-1", + author="other", + branch="root.other", + content=Content(role="model", parts=[Part.from_text(text="other")]), + ) + child_event = Event( + invocation_id="inv-2", + author="child", + content=Content(role="model", parts=[Part.from_text(text="done")]), + ) + agent = _ScriptedAgent("child", [[child_event]]) + ctx = _FakeInvocationContext( + agent, + _session_with_events(same_branch, nested_branch, other_branch), + branch="root", + ) + action, _ = _build_action( + agent, + NodeConfig(name="node-1"), + ctx=ctx, + isolated_messages=True, + history_scope="branch", + ) + + await action.execute({STATE_KEY_USER_INPUT: "next"}) + + texts = [event.get_text() for event in agent.calls[0].session.events if event.content] + assert texts[:3] == ["same", "nested", "next"] + assert "other" not in texts + assert agent.calls[0].session.state[STATE_KEY_MESSAGES] == [] + json.dumps(agent.calls[0].session.state) + + async def test_explicit_history_scope_overrides_legacy_isolated_flag(self): + existing = Event( + invocation_id="inv-1", + author="user", + branch="root.other", + content=Content(role="user", parts=[Part.from_text(text="history")]), + ) + child_event = Event( + invocation_id="inv-2", + author="child", + content=Content(role="model", parts=[Part.from_text(text="done")]), + ) + + all_agent = _ScriptedAgent("child", [[child_event]]) + all_ctx = _FakeInvocationContext(all_agent, _session_with_events(existing), branch="root") + all_action, _ = _build_action( + all_agent, + NodeConfig(name="node-1"), + ctx=all_ctx, + isolated_messages=True, + history_scope="all", + ) + await all_action.execute({STATE_KEY_USER_INPUT: "next"}) + all_texts = [event.get_text() for event in all_agent.calls[0].session.events if event.content] + assert all_texts[:2] == ["history", "next"] + + none_agent = _ScriptedAgent("child", [[child_event]]) + none_ctx = _FakeInvocationContext(none_agent, _session_with_events(existing), branch="root") + none_action, _ = _build_action( + none_agent, + NodeConfig(name="node-1"), + ctx=none_ctx, + isolated_messages=False, + history_scope="none", + ) + await none_action.execute({STATE_KEY_USER_INPUT: "next"}) + none_texts = [event.get_text() for event in none_agent.calls[0].session.events if event.content] + assert none_texts[:1] == ["next"] + assert none_agent.calls[0].session.state[STATE_KEY_MESSAGES] == [] + + async def test_all_history_scope_keeps_full_history_during_pending_hitl(self): + same_branch = Event( + invocation_id="inv-1", + author="child", + branch="root.child", + content=Content(role="model", parts=[Part.from_text(text="same")]), + ) + other_branch = Event( + invocation_id="inv-1", + author="other", + branch="root.other", + content=Content(role="model", parts=[Part.from_text(text="other")]), + ) + session = _session_with_events(same_branch, other_branch) + session.state[STATE_KEY_PENDING_AGENT_NODE_HITL] = {"node_id": "node-1"} + agent = _ScriptedAgent("child", [[]]) + ctx = _FakeInvocationContext(agent, session, branch="root") + action, _ = _build_action( + agent, + NodeConfig(name="node-1"), + ctx=ctx, + isolated_messages=False, + history_scope="all", + ) + + events = action._build_child_events(ctx, "next", "root.child") + + assert [event.get_text() for event in events] == ["same", "other", "next"] + + async def test_invalid_history_scope_fails_when_adding_agent_node(self): + graph = StateGraph(dict) + agent = _ScriptedAgent("child", [[]]) + + with pytest.raises(ValueError, match="history_scope"): + graph.add_agent_node("child", agent, history_scope="invalid") + async def test_execute_ignores_graph_events_for_state_accumulation(self): """Graph lifecycle events should not override state-derived response values.""" graph_event = Event( @@ -392,3 +527,47 @@ async def test_execute_ignores_graph_events_for_state_accumulation(self): result = await action.execute({}) assert result[STATE_KEY_LAST_RESPONSE] == "final" + + +class TestConcurrentHitlGuard: + """The single-slot pending-HITL bridge must reject concurrent nodes.""" + + def _action_with_state(self, initial_state: dict): + agent = _ScriptedAgent("child", [[]]) + session = _session_with_events() + session.state.update(initial_state) + ctx = _FakeInvocationContext(agent, session) + action, _ = _build_action(agent, NodeConfig(name="node-1"), ctx=ctx) + return action, ctx + + def test_rejects_pending_owned_by_another_node(self): + """A second node interrupting while another node is pending must fail + loudly instead of silently overwriting the first node's resume slot.""" + action, ctx = self._action_with_state( + {STATE_KEY_PENDING_AGENT_NODE_HITL: { + "node_id": "other-node", + "current": {} + }}) + with pytest.raises(RuntimeError, match="Concurrent HITL"): + action._reject_concurrent_pending_hitl(ctx) + + def test_allows_when_absent(self): + """No pending slot → first HITL round proceeds.""" + action, ctx = self._action_with_state({}) + action._reject_concurrent_pending_hitl(ctx) # must not raise + + def test_allows_own_pending_for_multi_round_resume(self): + """The same node re-interrupting across rounds is legitimate.""" + action, ctx = self._action_with_state({STATE_KEY_PENDING_AGENT_NODE_HITL: {"node_id": "node-1", "current": {}}}) + action._reject_concurrent_pending_hitl(ctx) # must not raise + + def test_detects_delta_written_in_same_superstep(self): + """A sibling node's pending written to the delta (not yet committed to + session.state) is still visible via ctx.state and must be rejected.""" + action, ctx = self._action_with_state({}) + ctx.event_actions.state_delta[STATE_KEY_PENDING_AGENT_NODE_HITL] = { + "node_id": "sibling", + "current": {}, + } + with pytest.raises(RuntimeError, match="Concurrent HITL"): + action._reject_concurrent_pending_hitl(ctx) diff --git a/trpc_agent_sdk/dsl/graph/__init__.py b/trpc_agent_sdk/dsl/graph/__init__.py index 360665639..c88c07c6f 100644 --- a/trpc_agent_sdk/dsl/graph/__init__.py +++ b/trpc_agent_sdk/dsl/graph/__init__.py @@ -94,15 +94,19 @@ from ._constants import STATE_KEY_STEP_NUMBER from ._constants import STATE_KEY_TOOL_CALLBACKS from ._constants import STATE_KEY_USER_INPUT +from ._constants import is_graph_internal_state_key from ._constants import is_unsafe_state_key from ._event_writer import AsyncEventWriter from ._event_writer import EventWriter from ._event_writer import EventWriterBase +from ._exceptions import GraphResumeError from ._graph_agent import GraphAgent +from ._history import HistoryScope from ._interrupt import interrupt from ._memory_saver import MemorySaver from ._memory_saver import MemorySaverOption from ._memory_saver import has_graph_internal_checkpoint_state +from ._memory_saver import has_graph_resume_state from ._memory_saver import strip_graph_internal_checkpoint_state from ._node_config import NodeConfig from ._state import State @@ -141,15 +145,19 @@ "STATE_KEY_SESSION", "STATE_KEY_STEP_NUMBER", "STATE_KEY_TOOL_CALLBACKS", + "is_graph_internal_state_key", "is_unsafe_state_key", "AsyncEventWriter", "EventWriter", "EventWriterBase", + "GraphResumeError", "GraphAgent", + "HistoryScope", "interrupt", "MemorySaver", "MemorySaverOption", "has_graph_internal_checkpoint_state", + "has_graph_resume_state", "strip_graph_internal_checkpoint_state", "NodeConfig", "State", diff --git a/trpc_agent_sdk/dsl/graph/_constants.py b/trpc_agent_sdk/dsl/graph/_constants.py index c2d04072b..300319f4f 100644 --- a/trpc_agent_sdk/dsl/graph/_constants.py +++ b/trpc_agent_sdk/dsl/graph/_constants.py @@ -13,6 +13,8 @@ >>> messages = state.get(STATE_KEY_MESSAGES, []) """ +from typing import Any + # ============================================================================= # Core State Keys # ============================================================================= @@ -148,6 +150,11 @@ STATE_KEY_PENDING_INTERRUPT_AUTHOR = "_trpc_graph_pending_interrupt_author" STATE_KEY_PENDING_INTERRUPT_BRANCH = "_trpc_graph_pending_interrupt_branch" +# Pending child-agent HITL context owned by AgentNodeAction. This bridges a +# child LongRunningEvent into a parent GraphAgent interrupt and supports +# replaying multiple clarification rounds in the same graph node. +STATE_KEY_PENDING_AGENT_NODE_HITL = "_trpc_graph_pending_agent_node_hitl" + # Prefix for synthetic function call IDs used by graph interrupt bridge STATE_KEY_LONG_RUNNING_PREFIX = "__trpc_graph_long_running__" @@ -179,6 +186,7 @@ STATE_KEY_PENDING_INTERRUPT_ID, STATE_KEY_PENDING_INTERRUPT_AUTHOR, STATE_KEY_PENDING_INTERRUPT_BRANCH, + STATE_KEY_PENDING_AGENT_NODE_HITL, }) @@ -192,3 +200,30 @@ def is_unsafe_state_key(key: str) -> bool: True if the key is unsafe """ return key in UNSAFE_STATE_KEYS + + +# Reserved prefix for keys owned by the GraphAgent runtime and persisted to +# Session.state (LangGraph checkpoints, pending interrupt / HITL markers). +# These are backend continuation tokens: they must never be emitted to external +# clients (AG-UI state snapshots/deltas) nor accepted back as inbound state +# patches, otherwise a client echo would clobber the checkpoint and restart the +# graph from START. Only GraphAgent-internal keys use this prefix; other +# framework-internal state keys are owned by their modules and managed there. +GRAPH_INTERNAL_STATE_PREFIX = "_trpc_graph_" + + +def is_graph_internal_state_key(key: Any) -> bool: + """Whether a Session.state key is owned by the GraphAgent runtime. + + GraphAgent-internal keys (``_trpc_graph_*``: LangGraph checkpoints and + pending interrupt / HITL markers) are backend continuation tokens that + external clients neither need to see nor are allowed to overwrite. + + Args: + key: A candidate state key. + + Returns: + True when the key is GraphAgent-internal and must be filtered at the + client boundary (outbound snapshots/deltas and inbound state patches). + """ + return isinstance(key, str) and key.startswith(GRAPH_INTERNAL_STATE_PREFIX) diff --git a/trpc_agent_sdk/dsl/graph/_events/_builder.py b/trpc_agent_sdk/dsl/graph/_events/_builder.py index 9ecd19555..f939c0401 100644 --- a/trpc_agent_sdk/dsl/graph/_events/_builder.py +++ b/trpc_agent_sdk/dsl/graph/_events/_builder.py @@ -105,6 +105,24 @@ def branch(self) -> str: # Internal Helpers # ========================================================================= + @staticmethod + def _display_name(node_description: Optional[str], node_id: str) -> str: + """Resolve a human-readable display name for a node. + + Falls back to ``node_id`` when ``node_description`` is empty or + whitespace-only. + + Args: + node_description: Optional description supplied by the graph author. + node_id: The node identifier used as fallback. + + Returns: + The display name to show in event text. + """ + if node_description and node_description.strip(): + return node_description.strip() + return node_id + def _build_event( self, text: str, @@ -185,9 +203,10 @@ def node_start( # Store metadata in state_delta state_delta: dict[str, Any] = {} _store_metadata(state_delta, METADATA_KEY_NODE, metadata) + display_name = self._display_name(node_description, node_id) return self._build_event( - text=f"Starting node: {node_id}", + text=f"Starting node: {display_name}", state_delta=state_delta, partial=True, author_override=self._author or node_id, @@ -227,9 +246,10 @@ def node_complete( # Store metadata in state_delta state_delta: dict[str, Any] = {} _store_metadata(state_delta, METADATA_KEY_NODE, metadata) + display_name = self._display_name(node_description, node_id) return self._build_event( - text=f"Completed node: {node_id} ({duration_ms:.1f}ms)", + text=f"Completed node: {display_name} ({duration_ms:.1f}ms)", state_delta=state_delta, author_override=self._author or node_id, object_type=GraphEventType.GRAPH_NODE_COMPLETE, @@ -266,7 +286,8 @@ def node_error( _store_metadata(state_delta, METADATA_KEY_NODE, metadata) # Build error message - text = f"Error in node {node_id}: {error}" + display_name = self._display_name(node_description, node_id) + text = f"Error in node {display_name}: {error}" return self._build_event( text=text, diff --git a/trpc_agent_sdk/dsl/graph/_exceptions.py b/trpc_agent_sdk/dsl/graph/_exceptions.py new file mode 100644 index 000000000..844bfcacd --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_exceptions.py @@ -0,0 +1,51 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Graph runtime exceptions.""" + +from __future__ import annotations + + +class GraphResumeError(RuntimeError): + """Raised when a paused graph receives an invalid resume input. + + A pending interrupt is an exclusive execution state: the next invocation + must answer that interrupt. Falling back to a fresh graph input can replay + nodes with external side effects, so callers receive a stable, typed error + instead. + """ + + error_code = "graph_resume_failed" + + def __init__( + self, + reason: str, + *, + pending_interrupt_id: str | None = None, + response_id: str | None = None, + ) -> None: + self.reason = reason + self.pending_interrupt_id = pending_interrupt_id + self.response_id = response_id + super().__init__(self._message()) + + def _message(self) -> str: + if self.reason == "missing_function_response": + return "Graph is waiting for an interrupt response, but the current input is not a FunctionResponse." + if self.reason == "interrupt_id_mismatch": + return ("Graph interrupt response ID does not match the pending interrupt " + f"(pending={self.pending_interrupt_id!r}, response={self.response_id!r}).") + if self.reason == "invalid_interrupt_id": + return f"Graph interrupt response ID is invalid: {self.response_id!r}." + return f"Graph resume failed: {self.reason}." + + def get_custom_metadata(self) -> dict[str, str]: + """Return protocol-safe diagnostics for event translators.""" + metadata = {"reason": self.reason} + if self.pending_interrupt_id: + metadata["pending_interrupt_id"] = self.pending_interrupt_id + if self.response_id: + metadata["response_id"] = self.response_id + return metadata diff --git a/trpc_agent_sdk/dsl/graph/_graph_agent.py b/trpc_agent_sdk/dsl/graph/_graph_agent.py index a3e1eb0c8..22bb58ca3 100644 --- a/trpc_agent_sdk/dsl/graph/_graph_agent.py +++ b/trpc_agent_sdk/dsl/graph/_graph_agent.py @@ -60,7 +60,9 @@ from ._constants import STREAM_KEY_EVENT from ._constants import is_unsafe_state_key from ._events import EventBuilder +from ._exceptions import GraphResumeError from ._memory_saver import has_graph_internal_checkpoint_state +from ._memory_saver import has_graph_resume_state from ._memory_saver import strip_graph_internal_checkpoint_state from ._state_graph import CompiledStateGraph @@ -167,13 +169,31 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, interrupted = False resume_command = self._extract_resume_command(ctx) + pending_resume = has_graph_resume_state(dict(ctx.session.state or {})) + if pending_resume and resume_command is None: + response = self._current_function_response(ctx) + pending_id = ctx.session.state.get(STATE_KEY_PENDING_INTERRUPT_ID) + resume_error = GraphResumeError( + "missing_function_response" if response is None else "interrupt_id_mismatch", + pending_interrupt_id=pending_id if isinstance(pending_id, str) else None, + response_id=(response.id if response is not None and isinstance(response.id, str) else None), + ) + yield Event( + invocation_id=ctx.invocation_id, + author=self.name, + branch=ctx.branch, + error_code=resume_error.error_code, + error_message=str(resume_error), + custom_metadata=resume_error.get_custom_metadata(), + ) + return if resume_command is not None: logger.debug(f"[{self.name}] Resuming graph from pending interrupt") - self._clear_pending_interrupt_state(ctx) graph_input: GraphInput = resume_command else: graph_input = initial_state + resume_accepted = False try: # Execute with stream_mode=["updates", "custom"] # "updates" is required for state to propagate between nodes @@ -183,6 +203,13 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, runnable_config, stream_mode=["updates", "custom"], ): + # Do not consume the durable pending marker until LangGraph has + # accepted the resume command and produced its first chunk. If + # checkpoint loading fails, the caller can safely retry the + # same interrupt instead of losing the recovery boundary. + if resume_command is not None and not resume_accepted: + self._clear_pending_interrupt_state(ctx) + resume_accepted = True # Cancellation checkpoint at each iteration await ctx.raise_if_cancelled() @@ -240,6 +267,11 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, yield chunk_event # Cancellation checkpoint after yielding events await ctx.raise_if_cancelled() + if resume_command is not None and not resume_accepted: + # A valid graph may complete without emitting an update. The + # astream call still accepted the resume command successfully. + self._clear_pending_interrupt_state(ctx) + resume_accepted = True except RunLimitException: raise except Exception as e: @@ -269,21 +301,35 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, logger.debug(f"[{self.name}] Graph execution completed in {step_count} steps") yield completion_event - def _extract_resume_command(self, ctx: InvocationContext) -> Optional[Command]: - """Build resume command when the latest user event is a function response.""" - events = getattr(ctx.session, "events", []) - if not events: - return None + @staticmethod + def _current_function_response(ctx: InvocationContext) -> Optional[FunctionResponse]: + """Return the response owned by this invocation. - last_event = events[-1] - if last_event.author != ROLE_USER: + ``ctx.user_content`` is the authoritative Runner input. The session-tail + fallback preserves compatibility with direct GraphAgent callers that + construct an InvocationContext without populating ``user_content``. + """ + user_content = getattr(ctx, "user_content", None) + if user_content is not None: + for part in getattr(user_content, "parts", []) or []: + if part.function_response is not None: + return part.function_response return None - function_responses = last_event.get_function_responses() - if not function_responses: - return None + events = getattr(ctx.session, "events", []) + if events: + last_event = events[-1] + if last_event.author == ROLE_USER: + function_responses = last_event.get_function_responses() + if function_responses: + return function_responses[0] + return None - function_response = function_responses[0] + def _extract_resume_command(self, ctx: InvocationContext) -> Optional[Command]: + """Build a resume command from the current invocation response.""" + function_response = self._current_function_response(ctx) + if function_response is None: + return None function_response_id = function_response.id if not isinstance(function_response_id, str) or not function_response_id: return None @@ -296,7 +342,7 @@ def _extract_resume_command(self, ctx: InvocationContext) -> Optional[Command]: if not function_response_id.startswith(STATE_KEY_LONG_RUNNING_PREFIX): return None - interrupt_id = function_response_id[len(STATE_KEY_LONG_RUNNING_PREFIX):] + interrupt_id = function_response_id.removeprefix(STATE_KEY_LONG_RUNNING_PREFIX) if not interrupt_id: return None @@ -359,7 +405,7 @@ def _create_interrupt_events( if isinstance(interrupt.value, dict): interrupt_response = interrupt.value else: - interrupt_response = {"desicion": interrupt.value} + interrupt_response = {"decision": interrupt.value} function_response = FunctionResponse( id=function_call.id, @@ -393,7 +439,11 @@ def _build_interrupt_function(self, interrupt: Interrupt) -> tuple[str, str, dic function_call_id = f"{STATE_KEY_LONG_RUNNING_PREFIX}{interrupt_id}" raw_args = interrupt.value - if isinstance(raw_args, dict): + if isinstance(raw_args, dict) and raw_args.get("_trpc_agent_node_hitl") is True: + function_name = str(raw_args.get("toolName") or function_name) + visible_args = raw_args.get("arguments") + function_args = visible_args if isinstance(visible_args, dict) else {} + elif isinstance(raw_args, dict): function_args = {str(key): value for key, value in raw_args.items()} else: function_args = {"value": raw_args} diff --git a/trpc_agent_sdk/dsl/graph/_history.py b/trpc_agent_sdk/dsl/graph/_history.py new file mode 100644 index 000000000..e728182ce --- /dev/null +++ b/trpc_agent_sdk/dsl/graph/_history.py @@ -0,0 +1,25 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Agent-node history inheritance policies.""" + +from typing import Literal + +HistoryScope = Literal["none", "branch", "all"] + +_HISTORY_SCOPES = frozenset({"none", "branch", "all"}) + + +def resolve_history_scope( + history_scope: HistoryScope | None, + *, + isolated_messages: bool, +) -> HistoryScope: + """Resolve the public policy while preserving the legacy boolean default.""" + if history_scope is None: + return "none" if isolated_messages else "all" + if history_scope not in _HISTORY_SCOPES: + raise ValueError("history_scope must be one of: none, branch, all") + return history_scope diff --git a/trpc_agent_sdk/dsl/graph/_memory_saver.py b/trpc_agent_sdk/dsl/graph/_memory_saver.py index b3aaa04bf..2ba79cfbe 100644 --- a/trpc_agent_sdk/dsl/graph/_memory_saver.py +++ b/trpc_agent_sdk/dsl/graph/_memory_saver.py @@ -34,6 +34,7 @@ from ._constants import STATE_KEY_CHECKPOINTS from ._constants import STATE_KEY_CHECKPOINT_BLOBS from ._constants import STATE_KEY_CHECKPOINT_WRITES +from ._constants import STATE_KEY_PENDING_INTERRUPT _INTERNAL_CHECKPOINT_KEYS = frozenset({ STATE_KEY_CHECKPOINTS, @@ -59,6 +60,23 @@ def has_graph_internal_checkpoint_state(state: dict[str, Any]) -> bool: return any(k in state for k in _INTERNAL_CHECKPOINT_KEYS) +def has_graph_resume_state(state: dict[str, Any]) -> bool: + """Whether the session is paused mid-graph waiting for a human response. + + This is intentionally scoped to the *pending-interrupt* marker only, NOT + to the presence of checkpoint storage keys. Checkpoint keys + (``_trpc_graph_checkpoint*``) persist after a graph runs to completion, so + keying off them would report every session that ever ran a GraphAgent as + "resumable" forever — wrongly preserving it from cleanup and suppressing + client state sync. ``STATE_KEY_PENDING_INTERRUPT`` is set to True only while + an interrupt is outstanding and reset to False on resume, so it precisely + identifies a session that must not be expired/deleted mid-HITL. + """ + if not state: + return False + return state.get(STATE_KEY_PENDING_INTERRUPT) is True + + def strip_graph_internal_checkpoint_state(state: dict[str, Any]) -> dict[str, Any]: """Return a shallow copy of state without internal checkpoint storage keys.""" if not state: diff --git a/trpc_agent_sdk/dsl/graph/_node_action/_agent.py b/trpc_agent_sdk/dsl/graph/_node_action/_agent.py index 6ce6378d3..aa5b536f5 100644 --- a/trpc_agent_sdk/dsl/graph/_node_action/_agent.py +++ b/trpc_agent_sdk/dsl/graph/_node_action/_agent.py @@ -6,17 +6,22 @@ """Agent node action executor.""" import json +from datetime import date +from datetime import datetime from typing import Any from typing import Callable from typing import Optional +from langgraph.errors import GraphInterrupt from trpc_agent_sdk.agents import BaseAgent from trpc_agent_sdk.agents import LlmAgent from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.events import Event +from trpc_agent_sdk.events import LongRunningEvent from trpc_agent_sdk.exceptions import RunLimitException from trpc_agent_sdk.types import Content from trpc_agent_sdk.types import EventActions +from trpc_agent_sdk.types import FunctionResponse from trpc_agent_sdk.types import Part from .._callbacks import NodeCallbackContext @@ -24,15 +29,45 @@ from .._constants import STATE_KEY_LAST_RESPONSE from .._constants import STATE_KEY_MESSAGES from .._constants import STATE_KEY_NODE_RESPONSES +from .._constants import STATE_KEY_PENDING_AGENT_NODE_HITL from .._constants import STATE_KEY_USER_INPUT from .._event_writer import AsyncEventWriter from .._event_writer import EventWriter +from .._history import HistoryScope +from .._history import resolve_history_scope +from .._interrupt import interrupt from .._node_config import NodeConfig from .._state import State from .._state_mapper import SubgraphResult from ._base import BaseNodeAction +def _json_safe(value: Any) -> Any: + """Recursively convert a value to a JSON-serializable form. + + ``child_state`` is persisted to Session.state (e.g. via SqlSessionService + / DynamicJSON) which serializes with plain ``json.dumps`` and no + ``default=str`` — non-JSON values such as pydantic models, datetime or + sets would raise at persist time and break the whole HITL round. This + helper normalises such values so the pending HITL payload can always be + persisted. + """ + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(v) for v in value] + if isinstance(value, (datetime, date)): + return value.isoformat() + if hasattr(value, "model_dump"): + try: + return _json_safe(value.model_dump(mode="json")) + except (AttributeError, TypeError, ValueError): + pass + return str(value) + + class AgentNodeAction(BaseNodeAction): """Executes sub-agent invocation with isolated state. @@ -55,6 +90,7 @@ def __init__( callback_ctx: Optional[NodeCallbackContext] = None, callbacks: Optional[NodeCallbacks] = None, isolated_messages: bool = False, + history_scope: HistoryScope | None = None, input_from_last_response: bool = False, event_scope: Optional[str] = None, input_mapper: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None, @@ -83,7 +119,10 @@ def __init__( self.node_config = node_config self.callback_ctx = callback_ctx self.callbacks = callbacks - self.isolated_messages = isolated_messages + self.history_scope = resolve_history_scope( + history_scope, + isolated_messages=isolated_messages, + ) self.input_from_last_response = input_from_last_response self.event_scope = event_scope self.input_mapper = input_mapper @@ -124,16 +163,41 @@ async def execute(self, state: State) -> dict[str, Any]: child_branch = f"{parent_ctx.branch}.{child_scope}" if parent_ctx.branch else child_scope child_user_input = child_state.get(STATE_KEY_USER_INPUT, "") + pending_hitl = self._get_pending_hitl(parent_ctx) + resume_content: Optional[Content] = None + if pending_hitl is not None: + completed_rounds = pending_hitl.get("completed", []) + for completed in completed_rounds if isinstance(completed_rounds, list) else []: + if isinstance(completed, dict): + interrupt(self._interrupt_payload(completed)) + + current_round = pending_hitl.get("current") + if not isinstance(current_round, dict): + raise RuntimeError(f"Agent node '{self.node_id}' has invalid pending HITL state.") + human_response = interrupt(self._interrupt_payload(current_round)) + resume_content = self._resume_content(current_round, human_response) + saved_child_state = current_round.get("child_state") + if isinstance(saved_child_state, dict): + child_state = dict(saved_child_state) + child_session = parent_ctx.session.model_copy(deep=True) child_session.state = dict(child_state) - child_events = self._build_child_events(parent_ctx, child_user_input, child_branch) + child_events = self._build_child_events( + parent_ctx, + child_user_input, + child_branch, + resume_content=resume_content, + ) if hasattr(child_session, "events"): child_session.events = child_events - if self.isolated_messages: + if self.history_scope in {"none", "branch"}: + # Persistent Session state must remain JSON-serializable. Content + # models live in the event log; GraphAgent rebuilds model input from + # the already-filtered child events when needed. child_session.state[STATE_KEY_MESSAGES] = [] - child_user_content = None - if isinstance(child_user_input, str) and child_user_input: + child_user_content = resume_content + if child_user_content is None and isinstance(child_user_input, str) and child_user_input: child_user_content = Content( role="user", parts=[Part.from_text(text=child_user_input)], @@ -165,11 +229,36 @@ async def execute(self, state: State) -> dict[str, Any]: transfer_requested = False child_ctx.agent = current_agent - async for event in current_agent.run_async(child_ctx): + agent_stream = current_agent.run_async(child_ctx) + async for event in agent_stream: await self._run_agent_event_callbacks(state, event) + if isinstance(event, LongRunningEvent): + self._reject_concurrent_pending_hitl(parent_ctx) + current_round = self._pending_round(event, child_ctx, final_state) + completed_rounds: list[dict[str, Any]] = [] + if pending_hitl is not None: + previous = pending_hitl.get("completed", []) + if isinstance(previous, list): + completed_rounds.extend(item for item in previous if isinstance(item, dict)) + previous_current = pending_hitl.get("current") + if isinstance(previous_current, dict): + completed_rounds.append({ + key: value + for key, value in previous_current.items() if key != "child_state" + }) + parent_ctx.state[STATE_KEY_PENDING_AGENT_NODE_HITL] = { + "node_id": self.node_id, + "completed": completed_rounds, + "current": current_round, + } + await agent_stream.aclose() + interrupt(self._interrupt_payload(current_round)) + if (not event.partial) and hasattr(child_session, "events"): - child_session.events.append(event.model_copy(deep=True)) + existing_ids = {item.id for item in child_session.events if getattr(item, "id", None)} + if not event.id or event.id not in existing_ids: + child_session.events.append(event.model_copy(deep=True)) if event.actions and event.actions.state_delta: delta = dict(event.actions.state_delta) @@ -245,9 +334,16 @@ async def execute(self, state: State) -> dict[str, Any]: if isinstance(candidate, str) and candidate: last_response = candidate + if pending_hitl is not None: + parent_ctx.state[STATE_KEY_PENDING_AGENT_NODE_HITL] = None + except RunLimitException: raise + except GraphInterrupt: + raise except Exception as e: + if pending_hitl is not None: + parent_ctx.state[STATE_KEY_PENDING_AGENT_NODE_HITL] = None raise RuntimeError(f"Agent node '{self.name}' execution failed: {e}") from e if last_response: @@ -293,10 +389,25 @@ def _build_child_events( parent_ctx: InvocationContext, child_user_input: Any, child_branch: str, + *, + resume_content: Optional[Content] = None, ) -> list[Event]: parent_events = getattr(parent_ctx.session, "events", []) - if self.isolated_messages: - child_events: list[Event] = [] + pending_hitl = self._get_pending_hitl(parent_ctx) + if self.history_scope == "branch": + child_events = [ + event.model_copy(deep=True) for event in parent_events + if event.branch == child_branch or str(event.branch or "").startswith(f"{child_branch}.") + ] + elif self.history_scope == "none" and pending_hitl is not None: + # Legacy isolated HITL resumes need the current child branch to + # reconstruct the pending function-call exchange. + child_events = [ + event.model_copy(deep=True) for event in parent_events + if event.branch == child_branch or str(event.branch or "").startswith(f"{child_branch}.") + ] + elif self.history_scope == "none": + child_events = [] else: child_events = [event.model_copy(deep=True) for event in parent_events] @@ -311,8 +422,105 @@ def _build_child_events( parts=[Part.from_text(text=child_user_input)], ), )) + if resume_content is not None: + child_events.append( + Event( + invocation_id=parent_ctx.invocation_id, + author="user", + branch=child_branch, + content=resume_content.model_copy(deep=True), + )) return child_events + def _get_pending_hitl(self, parent_ctx: InvocationContext) -> Optional[dict[str, Any]]: + value = parent_ctx.session.state.get(STATE_KEY_PENDING_AGENT_NODE_HITL) + if isinstance(value, dict) and value.get("node_id") == self.node_id: + return value + return None + + def _reject_concurrent_pending_hitl(self, parent_ctx: InvocationContext) -> None: + """Fail loudly if another agent node already holds an outstanding HITL. + + The pending-HITL bridge persists a single slot in state + (``STATE_KEY_PENDING_AGENT_NODE_HITL``). If two parallel (fan-out) agent + nodes interrupt within the same superstep, the second write would + silently overwrite the first node's resume context, so its subsequent + resume would rerun the child agent from scratch. Reading through + ``parent_ctx.state`` sees both the committed session state and the + delta another node just wrote in this superstep. Concurrent HITL is not + supported; serialize such nodes instead. + """ + existing = parent_ctx.state.get(STATE_KEY_PENDING_AGENT_NODE_HITL) + if isinstance(existing, dict): + other_node = existing.get("node_id") + if other_node is not None and other_node != self.node_id: + raise RuntimeError(f"Agent node '{self.node_id}' requested human-in-the-loop while node " + f"'{other_node}' already has an outstanding HITL round. Concurrent HITL " + "across parallel agent nodes is unsupported because the pending state uses " + "a single slot; serialize these nodes so at most one is pending at a time.") + + def _pending_round( + self, + event: LongRunningEvent, + child_ctx: InvocationContext, + child_state: dict[str, Any], + ) -> dict[str, Any]: + return { + "agent_name": event.author or self.agent.name, + "branch": event.branch or child_ctx.branch, + "function_call": event.function_call.model_dump(mode="json"), + "function_response": event.function_response.model_dump(mode="json"), + # child_state is persisted to Session.state; normalise it so the + # pending HITL payload survives JSON serialisation. + "child_state": _json_safe(child_state), + } + + def _interrupt_payload(self, pending_round: dict[str, Any]) -> dict[str, Any]: + function_call = pending_round.get("function_call") + function_call = function_call if isinstance(function_call, dict) else {} + arguments = function_call.get("args") + arguments = arguments if isinstance(arguments, dict) else {} + function_response = pending_round.get("function_response") + function_response = function_response if isinstance(function_response, dict) else {} + response = function_response.get("response") + response = response if isinstance(response, dict) else {} + return { + "_trpc_agent_node_hitl": True, + "nodeId": self.node_id, + "agentName": str(pending_round.get("agent_name") or self.agent.name), + "toolName": str(function_call.get("name") or "graph_interrupt"), + # Long-running tools return the UI interaction contract from the + # tool execution. The model-supplied call arguments take priority + # so the frontend always sees what the model actually requested; + # the tool result may add derived fields such as stable IDs that + # do not collide with existing argument keys. + "arguments": { + **response, + **arguments, + }, + } + + @staticmethod + def _resume_content(pending_round: dict[str, Any], human_response: Any) -> Content: + function_call = pending_round.get("function_call") + function_call = function_call if isinstance(function_call, dict) else {} + response = human_response if isinstance(human_response, dict) else {"value": human_response} + fc_id = str(function_call.get("id") or "") + if not fc_id: + raise ValueError("Cannot resume HITL round: pending function_call is missing " + "an 'id' field, which is required to match the interrupt " + "during graph replay.") + return Content( + role="user", + parts=[ + Part(function_response=FunctionResponse( + id=fc_id, + name=str(function_call.get("name") or ""), + response=response, + )) + ], + ) + async def _run_agent_event_callbacks(self, state: State, event: Event) -> None: if not self.callbacks or not self.callbacks.agent_event: return diff --git a/trpc_agent_sdk/dsl/graph/_state_graph.py b/trpc_agent_sdk/dsl/graph/_state_graph.py index 7940111a1..ff53076b4 100644 --- a/trpc_agent_sdk/dsl/graph/_state_graph.py +++ b/trpc_agent_sdk/dsl/graph/_state_graph.py @@ -48,6 +48,8 @@ from ._event_writer import EventWriter from ._memory_saver import MemorySaver from ._memory_saver import MemorySaverOption +from ._history import HistoryScope +from ._history import resolve_history_scope from ._node_action import AgentNodeAction from ._node_action import CodeNodeAction from ._node_action import KnowledgeNodeAction @@ -605,6 +607,7 @@ def add_agent_node( config: Optional[NodeConfig] = None, callbacks: Optional[NodeCallbacks] = None, isolated_messages: bool = False, + history_scope: HistoryScope | None = None, input_from_last_response: bool = False, event_scope: Optional[str] = None, input_mapper: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None, @@ -618,6 +621,9 @@ def add_agent_node( config: Common NodeConfig for the node (optional) callbacks: Lifecycle callbacks for this node (optional) isolated_messages: If True, child execution does not inherit parent message history. + history_scope: Explicit history policy: none, branch, or all. An explicit + value takes precedence over isolated_messages. When omitted, the + legacy boolean keeps its existing behavior. input_from_last_response: If True, map parent STATE_KEY_LAST_RESPONSE to child STATE_KEY_USER_INPUT. event_scope: Optional branch scope segment for child agent events. input_mapper: Function to transform parent state to child state. @@ -640,6 +646,10 @@ def add_agent_node( """ if agent is None: raise TypeError(f"Agent for node '{node_id}' must not be None.") + resolved_history_scope = resolve_history_scope( + history_scope, + isolated_messages=isolated_messages, + ) if config is None: config = NodeConfig(name=node_id) @@ -665,6 +675,7 @@ async def agent_action( callback_ctx=callback_ctx, callbacks=callbacks, isolated_messages=isolated_messages, + history_scope=resolved_history_scope, input_from_last_response=input_from_last_response, event_scope=event_scope, input_mapper=input_mapper, diff --git a/trpc_agent_sdk/models/_openai_model.py b/trpc_agent_sdk/models/_openai_model.py index 9adc9e352..2ad67a2a3 100644 --- a/trpc_agent_sdk/models/_openai_model.py +++ b/trpc_agent_sdk/models/_openai_model.py @@ -11,6 +11,8 @@ """ import base64 +import functools +import inspect import json import uuid from enum import Enum @@ -23,6 +25,7 @@ import httpx import openai +from openai.types.responses import ResponseCreateParams from pydantic import BaseModel from trpc_agent_sdk.common import check_enum @@ -51,6 +54,25 @@ from .tool_prompt import ToolPrompt +def _responses_create_parameters(client: Any) -> frozenset: + """Return the parameter names accepted by ``client.responses.create``. + + The signature of ``responses.create`` is fixed for a given OpenAI SDK + version, so the result is cached per ``responses`` resource *type* to + avoid re-inspecting on every request. Raises ``ValueError`` when the + signature cannot be introspected. + """ + return _responses_create_parameters_for_resource(type(client.responses)) + + +@functools.lru_cache(maxsize=64) +def _responses_create_parameters_for_resource(resource_type: type) -> frozenset: + try: + return frozenset(inspect.signature(resource_type.create).parameters) + except (TypeError, ValueError) as exc: + raise ValueError("Unable to determine Responses logprobs support") from exc + + class ToolCall(BaseModel): """Represents a tool call made by the model.""" @@ -110,6 +132,10 @@ class ApiParamsKey(str, Enum): PROMPT_CACHE_RETENTION = "prompt_cache_retention" +_RESPONSES_INPUT_ITEMS = "responses_input_items" +_RESPONSES_LOGPROBS_REQUEST = "_trpc_responses_logprobs_request" + + @register_model(model_name="OpenAIModel", supported_models=[r"gpt-.*", r"o1-.*", r"deepseek-.*", r"hy3-.*"]) class OpenAIModel(LLMModel): """OpenAI model implementation using the abstract model interface. @@ -131,6 +157,13 @@ class OpenAIModel(LLMModel): will be used as the base, with per-request configs overriding specific fields. Useful for maintaining consistent model behavior across multiple calls. + use_responses_api: Use ``client.responses.create`` instead of Chat Completions. + Defaults to False for backward compatibility. + responses_api_params: Optional Responses-only parameters such as ``store``, + ``reasoning``, ``include``, or ``truncation``. Typed as + the openai SDK's ``ResponseCreateParams`` and passed + through verbatim to ``responses.create``. The model, + input, and stream parameters remain managed by this class. **kwargs: Additional arguments passed to parent LLMModel class (e.g., api_key, base_url, etc.) @@ -168,6 +201,8 @@ def __init__( tool_prompt: str = "xml", generate_content_config: Optional[GenerateContentConfig] = None, http_client_provider_factory: HttpClientProviderFactory = temporary_http_client_provider_factory, + use_responses_api: bool = False, + responses_api_params: Optional[ResponseCreateParams] = None, **kwargs, ): super().__init__(model_name, filters_name, **kwargs) @@ -176,6 +211,12 @@ def __init__( # Extract OpenAI-specific config self.organization: str = kwargs.get(const.ORGANIZATION, "") self.client_args = kwargs.get(const.CLIENT_ARGS, {}) + self.use_responses_api = use_responses_api + self.responses_api_params = dict(responses_api_params or {}) + reserved_response_params = {"model", "input", "stream"}.intersection(self.responses_api_params) + if reserved_response_params: + names = ", ".join(sorted(reserved_response_params)) + raise ValueError(f"responses_api_params cannot override managed parameters: {names}") # Allow callers to inject a tuned httpx client http_client_provider_factory = http_client_provider_factory or temporary_http_client_provider_factory self._http_client_provider: BaseHttpClientProvider = http_client_provider_factory() @@ -184,6 +225,15 @@ def __init__( self.add_tools_to_prompt = add_tools_to_prompt self.tool_prompt = tool_prompt + # The Responses API uses native function tools; prompt-injected tool + # definitions (add_tools_to_prompt) are never forwarded, so tool calling + # would silently break. Fail fast at construction instead of at runtime. + if self.use_responses_api and self.add_tools_to_prompt: + raise ValueError("use_responses_api=True is incompatible with add_tools_to_prompt=True: " + "the Responses API relies on native function tools, so prompt-injected " + "tool definitions would be ignored and tool calling would silently fail. " + "Disable add_tools_to_prompt when using the Responses API.") + # Default generation config that can be overridden per request self.generate_content_config = generate_content_config # Optional hard cap for tool-response payload injected into model context. @@ -234,7 +284,7 @@ def _create_async_client(self) -> openai.AsyncOpenAI: logging.getLogger("httpx").setLevel(logging.WARNING) client_args = self.client_args.copy() - client_args['http_client'] = self._http_client_provider.create_http_client() + client_args["http_client"] = self._http_client_provider.create_http_client() return openai.AsyncOpenAI( api_key=self._api_key, @@ -320,8 +370,13 @@ def _format_messages(self, request: LlmRequest) -> List[Dict[str, Any]]: parts: list[Part] = content.parts # type: ignore conditions_iter = [ - len(parts) == 1, parts[0].text, parts[0].function_call, parts[0].function_response, - parts[0].code_execution_result, parts[0].executable_code, parts[0].inline_data + len(parts) == 1, + parts[0].text, + parts[0].function_call, + parts[0].function_response, + parts[0].code_execution_result, + parts[0].executable_code, + parts[0].inline_data, ] # Handle different content structures if all(conditions_iter): @@ -338,15 +393,25 @@ def _format_messages(self, request: LlmRequest) -> List[Dict[str, Any]]: reasoning_parts: list[str] = [] image_parts = [] tool_calls = [] + responses_input_items = [] for part in parts: # type: ignore + if part.thought: + if self.use_responses_api and part.thought_signature: + try: + raw = part.thought_signature + raw = raw.decode("utf-8") if isinstance(raw, bytes) else raw + reasoning_item = json.loads(raw) + if isinstance(reasoning_item, dict) and reasoning_item.get("type") == "reasoning": + responses_input_items.append(reasoning_item) + except (UnicodeDecodeError, json.JSONDecodeError, TypeError): + logger.warning("Ignoring invalid Responses reasoning item metadata") + # Reasoning is stripped by default, but some providers + # (e.g. Hunyuan hy3) require it to be replayed. + if part.text and self._adapter.should_preserve_reasoning_content(): + reasoning_parts.append(part.text) + continue if part.text: - if part.thought: - # Reasoning is stripped by default, but some providers - # (e.g. Hunyuan hy3) require it to be replayed. - if self._adapter.should_preserve_reasoning_content(): - reasoning_parts.append(part.text) - continue text_parts.append(part.text) elif part.inline_data and part.inline_data.mime_type: # Handle image data - convert to OpenAI vision format @@ -463,6 +528,16 @@ def _format_messages(self, request: LlmRequest) -> List[Dict[str, Any]]: elif self._adapter.should_backfill_reasoning_content(role, message): message[const.REASONING_CONTENT] = "" + if responses_input_items: + message[_RESPONSES_INPUT_ITEMS] = responses_input_items + + formatted_messages.append(message) + elif responses_input_items: + # Pure-reasoning turn: the assistant emitted only thought + # parts with no text, image, or tool call. We must still + # emit a message so that the collected Responses reasoning + # items are not silently dropped from conversation history. + message = {const.ROLE: role, const.CONTENT: "", _RESPONSES_INPUT_ITEMS: responses_input_items} formatted_messages.append(message) # Validate and fix message sequence for OpenAI compatibility @@ -518,8 +593,10 @@ def _validate_and_fix_openai_messages(self, messages: List[Dict[str, Any]]) -> L # Assistant message without tool calls if pending_tool_calls: # Need to add dummy responses for pending tool calls - logger.warning("Adding dummy tool responses for %s pending tool calls before assistant message", - len(pending_tool_calls)) + logger.warning( + "Adding dummy tool responses for %s pending tool calls before assistant message", + len(pending_tool_calls), + ) for pending_call in pending_tool_calls: dummy_response = { const.ROLE: const.TOOL, @@ -548,8 +625,11 @@ def _validate_and_fix_openai_messages(self, messages: List[Dict[str, Any]]) -> L # User or system message if pending_tool_calls: # Add dummy responses for any pending tool calls before user/system message - logger.warning("Adding dummy tool responses for %s pending tool calls before %s message", - len(pending_tool_calls), role) + logger.warning( + "Adding dummy tool responses for %s pending tool calls before %s message", + len(pending_tool_calls), + role, + ) for pending_call in pending_tool_calls: dummy_response = { const.ROLE: const.TOOL, @@ -739,8 +819,11 @@ def _process_tool_call_delta(self, tool_call_delta: dict, accumulated_tool_calls @staticmethod def _build_usage_metadata(usage_data: dict) -> GenerateContentResponseUsageMetadata: - """Build ``GenerateContentResponseUsageMetadata`` from a raw usage dict. + """Build ``GenerateContentResponseUsageMetadata`` from Chat Completions usage. + Uses the Chat Completions naming (``prompt_tokens`` / ``completion_tokens`` + / ``*_tokens_details``). Responses API payloads use different field names + and are handled by ``_build_responses_usage_metadata``. ``cache_read_input_tokens`` prefers Anthropic/LiteLLM-style top-level fields; falls back to OpenAI-style ``prompt_tokens_details.cached_tokens``. """ @@ -758,11 +841,37 @@ def _build_usage_metadata(usage_data: dict) -> GenerateContentResponseUsageMetad cache_creation_input_tokens=usage_data.get("cache_creation_input_tokens"), ) + @staticmethod + def _build_responses_usage_metadata(usage_data: dict) -> GenerateContentResponseUsageMetadata: + """Build usage metadata from a Responses API usage payload. + + The Responses API reports ``input_tokens`` / ``output_tokens`` / + ``total_tokens`` with ``input_tokens_details`` / ``output_tokens_details`` + — distinct from the Chat Completions ``prompt_tokens`` / ``completion_tokens`` + naming — so this keeps the two API dialects from bleeding into each other. + """ + output_details = usage_data.get("output_tokens_details") or {} + input_details = usage_data.get("input_tokens_details") or {} + cached_tokens = input_details.get("cached_tokens") if isinstance(input_details, dict) else None + return GenerateContentResponseUsageMetadata( + prompt_token_count=usage_data.get("input_tokens", 0), + candidates_token_count=usage_data.get("output_tokens", 0), + thoughts_token_count=output_details.get("reasoning_tokens") if isinstance(output_details, dict) else None, + total_token_count=usage_data.get("total_tokens", 0), + cache_read_input_tokens=cached_tokens, + cache_creation_input_tokens=None, + ) + def _process_usage(self, chunk_dict: dict) -> Optional[GenerateContentResponseUsageMetadata]: """Extract usage metadata from a streaming chunk dict.""" usage_data = chunk_dict.get(const.USAGE) return self._build_usage_metadata(usage_data) if usage_data is not None else None + def _process_responses_usage(self, response_dict: dict) -> Optional[GenerateContentResponseUsageMetadata]: + """Extract usage metadata from a Responses API payload.""" + usage_data = response_dict.get(const.USAGE) + return self._build_responses_usage_metadata(usage_data) if usage_data is not None else None + def _process_chunk_without_content( self, chunk_dict: dict, accumulated_tool_calls: list[dict] ) -> tuple[Optional[FinishReason], Optional[GenerateContentResponseUsageMetadata], dict[int, str]]: @@ -914,8 +1023,12 @@ def _create_complete_tool_calls(self, accumulated_tool_calls: list[dict]) -> Opt logger.warning("Generated fallback ID '%s' for tool call with missing ID", tool_call_id) thought_sig = tool_call_data.get(ToolKey.THOUGHT_SIGNATURE) or None - logger.debug("Creating tool call: id=%s, name=%s, arguments=%s", tool_call_id, - function_map[ToolKey.NAME], arguments) + logger.debug( + "Creating tool call: id=%s, name=%s, arguments=%s", + tool_call_id, + function_map[ToolKey.NAME], + arguments, + ) complete_tool_calls.append( ToolCall( id=tool_call_id, @@ -1107,7 +1220,8 @@ def _create_response_with_content(self, response_dict: dict) -> LlmResponse: tool_call = ToolCall( id=f"call_{uuid.uuid4().hex[:24]}", name=func_call.name, # type: ignore - arguments=func_call.args) # type: ignore + arguments=func_call.args, + ) # type: ignore tool_calls.append(tool_call) except Exception as ex: # pylint: disable=broad-except logger.warning("Failed to parse function calls from text content: %s", ex) @@ -1158,11 +1272,304 @@ def _create_response_with_content(self, response_dict: dict) -> LlmResponse: return LlmResponse(content=content, usage_metadata=usage, error_code=error_code, response_id=response_id) - async def _generate_single(self, - api_params: Dict, - request: LlmRequest, - http_options: Dict[str, Any] | None = None, - ctx: InvocationContext | None = None) -> LlmResponse: + @staticmethod + def _model_dump(value: Any) -> dict: + """Return a dictionary for OpenAI SDK models and test doubles.""" + if isinstance(value, dict): + return value + return value.model_dump() + + def _convert_messages_to_responses_input(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert Chat Completions messages to Responses input items.""" + input_items: List[Dict[str, Any]] = [] + for message in messages: + role = message.get(const.ROLE, const.USER) + if role == const.TOOL: + call_id = message.get(const.TOOL_CALL_ID) + if not call_id: + logger.warning("Tool message missing tool_call_id; generating a " + "unique id to avoid Responses API call_id collisions.") + call_id = f"unknown_{uuid.uuid4().hex[:8]}" + input_items.append({ + "type": "function_call_output", + "call_id": call_id, + "output": str(message.get(const.CONTENT, "")), + }) + continue + + input_items.extend(message.get(_RESPONSES_INPUT_ITEMS) or []) + + content = message.get(const.CONTENT) + if content not in (None, "", []): + if isinstance(content, list): + converted_content = [] + for item in content: + item_type = item.get("type") + if item_type == "image_url": + image = item.get("image_url") or {} + converted_content.append({ + "type": "input_image", + "image_url": image.get("url", ""), + "detail": image.get("detail", "auto"), + }) + elif item_type == "text": + converted_content.append({ + "type": "output_text" if role == const.ASSISTANT else "input_text", + "text": item.get("text", ""), + }) + else: + converted_content.append(item) + content = converted_content + input_items.append({"role": role, "content": content}) + + for tool_call in message.get(const.TOOL_CALLS, []) or []: + function = tool_call.get(ToolKey.FUNCTION, {}) + input_items.append({ + "type": "function_call", + "call_id": tool_call.get(ToolKey.ID) or f"call_{uuid.uuid4().hex[:24]}", + "name": function.get(ToolKey.NAME, ""), + "arguments": function.get(ToolKey.ARGUMENTS, "{}"), + }) + return self._reorder_responses_function_call_outputs(input_items) + + @staticmethod + def _reorder_responses_function_call_outputs(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Ensure each function_call_output follows its function_call. + + The Responses API rejects a ``function_call_output`` whose matching + ``function_call`` has not appeared earlier in the same input array. + Message assembly can place tool responses before the assistant + tool_calls message, so orphaned outputs are buffered and flushed right + after their matching function_call (or at the end if the call never + appears). Items in canonical order are returned unchanged. + """ + result: List[Dict[str, Any]] = [] + seen_calls: set[str] = set() + buffered: Dict[str, Dict[str, Any]] = {} + for item in items: + item_type = item.get("type") + call_id = item.get("call_id") + if item_type == "function_call": + result.append(item) + if isinstance(call_id, str): + seen_calls.add(call_id) + output = buffered.pop(call_id, None) + if output is not None: + result.append(output) + elif item_type == "function_call_output" and isinstance(call_id, str): + if call_id in seen_calls: + result.append(item) + else: + buffered.setdefault(call_id, item) + else: + result.append(item) + result.extend(buffered.values()) + return result + + @staticmethod + def _convert_tools_to_responses_format(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Flatten Chat Completions function definitions for Responses.""" + converted = [] + for tool in tools: + if tool.get("type") != "function": + converted.append(tool) + continue + function = dict(tool.get("function") or {}) + function["type"] = "function" + converted.append(function) + return converted + + @staticmethod + def _convert_response_format_to_responses(response_format: Dict[str, Any]) -> Dict[str, Any]: + """Convert Chat Completions response_format to Responses text config.""" + if response_format.get("type") == "json_schema": + schema = dict(response_format.get("json_schema") or {}) + return {"format": {"type": "json_schema", **schema}} + return {"format": dict(response_format)} + + def _convert_api_params_to_responses(self, api_params: Dict[str, Any]) -> Dict[str, Any]: + """Translate shared generation parameters to ``responses.create``.""" + responses_params: Dict[str, Any] = { + "model": api_params[ApiParamsKey.MODEL], + "input": self._convert_messages_to_responses_input(api_params[ApiParamsKey.MESSAGES]), + "stream": api_params[ApiParamsKey.STREAM], + } + parameter_map = { + ApiParamsKey.TEMPERATURE: "temperature", + ApiParamsKey.TOP_P: "top_p", + ApiParamsKey.TOOL_CHOICE: "tool_choice", + ApiParamsKey.PARALLEL_TOOL_CALLS: "parallel_tool_calls", + ApiParamsKey.PROMPT_CACHE_KEY: "prompt_cache_key", + ApiParamsKey.PROMPT_CACHE_RETENTION: "prompt_cache_retention", + } + for source, target in parameter_map.items(): + if source in api_params: + responses_params[target] = api_params[source] + + # These shared generation parameters have no equivalent in the + # Responses API. Forwarding them would make the OpenAI API reject the + # request with an HTTP 400, so they are intentionally not mapped; warn + # once per request so callers know the option was dropped. + unsupported_responses_params = [ + key.value for key in ( + ApiParamsKey.STOP, + ApiParamsKey.FREQUENCY_PENALTY, + ApiParamsKey.PRESENCE_PENALTY, + ApiParamsKey.SEED, + ApiParamsKey.N, + ) if key in api_params + ] + if unsupported_responses_params: + logger.warning( + "The Responses API does not support these parameters; they will be ignored: %s", + ", ".join(unsupported_responses_params), + ) + + # The OpenAI Python SDK has used more than one Responses logprobs + # shape. Keep this framework-level request private until we can inspect + # the installed client's supported parameters immediately before send. + response_logprobs = api_params.get(ApiParamsKey.LOGPROBS) + top_logprobs = api_params.get(ApiParamsKey.TOP_LOGPROBS) + if response_logprobs is not None or top_logprobs is not None: + responses_params[_RESPONSES_LOGPROBS_REQUEST] = { + "enabled": bool(response_logprobs) if response_logprobs is not None else bool(top_logprobs), + "top_logprobs": top_logprobs, + } + + max_output_tokens = api_params.get(ApiParamsKey.MAX_COMPLETION_TOKENS) or api_params.get( + ApiParamsKey.MAX_TOKENS) + if max_output_tokens: + responses_params["max_output_tokens"] = max_output_tokens + if ApiParamsKey.TOOLS in api_params: + responses_params["tools"] = self._convert_tools_to_responses_format(api_params[ApiParamsKey.TOOLS]) + if ApiParamsKey.RESPONSE_FORMAT in api_params: + responses_params["text"] = self._convert_response_format_to_responses( + api_params[ApiParamsKey.RESPONSE_FORMAT]) + + # Merge user-supplied responses_api_params. Note: the "reasoning" + # sub-dict is intentionally NOT deep-merged here because reasoning is + # injected later in _generate_async_impl (via setdefault), which + # already preserves user-supplied values. + responses_params.update(self.responses_api_params) + if responses_params.get("store") is False: + include = responses_params.get("include") + if include is None: + include = [] + elif isinstance(include, str): + include = [include] + else: + include = list(include) + if "reasoning.encrypted_content" not in include: + include.append("reasoning.encrypted_content") + responses_params["include"] = include + return responses_params + + @staticmethod + def _prepare_responses_api_params(client: Any, api_params: Dict[str, Any]) -> Dict[str, Any]: + """Adapt optional logprobs to the installed OpenAI client's capability. + + ``openai>=1.66`` exposes Responses but accepts neither of the later + logprobs parameter shapes. Newer clients may expose either a top-level + ``top_logprobs`` integer or a structured ``logprobs`` object. + """ + prepared = dict(api_params) + requested = prepared.pop(_RESPONSES_LOGPROBS_REQUEST, None) + if not requested or not requested["enabled"]: + return prepared + + supported = _responses_create_parameters(client) + if "logprobs" in supported: + prepared["logprobs"] = requested + return prepared + + top_logprobs = requested["top_logprobs"] + if "top_logprobs" in supported and top_logprobs: + prepared["top_logprobs"] = top_logprobs + return prepared + + raise ValueError("Responses logprobs requires an OpenAI SDK that supports the Responses " + "logprobs parameters; upgrade openai or disable logprobs.") + + @staticmethod + def _responses_error(response_dict: dict) -> tuple[Optional[str], Optional[str]]: + status = response_dict.get("status") + if status not in {"failed", "incomplete", "cancelled"}: + return None, None + error = response_dict.get("error") or response_dict.get("incomplete_details") or {} + if isinstance(error, dict): + return str(error.get("code") or status), str(error.get("message") or error.get("reason") or status) + return str(status), str(error) + + def _create_responses_response(self, response_dict: dict) -> LlmResponse: + """Convert a completed Responses payload into ``LlmResponse``.""" + parts = [] + for item in response_dict.get("output") or []: + item_type = item.get("type") + if item_type == "reasoning": + summaries = item.get("summary") or [] + if not summaries: + part = Part.from_text(text="") + part.thought = True + part.thought_signature = json.dumps(item, ensure_ascii=False).encode("utf-8") + parts.append(part) + for index, summary in enumerate(summaries): + text = summary.get("text") if isinstance(summary, dict) else None + if text: + part = Part.from_text(text=text) + part.thought = True + if index == 0: + part.thought_signature = json.dumps(item, ensure_ascii=False).encode("utf-8") + parts.append(part) + elif item_type == "message": + for output_part in item.get("content") or []: + text = output_part.get("text") or output_part.get("refusal") + if text: + part = Part.from_text(text=text) + part.thought = False + parts.append(part) + elif item_type == "function_call": + arguments = json_loads_repair(item.get("arguments") or "{}") + if not isinstance(arguments, dict): + logger.warning("Skipping Responses function call with non-dict arguments: %r", arguments) + continue + part = Part.from_function_call(name=item.get("name", ""), args=arguments) + part.function_call.id = item.get("call_id") or item.get("id") # type: ignore + parts.append(part) + + content = Content(parts=parts, role=const.MODEL) if parts else None + usage = self._process_responses_usage(response_dict) + error_code, error_message = self._responses_error(response_dict) + return LlmResponse( + content=content, + usage_metadata=usage, + error_code=error_code, + error_message=error_message, + response_id=response_dict.get("id"), + ) + + async def _generate_responses_single( + self, + api_params: Dict[str, Any], + http_options: Optional[Dict[str, Any]] = None, + ) -> LlmResponse: + """Generate one non-streaming response through the Responses API.""" + client = self._create_async_client() + try: + response = await client.responses.create( + **self._prepare_responses_api_params(client, api_params), + **(http_options or {}), + ) + return self._create_responses_response(self._model_dump(response)) + finally: + await self._http_client_provider.close_http_client(client) + + async def _generate_single( + self, + api_params: Dict[str, Any], + request: LlmRequest, + http_options: Dict[str, Any] | None = None, + ctx: InvocationContext | None = None, + ) -> LlmResponse: """Generate a single response (non-streaming).""" if http_options is None: http_options = {} @@ -1484,7 +1891,8 @@ def _log_unsupported_config_options(self, config: GenerateContentConfig) -> None if unsupported_options: logger.warning( "The following configuration options are not supported in OpenAI models and will be ignored: %s", - ', '.join(unsupported_options)) + ", ".join(unsupported_options), + ) @override async def _generate_async_impl(self, @@ -1509,7 +1917,7 @@ async def _generate_async_impl(self, # Debug log the formatted messages to help with troubleshooting logger.debug("Formatted messages for OpenAI API: %s", json.dumps(messages, indent=2)) - api_params = { + api_params: Dict[str, Any] = { ApiParamsKey.MODEL: self._model_name, ApiParamsKey.MESSAGES: messages, ApiParamsKey.STREAM: stream, @@ -1537,11 +1945,11 @@ async def _generate_async_impl(self, api_params[ApiParamsKey.STOP] = request.config.stop_sequences # Additional OpenAI-specific parameters - if (request.config.frequency_penalty is not None - and not self._adapter.should_skip_config_param("frequency_penalty")): + if request.config.frequency_penalty is not None and not self._adapter.should_skip_config_param( + "frequency_penalty"): api_params[ApiParamsKey.FREQUENCY_PENALTY] = request.config.frequency_penalty - if (request.config.presence_penalty is not None - and not self._adapter.should_skip_config_param("presence_penalty")): + if request.config.presence_penalty is not None and not self._adapter.should_skip_config_param( + "presence_penalty"): api_params[ApiParamsKey.PRESENCE_PENALTY] = request.config.presence_penalty if request.config.seed is not None and not self._adapter.should_skip_config_param("seed"): api_params[ApiParamsKey.SEED] = request.config.seed @@ -1584,21 +1992,209 @@ async def _generate_async_impl(self, http_options = {} if request.config: http_options = self._extract_http_options(request.config) - # set thinking params - self._set_thinking(request, http_options) + if self.use_responses_api: + api_params = self._convert_api_params_to_responses(api_params) + if (request.config and request.config.thinking_config and request.config.thinking_config.include_thoughts + and request.config.thinking_config.thinking_budget != 0): + # Always request a readable reasoning summary so callers can + # observe the model's reasoning. reasoning.effort is NOT + # derived from thinking_budget here: supported effort values + # vary by model (none/minimal/low/medium/high/xhigh/max) and + # are passed through verbatim via ``responses_api_params``. + reasoning = dict(api_params.get("reasoning") or {}) + reasoning.setdefault("summary", "auto") + api_params["reasoning"] = reasoning + else: + # Chat Completions provider-specific thinking params. + self._set_thinking(request, http_options) if stream: - async for response in self._generate_stream(api_params, request, http_options, ctx): + generator = (self._generate_responses_stream(api_params, request, http_options) + if self.use_responses_api else self._generate_stream(api_params, request, http_options, ctx)) + async for response in generator: yield response else: - response = await self._generate_single(api_params, request, http_options, ctx) + response = (await self._generate_responses_single(api_params, http_options) if self.use_responses_api else + await self._generate_single(api_params, request, http_options, ctx)) yield response - async def _generate_stream(self, - api_params: Dict, - request: LlmRequest, - http_options: Dict[str, Any] | None = None, - ctx: InvocationContext | None = None) -> AsyncGenerator[LlmResponse, None]: + async def _generate_responses_stream( + self, + api_params: Dict[str, Any], + request: LlmRequest, + http_options: Optional[Dict[str, Any]] = None, + ) -> AsyncGenerator[LlmResponse, None]: + """Generate streaming responses through ``responses.create``.""" + client = self._create_async_client() + response: Any = None + response_id: Optional[str] = None + completed_response: Optional[dict] = None + accumulated_text = "" + accumulated_reasoning = "" + function_calls: Dict[str, Dict[str, Any]] = {} + function_order: List[str] = [] + streaming_tool_names = getattr(request, "streaming_tool_names", None) or set() + + def upsert_function(item: dict) -> tuple[str, Dict[str, Any]]: + item_id = str(item.get("id") or item.get("call_id") or f"fc_{len(function_order)}") + if item_id not in function_calls: + function_calls[item_id] = { + "type": "function_call", + "id": item.get("id"), + "call_id": item.get("call_id") or item.get("id"), + "name": item.get("name", ""), + "arguments": item.get("arguments") or "", + } + function_order.append(item_id) + else: + current = function_calls[item_id] + for key in ("id", "call_id", "name"): + if item.get(key): + current[key] = item[key] + if item.get("arguments"): + current["arguments"] = item["arguments"] + return item_id, function_calls[item_id] + + try: + response = await client.responses.create( + **self._prepare_responses_api_params(client, api_params), + **(http_options or {}), + ) + if response is None: + raise ValueError("Empty response from Responses API") + + async for event in response: + event_dict = self._model_dump(event) + event_type = event_dict.get("type", "") + logger.debug("OpenAI Responses event: %s", json.dumps(event_dict, ensure_ascii=False)) + + response_data = event_dict.get("response") or {} + if response_id is None and response_data.get("id"): + response_id = response_data["id"] + if response_id is None and event_dict.get("response_id"): + response_id = event_dict["response_id"] + + if event_type == "error": + completed_response = { + "id": response_id, + "status": "failed", + "error": { + "code": event_dict.get("code") or "responses_stream_error", + "message": event_dict.get("message") or "Responses stream failed", + }, + "output": [], + } + continue + + if event_type in {"response.output_text.delta", "response.refusal.delta"}: + delta = event_dict.get("delta") or "" + if delta: + accumulated_text += delta + part = Part.from_text(text=delta) + part.thought = False + yield LlmResponse( + content=Content(parts=[part], role=const.MODEL), + partial=True, + response_id=response_id, + custom_metadata={const.CHUNK: event_dict}, + ) + continue + + if event_type in {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}: + delta = event_dict.get("delta") or "" + if delta: + accumulated_reasoning += delta + part = Part.from_text(text=delta) + part.thought = True + yield LlmResponse( + content=Content(parts=[part], role=const.MODEL), + partial=True, + response_id=response_id, + custom_metadata={const.CHUNK: event_dict}, + ) + continue + + if event_type in {"response.output_item.added", "response.output_item.done"}: + item = event_dict.get("item") or {} + if item.get("type") == "function_call": + upsert_function(item) + continue + + if event_type == "response.function_call_arguments.delta": + item_id = str(event_dict.get("item_id") or f"fc_{event_dict.get('output_index', 0)}") + function_call = function_calls.get(item_id) + if function_call is None: + continue + delta = event_dict.get("delta") or "" + function_call["arguments"] += delta + name = function_call.get("name", "") + if delta and name and name in streaming_tool_names: + part = Part.from_function_call(name=name, args={const.TOOL_STREAMING_ARGS: delta}) + part.function_call.id = function_call.get("call_id") # type: ignore + yield LlmResponse( + content=Content(parts=[part], role=const.MODEL), + partial=True, + response_id=response_id, + custom_metadata={ + const.CHUNK: event_dict, + const.TOOL_STREAMING: True + }, + ) + continue + + if event_type == "response.function_call_arguments.done": + item_id = str(event_dict.get("item_id") or f"fc_{event_dict.get('output_index', 0)}") + item = event_dict.get("item") or {} + if item.get("type") == "function_call": + upsert_function(item) + elif item_id in function_calls: + function_calls[item_id]["arguments"] = event_dict.get("arguments") or "" + continue + + if event_type in {"response.completed", "response.failed", "response.incomplete"}: + completed_response = response_data + + if completed_response is None: + output = [] + if accumulated_reasoning: + output.append({ + "type": "reasoning", + "summary": [{ + "type": "summary_text", + "text": accumulated_reasoning + }], + }) + if accumulated_text: + output.append({ + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": accumulated_text + }], + }) + output.extend(function_calls[item_id] for item_id in function_order) + completed_response = {"id": response_id, "status": "completed", "output": output} + + final_response = self._create_responses_response(completed_response) + final_response.partial = False + final_response.custom_metadata = {"stream_complete": True} + yield final_response + finally: + if response is not None and hasattr(response, "aclose"): + await response.aclose() + try: + await self._http_client_provider.close_http_client(client) + except Exception: # pylint: disable=broad-except + logger.debug("Error closing HTTP client for Responses stream", exc_info=True) + + async def _generate_stream( + self, + api_params: Dict[str, Any], + request: LlmRequest, + http_options: Dict[str, Any] | None = None, + ctx: InvocationContext | None = None, + ) -> AsyncGenerator[LlmResponse, None]: """Generate streaming responses.""" if http_options is None: http_options = {} @@ -1611,7 +2207,7 @@ async def _generate_stream(self, response_id: str | None = None # Track response ID from API # For streaming tool call arguments - get the set of tool names that should stream - streaming_tool_names = getattr(request, 'streaming_tool_names', None) or set() + streaming_tool_names = getattr(request, "streaming_tool_names", None) or set() # Create tool prompt instance for streaming if needed tool_prompt = None @@ -1705,10 +2301,12 @@ async def _generate_stream(self, content_part.thought = True partial_content = Content(parts=[content_part], role=const.MODEL) - yield LlmResponse(content=partial_content, - partial=True, - response_id=response_id, - custom_metadata={const.CHUNK: chunk_dict}) + yield LlmResponse( + content=partial_content, + partial=True, + response_id=response_id, + custom_metadata={const.CHUNK: chunk_dict}, + ) # Handle regular content if delta.get(const.CONTENT): @@ -1733,10 +2331,12 @@ async def _generate_stream(self, content_part.thought = is_thinking partial_content = Content(parts=[content_part], role=const.MODEL) - yield LlmResponse(content=partial_content, - partial=True, - response_id=response_id, - custom_metadata={const.CHUNK: chunk_dict}) + yield LlmResponse( + content=partial_content, + partial=True, + response_id=response_id, + custom_metadata={const.CHUNK: chunk_dict}, + ) # Handle usage usage = self._process_usage(chunk_dict) @@ -1752,10 +2352,12 @@ async def _generate_stream(self, content_part = Part.from_text(text=flushed_reasoning_text) content_part.thought = True partial_content = Content(parts=[content_part], role=const.MODEL) - yield LlmResponse(content=partial_content, - partial=True, - response_id=response_id, - custom_metadata={"stream_filter_flushed": "reasoning"}) + yield LlmResponse( + content=partial_content, + partial=True, + response_id=response_id, + custom_metadata={"stream_filter_flushed": "reasoning"}, + ) flushed_content_text = self._adapter.flush_streaming_text(streaming_text_filter_state["content"]) if flushed_content_text: @@ -1764,10 +2366,12 @@ async def _generate_stream(self, content_part = Part.from_text(text=flushed_content_text) content_part.thought = is_thinking partial_content = Content(parts=[content_part], role=const.MODEL) - yield LlmResponse(content=partial_content, - partial=True, - response_id=response_id, - custom_metadata={"stream_filter_flushed": "content"}) + yield LlmResponse( + content=partial_content, + partial=True, + response_id=response_id, + custom_metadata={"stream_filter_flushed": "content"}, + ) # Yield final complete response final_content = None diff --git a/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py b/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py index 6478aa8a8..2e9214b0d 100644 --- a/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py +++ b/trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py @@ -54,6 +54,7 @@ from trpc_agent_sdk.runners import Runner from trpc_agent_sdk.sessions import BaseSessionService from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.dsl.graph import is_graph_internal_state_key from trpc_agent_sdk.tools import LongRunningFunctionTool from trpc_agent_sdk.types import Content @@ -1103,11 +1104,22 @@ async def _run_trpc_in_background(self, # Ensure session exists await self._ensure_session_exists(app_name, user_id, input.thread_id, input.state) - # this will always update the backend states with the frontend states - # Recipe Demo Example: if there is a state "salt" in the ingredients state and in frontend user - # remove this salt state using UI from the ingredients list then our backend should also update - # these state changes as well to sync both the states - await self._session_manager.update_session_state(input.thread_id, app_name, user_id, input.state) + # Always synchronise the client-supplied state so UI-driven edits + # (e.g. the Recipe Demo removing an ingredient from the list) reach + # the backend for both normal turns and HITL tool-result rounds. + # GraphAgent-internal keys (``_trpc_graph_*``, e.g. the checkpoint / + # interrupt markers) are stripped first: they are never emitted to + # the client, so any occurrence here is a stale echo that must not + # overwrite the live checkpoint and restart the graph. + await self._session_manager.update_session_state( + input.thread_id, + app_name, + user_id, + { + key: value + for key, value in (input.state or {}).items() if not is_graph_internal_state_key(key) + }, + ) # Convert messages # only use this new_message if there is no tool response from the user diff --git a/trpc_agent_sdk/server/ag_ui/_core/_event_translator.py b/trpc_agent_sdk/server/ag_ui/_core/_event_translator.py index 747fbab39..8b735330d 100644 --- a/trpc_agent_sdk/server/ag_ui/_core/_event_translator.py +++ b/trpc_agent_sdk/server/ag_ui/_core/_event_translator.py @@ -51,6 +51,7 @@ from trpc_agent_sdk.events import LongRunningEvent from trpc_agent_sdk.log import logger from trpc_agent_sdk.models import TOOL_STREAMING_ARGS +from trpc_agent_sdk.dsl.graph import is_graph_internal_state_key class EventTranslator: @@ -599,9 +600,15 @@ def _create_state_delta_event(self, state_delta: Dict[str, Any], timestamp: floa A StateDeltaEvent """ # Convert to JSON Patch format (RFC 6902) - # Use "add" operation which works for both new and existing paths + # Use "add" operation which works for both new and existing paths. + # GraphAgent-internal keys (``_trpc_graph_*``, e.g. the LangGraph + # checkpoint / interrupt markers) are never emitted to the client: they + # are backend continuation tokens, not client-facing state, and echoing + # them back would clobber the checkpoint. patches = [] for key, value in state_delta.items(): + if is_graph_internal_state_key(key): + continue patches.append({"op": "add", "path": f"/{key}", "value": value}) timestamp_ms = int(timestamp * 1000) @@ -621,8 +628,13 @@ def _create_state_snapshot_event( Returns: A StateSnapshotEvent """ + # Drop GraphAgent-internal keys (``_trpc_graph_*``) so the client + # snapshot only carries business state. Keeping them would leak backend + # continuation tokens and let the client echo them back to overwrite the + # checkpoint. + safe_snapshot = {key: value for key, value in state_snapshot.items() if not is_graph_internal_state_key(key)} timestamp_ms = int(timestamp * 1000) - return StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=state_snapshot, timestamp=timestamp_ms) + return StateSnapshotEvent(type=EventType.STATE_SNAPSHOT, snapshot=safe_snapshot, timestamp=timestamp_ms) async def force_close_streaming_message(self) -> AsyncGenerator[BaseEvent, None]: """Force close any open streaming message. diff --git a/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py b/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py index 848e7590f..8dc6fc18d 100644 --- a/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py +++ b/trpc_agent_sdk/server/ag_ui/_core/_session_manager.py @@ -27,6 +27,7 @@ from typing import Set from typing import Union +from trpc_agent_sdk.dsl.graph import has_graph_resume_state from trpc_agent_sdk.log import logger @@ -92,9 +93,13 @@ def __init__( self._cleanup_task: Optional[asyncio.Task] = None self._initialized = True - logger.info("Initialized SessionManager - timeout: %ss, cleanup: %ss, max/user: %s, memory: %s", - session_timeout_seconds, cleanup_interval_seconds, max_sessions_per_user or 'unlimited', - 'enabled' if memory_service else 'disabled') + logger.info( + "Initialized SessionManager - timeout: %ss, cleanup: %ss, max/user: %s, memory: %s", + session_timeout_seconds, + cleanup_interval_seconds, + max_sessions_per_user or "unlimited", + "enabled" if memory_service else "disabled", + ) @classmethod def get_instance(cls, **kwargs): @@ -179,7 +184,9 @@ async def update_session_state(self, if not session: logger.debug( "Session not found for update: %s:%s - this may be normal if session is still being created", - app_name, session_id) + app_name, + session_id, + ) return False if not state_updates: @@ -575,11 +582,24 @@ async def _cleanup_expired_sessions(self): if session and hasattr(session, "last_update_time"): age = current_time - session.last_update_time if age > self._timeout: - # Check for pending tool calls before deletion (HITL scenarios) - pending_calls = session.state.get("pending_tool_calls", []) if session.state else [] + # Do not expire a session that is mid human-in-the-loop. + # Two independent HITL mechanisms can pause a session: + # - pending_tool_calls: classic long-running tool wait. + # - a GraphAgent interrupt that is still outstanding. + # Deleting either would lose the resume point. The graph + # check is scoped to the *outstanding interrupt* marker + # only (see has_graph_resume_state); a completed graph + # is eligible for normal cleanup. + state = session.state if session.state else {} + pending_calls = state.get("pending_tool_calls", []) if pending_calls: - logger.info("Preserving expired session %s - has %s pending tool calls (HITL)", session_key, - len(pending_calls)) + logger.info( + "Preserving expired session %s - has %s pending tool calls (HITL)", + session_key, + len(pending_calls), + ) + elif has_graph_resume_state(state): + logger.info("Preserving expired session %s - graph interrupt awaiting resume", session_key) else: await self._delete_session(session) expired_count += 1