Skip to content

Feature: 支持OpenAI Responses API与Agent节点HITL桥接 - #290

Open
pcerypeng wants to merge 1 commit into
trpc-group:mainfrom
pcerypeng:main
Open

Feature: 支持OpenAI Responses API与Agent节点HITL桥接#290
pcerypeng wants to merge 1 commit into
trpc-group:mainfrom
pcerypeng:main

Conversation

@pcerypeng

Copy link
Copy Markdown

No description provided.

@helloopenworld

Copy link
Copy Markdown
Contributor

AI Code Review

根据我对 diff 及其上下文的审查,以下是我的审查结论。

发现的问题

⚠️ Warning

  • trpc_agent_sdk/models/_openai_model.py:1278 (_generate_responses_single): non-streaming Responses 路径把 http_options_prepare_responses_api_params(client, api_params) 的返回值用 ** 合并后传给 responses.create。若 http_options 中的键(如 extra_body/timeout/extra_headers 之外的扩展项,或将来 SDK 新增的同名参数)与 prepared 中已有键重名,Python 会抛 TypeError: got multiple values for keyword argument,导致请求失败。建议显式合并而非直接 ** 双解包,或在 _prepare_responses_api_params 内部统一吸收 http_options。

  • trpc_agent_sdk/models/_openai_model.py:1375 (_convert_messages_to_responses_input): tool 角色消息用 "output": str(message.get(const.CONTENT, "")) 转换。当上游 CONTENT 为 dict(部分适配器/自定义路径可能产生非字符串)时,str(dict) 输出 Python repr 而非合法 JSON,写入 Responses function_call_output.output 会得到 "{'k': 'v'}" 这类不可解析文本。建议改为 json.dumps(content, ensure_ascii=False)(保持字符串契约)。

  • trpc_agent_sdk/models/_openai_model.py:1466 (_generate_responses_stream): 终态构建 final_response 后无条件执行 final_response.custom_metadata = {"stream_complete": True},会覆盖 completed_response 已携带的 custom_metadata(如 error/incomplete 路径中可能存在的诊断信息)。建议改为 final_response.custom_metadata = {**(final_response.custom_metadata or {}), "stream_complete": True} 以保留既有字段。

  • trpc_agent_sdk/dsl/graph/_node_action/_agent.py:236-256: 在 async for event in agent_stream 循环内捕获到 LongRunningEvent 后,先写 state、aclose 流,再调用 interrupt(...)interrupt 会抛出 GraphInterrupt,该异常通过 except GraphInterrupt: raise 透传是正确的;但同一轮循环里 interrupt 之后的 if (not event.partial) 等逻辑不可达,依赖异常传播才能跳过——逻辑可读但脆弱。建议 interrupt(...)return 或显式 raise 以表明控制流终止,避免后续误删/误改该分支时引入静默 bug。

  • trpc_agent_sdk/dsl/graph/_node_action/_agent.py:244-249: 构造 completed_rounds 时把 previous_current 去掉 child_state 后并入 completed,但 _interrupt_payload 在 resume 时对 completed 轮次重放 interrupt(...) 依赖 payload 与首次写出时严格一致。previous_current 是上一轮 current(已保存为完整 dict,含 function_call/function_response),去除 child_state 后其余字段保留,逻辑成立;但缺少对 previous_currentfunction_call.id 缺失的防御(_resume_content 仅在 current 路径校验)。建议对 completed 轮次同样做最小校验,避免持久化数据被外部篡改后在 resume 时抛 KeyError 而非可定位错误。

💡 Suggestion

  • trpc_agent_sdk/dsl/graph/_history.py:15-25: resolve_history_scopeStateGraph.add_agent_nodeAgentNodeAction.__init__ 中各调用一次(_state_graph.py 已解析后传入 resolved 值,AgentNodeAction 又对已 resolved 的值再解析一次)。对已 resolved 值二次校验无害但冗余,可在 AgentNodeAction 直接信任传入的 resolved 值,避免双重校验带来的语义混淆。

总结

整体改动(Responses API 适配、Agent 节点 HITL 桥接、框架内部 state key 过滤)逻辑清晰且测试覆盖较全,未发现 Critical 级阻塞问题。主要风险集中在 Responses 路径的参数合并与元数据覆盖等边界处理,建议修复上述 Warning 以提升健壮性。

测试建议

  • 补充一条 non-streaming Responses 测试:http_options 键与 responses_api_params/api_params 键重名时的行为,验证不会抛 TypeError
  • 补充一条 function_call_outputoutput 为非字符串(dict)时的转换测试,覆盖 _convert_messages_to_responses_input 的 tool 消息序列化路径。

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.04553% with 34 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@859ac4e). Learn more about missing BASE report.

Files with missing lines Patch % Lines
trpc_agent_sdk/models/_openai_model.py 95.10086% 17 Missing ⚠️
trpc_agent_sdk/dsl/graph/_node_action/_agent.py 90.00000% 11 Missing ⚠️
trpc_agent_sdk/dsl/graph/_exceptions.py 86.95652% 3 Missing ⚠️
trpc_agent_sdk/dsl/graph/_graph_agent.py 97.61905% 1 Missing ⚠️
..._agent_sdk/server/ag_ui/_core/_event_translator.py 80.00000% 1 Missing ⚠️
...c_agent_sdk/server/ag_ui/_core/_session_manager.py 85.71429% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main        #290   +/-   ##
==========================================
  Coverage        ?   88.48542%           
==========================================
  Files           ?         494           
  Lines           ?       46871           
  Branches        ?           0           
==========================================
  Hits            ?       41474           
  Misses          ?        5397           
  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

现在让我更仔细地确认关于代理节点 _pending_round / final_state 的问题,以及 child_state 丢失消息的问题。实际上,让我重新检查一个真正的 Bug:当 history_scope in {"none", "branch"} 时,代码会设置 child_session.state[STATE_KEY_MESSAGES] = []。但是当使用保存的 child_state 恢复 HITL 时,child_state = dict(saved_child_state)。而 saved_child_state 来自 _json_safe(final_state),其中 final_state = dict(child_session.state)(在执行时)。所以 child_state 包含了中断时的子节点状态。然后 child_session.state = dict(child_state),接着 STATE_KEY_MESSAGES = []。因此,无论哪种方式,消息都会被清除。没问题。

现在,一个真正的担忧是:在恢复时,会重建 child_events,但对于 history_scope=="branch",它通过分支匹配从父事件中过滤父事件。然而,之前的轮次中断事件(函数调用/响应)会被附加到父会话事件中吗?让我检查一下 —— 当代理节点中断时,父节点 _create_interrupt_events 会产生函数调用/响应/长运行事件,这些事件会通过 Runner 附加到会话中。在恢复时,子节点 _build_child_events 通过分支进行过滤。但是子节点的分支是 parent.branch.child_scope,而中断事件的分支是 ctx.branch(父节点的分支)。因此,中断事件不会被包含在子事件中。相反,resume_content 会作为用户内容被注入。没问题 —— 这在测试中已得到验证。

我现在对我的审查很有信心。让我写出最终的审查意见。让我核实关于新守卫导致旧行为回退的一个具体顾虑:如果会话因某种原因残留了 PENDING_INTERRUPT=True 但实际上没有待处理的中断(例如部分失败),现在任何新的用户输入都会被拒绝并报 GraphResumeError。这是一个行为变更 —— 以前它会重新开始。这是一个稳定性/兼容性问题。让我评估一下:_clear_pending_interrupt_state 总是在成功恢复时以及图完成时(通过完成路径)被调用。但有没有一种路径会导致图完成但 PENDING_INTERRUPT 仍为 True?让我检查一下完成路径 —— finally 块不会清除 PENDING_INTERRUPT。它只在 resume_command is not None 路径中的 _clear_pending_interrupt_state 内部被清除。在正常运行(无中断)时,PENDING_INTERRUPT 从未被设置为 True(它默认为 False/未设置)。当中断发生时,_create_interrupt_events 会将其设置为 True。在恢复时,它会被清除。所以唯一的残留情况是中断被设置,但会话从未恢复。新的守卫正确地拒绝了重新开始。这是预期的 fail-closed(故障关闭)行为。没问题 —— 这是有意为之并经过测试的。

让我完成最终工作。让我核实 interrupt_id_mismatch 的测试预期是否与代码匹配:测试设置 pending_id=...approval:pending,响应 id=...approval:stale_extract_resume_commandpending_id 是字符串,function_response_id != pending_id → 返回 None。所以 resume_command=Nonepending_resume=True。响应不为 None → 原因=interrupt_id_mismatch。测试预期 reason=interrupt_id_mismatchpending_interrupt_id=pending_idresponse_id=response.id。代码:pending_id if isinstance(pending_id, str) ✓,response.id ✓。匹配。

现在让我写审查意见。最后让我检查一个真正的问题:非响应输入测试预期 reason=missing_function_response。设置:pending_id=...approval:pendinguser_content=text("continue")_current_function_responseuser_content 不为 None,遍历部分,没有 function_response → 返回 None(来自 user_content 分支的 return None)。所以响应=None → 原因=missing_function_response。测试预期 missing_function_response ✓。很好。

但是等等 —— 测试中还有一个 stale_response_event 被附加到会话事件中。但 _current_function_response 优先处理 user_content(即文本“continue”),并在没有找到 function_response 时返回 None,永远不会回退到会话尾部。这是关键的新行为(优先使用 user_content)。很好 —— 已测试。

我现在已经完全理解了。让我写出审查意见。最值得注意的可验证问题:

  1. openai>=1.66.0 的依赖提升 —— 兼容性警告。
  2. 代理节点 HITL:_reject_concurrent_pending_hitl 读取 parent_ctx.state,但如果兄弟节点在同一个超步(superstep)中已经写入,这属于最佳尝试。并发 HITL 不受支持并会引发异常。这是可以接受的。

让我核实是否还有我遗漏的关键问题。让我再仔细检查一下 _build_interrupt_function 的参数合并问题 —— 它是否会丢失原始的工具参数?

当代理节点中断时:负载 arguments = {**response, **arguments}(响应字段被参数覆盖)。当为客户端转换回来时:visible_args = raw_args.get("arguments")(合并后的字典)。因此,客户端看到的是合并后的参数。然后客户端提交回复 → _resume_content 创建 FunctionResponse(id=fc_id, response=human_response)。子代理在恢复时接收到此回复。子代理的 LongRunningEvent.function_call(原始的)具有真实的参数。因此,转换过程中原始参数的丢失并不重要,因为子节点是从其自己的 function_call(已存储)恢复的,而不是从中断负载中恢复的。很好 —— 没有实际的 Bug。

让我最后核实一下:_resume_content 使用 function_call.get("id") 作为 fc_id。子代理的 LongRunningEvent 具有 function_call.id = "child-question-1"。这被存储起来。在恢复时,fc_id="child-question-1"。创建的 FunctionResponse id 为 "child-question-1"。子代理的 _function_response(ctx.user_content) 读取它。测试断言 received_call_ids == ["child-question-1", "child-question-2"]。很好。

我已经彻底分析了。代码质量很高,测试充分。让我写下审查意见。

发现的问题

⚠️ Warning

  • pyproject.toml:28openai 最低版本从 1.3.0 提升到 1.66.0 是破坏性依赖变更

    • 这是为支持 Responses API(responses.create)的必要提升,但会强制所有下游用户升级 openai SDK,可能破坏现有部署的依赖锁定。建议在 CHANGELOG/发布说明中显式标注此 breaking change,并确认 CI 与文档中的最低版本说明同步更新。
  • trpc_agent_sdk/dsl/graph/_graph_agent.py:169-185:pending resume 守卫将「pending_id 缺失但 PENDING_INTERRUPT=True」的会话也按可恢复处理

    • has_graph_resume_state 只校验 STATE_KEY_PENDING_INTERRUPT is True,而 _extract_resume_commandpending_id 非字符串时跳过 id 一致性校验,仅做 long-running 前缀匹配。若历史会话残留 PENDING_INTERRUPT=TruePENDING_INTERRUPT_ID 为空/None,一个带有任意 __trpc_graph_long_running__ 前缀 id 的 FunctionResponse 即可触发恢复。建议在守卫中要求 pending_id 为有效字符串,否则归类为 interrupt_id_mismatch,避免弱校验下的误恢复。

💡 Suggestion

  • trpc_agent_sdk/models/_openai_model.py:2186-2190:Responses 流式 finallyclose_http_client 做了异常吞咽,而 Chat Completions 的 _generate_stream_openai_model.py:2443)未做同样保护
    • 两条路径资源清理容错不一致;为统一行为,可让 Chat Completions 流式也包一层 try/except,或在文档中说明差异。属维护性改进,不影响正确性。

总结

整体改动质量较高,HITL 多轮恢复、Responses API 适配、graph-internal state 边界过滤等关键路径均有对应单测与端到端测试覆盖,未发现明确的阻塞级缺陷。主要需关注 openai 最低版本提升带来的兼容性影响,以及 pending resume 守卫在 pending_id 缺失场景下的弱校验。

测试建议

  • 补充一个用例:会话状态为 PENDING_INTERRUPT=TruePENDING_INTERRUPT_ID=None/空 时,提交带 long-running 前缀 id 的 FunctionResponse,断言被 GraphResumeError(interrupt_id_mismatch)拒绝而非误恢复。
  • 暂无其他额外测试建议。

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