Skip to content

agent, tools: 支持 OpenAI Responses API、Agent HITL 多轮交互,修复 async generator span 泄漏#245

Open
pcerypeng wants to merge 4 commits into
trpc-group:mainfrom
pcerypeng:main
Open

agent, tools: 支持 OpenAI Responses API、Agent HITL 多轮交互,修复 async generator span 泄漏#245
pcerypeng wants to merge 4 commits into
trpc-group:mainfrom
pcerypeng:main

Conversation

@pcerypeng

Copy link
Copy Markdown

agent, tools: 支持 OpenAI Responses API、Agent HITL 多轮交互,修复 async generator span 泄漏

本次 PR 包含三项改进:

  1. OpenAI Responses API 适配 — 新增对 OpenAI Responses API 的流式和非流式
    支持,涵盖 reasoning、tool calls、logprobs 等特性。

  2. Agent 节点 HITL(人机多轮交互)机制 — 通过 interrupt bridge 桥接机制,
    允许 Agent 节点在运行中暂停等待人工输入,支持多轮审批或修正流程后继续执行。

  3. 修复 async generator 中 OpenTelemetry span 泄漏 — 将 start_as_current_span
    替换为手动 start_span + attach/detach + try/finally 模式,确保 async
    generator 被取消时 span 仍能正确结束。新增防御性 ValueError 捕获,处理
    跨 task 清理场景。

此外,本次 PR 还新增了框架级工具错误检测能力:

  • 引入 ToolArgumentErrorResponse 标记类,用于参数校验失败的场景。
  • 新增标准化工具错误码(tool_not_found、tool_argument_error、
    tool_execution_error),并提供 is_tool_execution_error() 公共辅助函数。

RELEASE NOTES: 新增 OpenAI Responses API 支持、Agent 节点 HITL 多轮交互能力,
修复 async generator 取消时 OpenTelemetry span 泄漏问题。

新增功能:
- OpenAI Responses API 适配 (非流式/流式), 支持 reasoning、tool calls、logprobs
- Agent 节点多轮 HITL 机制, 通过 interrupt bridge 桥接子 agent LongRunningEvent
- AG-UI GraphAgent checkpoint 保护, 防止工具结果恢复时覆盖 LangGraph checkpoint

代码修复 (代码审查):
- _constants.py: 将 STATE_KEY_PENDING_AGENT_NODE_HITL 加入 UNSAFE_STATE_KEYS,
  防止含敏感工具参数的 child_state 通过 completion 事件对外暴露
- _openai_model.py: 非流式 Responses 路径分离 http_options 传参, 与流式路径
  保持一致, 修复 extra_body 被误传为 responses.create 顶层参数的 Bug
- _llm_agent.py: 删除重复的 logger.debug 行; 在长运行工具与并行工具批混用时
  发出警告, 提示同批其它工具结果不会被 LLM 进一步处理
- _long_running_tool.py: 提取 TOOL_ERROR_CODE_* 共享常量, 消除与
  _tools_processor.py 中的硬编码字符串重复
- _agui_agent.py: 优化 GraphAgent checkpoint 检测——复用 _ensure_session_exists
  返回的 session 消除额外 DB 查询, 用模块级常量替代硬编码前缀, 改为同步方法
- _session_manager.py: 新增 session_service 公共属性, 消除私有成员访问

新增测试:
- AgentNode HITL 中断后进程重启恢复场景 (SqlSessionService)
- STATE_KEY_PENDING_AGENT_NODE_HITL unsafe 归类验证
- 多轮 HITL 客户端使用过期 function_call.id 提交 resume 时不静默完成
- 非流式 Responses 路径 http_options 含 extra_body/extra_headers/timeout 时参数分离
- GraphAgent checkpoint 保护测试
- test_constants.py 更新覆盖新增 unsafe key
- 将 start_as_current_span 替换为手动 start_span + attach/detach + try/finally
- 确保 async generator 被 cancel 时 span 仍能正确 end
- 防御性 catch ValueError 处理跨 task 清理场景
- _run_with_span_pattern 的 detached 分支现在与生产代码行为一致:start_span + attach + detach
- 确保 trace_agent 通过 trace.get_current_span() 能获取到正确的 span
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.86081% with 24 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@6a2f7f9). Learn more about missing BASE report.

Files with missing lines Patch % Lines
trpc_agent_sdk/models/_openai_model.py 95.27027% 14 Missing ⚠️
trpc_agent_sdk/dsl/graph/_node_action/_agent.py 96.15385% 3 Missing ⚠️
trpc_agent_sdk/agents/_base_agent.py 80.00000% 2 Missing ⚠️
trpc_agent_sdk/agents/_llm_agent.py 91.66667% 2 Missing ⚠️
trpc_agent_sdk/agents/core/_tools_processor.py 80.00000% 1 Missing ⚠️
trpc_agent_sdk/dsl/graph/_graph_agent.py 85.71429% 1 Missing ⚠️
...c_agent_sdk/server/ag_ui/_core/_session_manager.py 66.66667% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main        #245   +/-   ##
==========================================
  Coverage        ?   87.97431%           
==========================================
  Files           ?         482           
  Lines           ?       45461           
  Branches        ?           0           
==========================================
  Hits            ?       39994           
  Misses          ?        5467           
  Partials        ?           0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

确认 — 已删除且未迁移。现在我来检查 test_openai_responses_model.py 是否存在严重问题 — 特别是 _model 辅助函数默认使用 api_key="test_key",以及是否有测试实际发起了网络请求。测试全部 patch 了 _create_async_client,因此不会产生网络请求。很好。

现在我来检查一下 test_langfuse 的 detached span 修复中可能存在的问题 — span_token 变量仅在 if pattern == "detached" 分支内定义。如果 callback() 抛出异常,finally 会运行 detach(span_token)span_token 已定义。没问题。

现在我来验证 _agui_agent.py 中的一个真正问题:_is_graph_checkpoint_resume 检查 session.state 中的 _trpc_graph_checkpoint* 键以及 _trpc_graph_pending_interrupt is True。但检查点状态键可能并不总是以这种方式持久化。注释承认它依赖于 pending_interrupt 键。但有一个微妙之处:当 auto_persist=True 时,检查点键确实存在。当 auto_persist=False 时,则依赖于 pending_interrupt。这是为 graph resume 设计的。风险在于:对于提交带有 _trpc_graph_pending_interrupt=True 状态的 tool results 的非 graph agents,状态同步会被跳过。但这正是预期的行为。我觉得这没问题。

我来检查一个担忧:_is_graph_checkpoint_resume 使用传入的 session。但 session 来自 _ensure_session_exists,它返回持久化/缓存的 session。对于 SqlSessionService,它从数据库加载状态。很好。

现在我来检查 GraphAgent 上下文中的 LongRunningEvent,以及 _create_interrupt_events 是否正确映射了 HITL。当 AgentNodeAction 调用 interrupt(self._interrupt_payload(current_round)) 时,LangGraph 会捕获它,GraphAgent 的 _iter_interrupts 会检测到中断,然后 _create_interrupt_events 会调用 _build_interrupt_function,该方法检查 raw_args.get("_trpc_agent_node_hitl") is True。很好 — 有效载荷包含该标志。

我还要验证恢复时的 _build_interrupt_function:当 GraphAgent 恢复时,Command(resume={interrupt_id: function_response.response})function_response.response 是用户的答案。这成为 LangGraph 中的 interrupt() 返回值 → human_response。然后 _resume_content 使用该响应构建内容。很好。

现在我来看一下 test_agent_node_multiround_hitl_resume_with_correct_order 测试 — 它有一个条件性的 if stale_pending is not None: ... else:。这有点脆弱,但反映了一个已记录的不确定性。不是一个阻塞性问题。

我来检查最后一个潜在的实际问题:在 _pending_round 中,child_statedict(child_ctx.session.state)... 等等,不对,是 dict(child_state),其中 child_statefinal_state。我来重读一下。

current_round = self._pending_round(event, child_ctx, final_state),且 _pending_round 执行 "child_state": dict(child_state),其中 child_statefinal_state。所以它捕获了到目前为止累积的最终状态。恢复时,saved_child_state = current_round.get("child_state"); child_state = dict(saved_child_state)。这会在恢复时恢复子图状态。很好。

但有一个细微的问题:completed_rounds 排除了 child_state 键:{key: value for key, value in previous_current.items() if key != "child_state"}。因此,完成的轮次不存储 child_state(为了减小持久化负载的大小)。当重放完成的轮次时,interrupt(self._interrupt_payload(completed)) 只是重新触发中断以推进 LangGraph 状态 — interrupt 的返回值被忽略了。很好。

我现在已经完全理解了。我来写审查报告。我也要再次检查一下 pyproject.toml 中 openai 的版本提升不会破坏 Chat Completions 的任何功能 — 1.66 版本仍然支持 Chat Completions。没有问题。

我来验证一下是否存在导入问题:_node_action/_agent.py 在模块顶部导入了 from langgraph.errors import GraphInterrupt。我来确认一下这是否可用,以及 except GraphInterrupt: raise 是否能正确传播。LangGraph 在内部使用 GraphInterrupt 进行暂停;重新引发它允许图的执行循环捕获它。这是正确的模式。但是 — 在 execute() 中捕获并重新引发 GraphInterrupt 意味着父图的 astream 会检测到中断。很好。

现在,interrupt(...)async for 循环内部被调用。如果 interrupt() 引发了 GraphInterruptawait agent_stream.aclose() 就不会执行(异常会先传播)。我来检查一下顺序:

await agent_stream.aclose()
interrupt(self._interrupt_payload(current_round))
raise RuntimeError(...)

aclose()interrupt() 之前。所以 agent_stream 首先被关闭,然后 interrupt() 引发 GraphInterruptGraphInterrupt 会传播到 try/except GraphInterrupt: raise 之外。很好 — 子生成器已正确关闭。RuntimeError 永远不会执行(interrupt 总是会引发 GraphInterrupt)。这是作为后备保护措施使用的。没问题。

我来做最后总结。我还要仔细检查一个真正值得关注的问题:_agui_agent.py 第 1118 行的变更现在跳过了 graph-resume 轮次的 update_session_state。但注释说“非 graph agents 有意提交带有 tool result 的状态补丁”。对于 graph agents,状态同步被跳过。这意味着对于 graph HITL 恢复,来自浏览器 input.state 的任何合法状态更新都会被丢弃。这是有意为之的(检查点是权威的)。但值得注意其局限性。这不是一个 bug,而是设计使然。

现在我来写审查报告。

发现的问题

🚨 Critical

无。

⚠️ Warning

  • tests/tools/test_long_running_tool.py:1-16:删除了 LongRunningFunctionTool 的全部既有测试覆盖

    • 该文件原有 TestLongRunningFunctionToolInitTestLongRunningFunctionToolGetDeclaration 等用例(覆盖 is_long_running/name/filters 校验/声明描述拼接),被整文件替换为仅 3 个 is_tool_execution_error 单测,且未迁移到其他文件(已 grep 确认)。这使 LongRunningFunctionTool 的初始化与 _get_declaration 行为(含“不要重复调用”提示注入)回归到无测试保护状态。建议保留原有 init/declaration 用例,再叠加新增的错误分类测试。
  • trpc_agent_sdk/agents/_llm_agent.py:561-566:长时工具与 parallel_tool_calls=True 混用时仅打印 warning 后照常执行

    • 当同一批 tool_calls 中同时存在长时工具和普通工具且开启并行调用时,代码只记录警告,仍进入正常执行/挂起流程。由于长时工具会 return 终止本轮 agent 执行,同批次其他工具的 function_response 虽被写入 session 但不会被 LLM 总结,下一轮恢复后模型上下文可能与实际工具状态不一致。建议要么在此组合下强制 parallel_tool_calls=False,要么在文档/类型层面禁止该组合并显式报错,而非静默继续。

💡 Suggestion

  • trpc_agent_sdk/models/_openai_model.py:1386-1397_generate_responses_streamresponse.function_call_arguments.delta 分支):当 item_id 未出现过时直接 function_order.append(item_id) 并新建条目,但随后 name 可能为空字符串;只有 name in streaming_tool_names 才会流式吐出 delta。若上游先发 delta 再发 output_item.added,初始 delta 会因 name 为空被丢弃(与 fallback 路径行为一致,但可读性上易误判)。可在注释中说明该顺序依赖,便于后续维护。

总结

整体风险较低,无 Critical 阻塞问题。核心新增逻辑(AgentNode HITL 桥接、OpenAI Responses API 适配、长时工具错误码归类、AG-UI graph 检查点恢复保护)正确性与异常路径处理总体得当;主要需关注的是既有 LongRunningFunctionTool 测试覆盖被删除导致的回归保护缺失,以及长时工具与并行工具调用混用时的静默行为。

测试建议

  • 补回 LongRunningFunctionTool 的 init / _get_declaration 用例(含无 docstring 与 filters_name 校验失败两条路径),避免既有能力回归无保护。
  • 建议补充一条用例:同一批 tool_calls 同时包含 LongRunningFunctionTool 与普通工具、且 parallel_tool_calls=True 时,验证 agent 行为(是否如 warning 所述、恢复后模型上下文是否一致),明确该组合的预期契约。

1. 恢复 LongRunningFunctionTool 的 init/declaration 测试覆盖
   a972e6e 中误删了 TestLongRunningFunctionToolInit 和
   TestLongRunningFunctionToolGetDeclaration 共 6 个用例,导致
   is_long_running/name/filters 校验和 _get_declaration 的
   'Do not call this tool again' 注入回归到无测试保护状态。

2. llm_agent: 长时工具+parallel_tool_calls 混用时 raise RuntimeError
   原实现仅 logger.warning 后照常执行,同批普通工具的
   function_response 会被持久化但不会被 LLM 总结,恢复后模型上下文
   与实际工具状态不一致。改为显式报错禁止该组合。

3. _openai_model: 补充 function_call_arguments.delta 顺序依赖注释
   说明当上游 API 先发 delta 再发 output_item.added 时,初始 delta
   因 name 为空而被 streaming_tool_names 守卫静默跳过的行为。
@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

现在让我检查一下 _is_graph_checkpoint_resume 的关键行为——当工具结果恢复图形时,_ensure_session_exists 返回的会话状态是否仍然包含 True_trpc_graph_pending_interrupt?它会在恢复运行之前被清除吗?恢复操作是在另一个流程中通过 _handle_tool_result_submission 处理的。让我验证一下顺序:run_async_ensure_session_exists_is_graph_checkpoint_resume 跳过状态更新。会话状态中仍然保留着来自上次中断的 pending_interrupt=True。所以检测是有效的,并且跳过状态更新可以防止浏览器状态覆盖检查点(checkpoint)。很好——这是该 PR 的核心修复。

我已经掌握了所需的所有上下文。让我来撰写审查意见。

发现的问题

🚨 Critical

  • trpc_agent_sdk/server/ag_ui/_core/_agui_agent.py:1127_is_graph_checkpoint_resume 在 tool-result 提交时跳过了 update_session_state,但检测信号依赖 session.state 中残留的 _trpc_graph_checkpoint* / _trpc_graph_pending_interrupt 标记。当使用 InMemorySessionService 且前端在 tool-result 提交时显式带上了 input.state(前端覆盖式同步),由于 update_session_state 被跳过是正确的;但若 session 在该次请求前因超时/清理被重建(_ensure_session_exists 会用 initial_state=input.state 重新建会话),旧的 checkpoint 标记会丢失,_is_graph_checkpoint_resume 返回 False,导致前端 state 覆盖 LangGraph checkpoint,Command(resume=...) 从 START 重新执行。
    • 影响:HITL resume 在 session 被清理重建的场景下静默失败,checkpoint 丢失。
    • 修复方向:resume 路径不应依赖 session 缓存中的内部标记判定;建议改为依据 input 中是否为 tool-result 提交 + GraphAgent 类型/上一轮产出 LongRunningEvent 来判定,或在 _ensure_session_exists 重建会话时显式保留 checkpoint 类 state keys。

⚠️ Warning

  • pyproject.toml:113openai>=1.3.0 提升到 openai>=1.66.0 是对下游使用者的破坏性依赖变更,任何 pin 在旧版 openai 的环境安装新版本 SDK 会直接失败。Responses API 路径需要该版本,但默认 use_responses_api=False 时旧版本本可工作。

    • 修复方向:确认这是预期 breaking change 并在 CHANGELOG/release notes 显式标注;或考虑将 Responses 支持做成可选 extra,仅在启用时要求高版本。
  • trpc_agent_sdk/dsl/graph/_node_action/_agent.py:211STATE_KEY_PENDING_AGENT_NODE_HITLcurrent 轮里保存了完整的 child_state_pending_round"child_state": dict(child_state)),而该 key 已加入 UNSAFE_STATE_KEYS(test 也验证了),不会被输出到 completion state_delta。但它会随 interrupt bridge event 的 state_delta 持久化到 session.state(测试 test_agent_node_hitl_survives_service_restart 依赖此行为)。child_state 可能包含较大或敏感的子代理中间状态,长期累积于 session 且多轮 completed 列表只剔除了 child_state、保留其余字段,存在状态膨胀风险。

    • 修复方向:评估是否需要在完成轮次后对 completed 列表裁剪,或对 child_state 体积设限。
  • trpc_agent_sdk/dsl/graph/_node_action/_agent.py:216-218await agent_stream.aclose() 后立即 interrupt(...)raise RuntimeError(...)interrupt() 会抛 GraphInterrupt,因此 raise RuntimeError 实为不可达防御代码;但若 interrupt 因实现变更未抛异常,RuntimeError 会被外层 except Exception 捕获并清空 STATE_KEY_PENDING_AGENT_NODE_HITL(305-306 行),反而清掉刚写入的 pending 状态。

    • 修复方向:将 interrupt(...) 后的兜底改为更明确的断言/日志,或在 except Exception 中避免在 GraphInterrupt 已设置 pending 后清空状态。
  • trpc_agent_sdk/models/_openai_model.py:4197async for event in response 迭代 Responses 流,但 response 对象未在 finally 之外显式关闭。当客户端在迭代中途取消(aclose/CancelledError)时,底层流可能未被显式 close,仅依赖 _http_client_provider.close_http_client(client) 关闭 httpx client;若 provider 的 close 不保证取消底层 SSE 流,可能残留连接。

    • 修复方向:在 finally 中对 response 调用 aclose(若为 async stream)或确保 provider close 涵盖流取消。

💡 Suggestion

  • trpc_agent_sdk/dsl/graph/_events/_builder.py:3350node_description.strip() if node_description and node_description.strip() else node_id 调用了两次 .strip(),可简化为 display_name = (node_description.strip() or node_id) if node_description else node_id,行为等价且更清晰。

总结

整体实现质量较高,HITL 多轮恢复、Responses API 支持、span 上下文修复等核心逻辑均有对应测试覆盖。存在一个 Critical 风险:graph resume 检测依赖 session 缓存中的内部标记,在 session 被重建时会失效导致 checkpoint 被前端 state 覆盖;其余为依赖版本破坏性变更与若干边界/资源清理建议。建议优先修复 Critical 项的检测逻辑。

测试建议

  • 补充 session 在 tool-result 提交前被清理重建(_ensure_session_exists 走新建分支)后仍能正确保留 LangGraph checkpoint 并 resume 的集成测试。
  • 补充 Responses 流式过程中客户端中途取消(aclose)时底层连接/流被正确释放的测试。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants