Skip to content

feat(agent_wrapper): add answer_reach_limit to base reply signature#359

Open
xyf2020 wants to merge 2 commits into
agentscope-ai:mainfrom
xyf2020:up
Open

feat(agent_wrapper): add answer_reach_limit to base reply signature#359
xyf2020 wants to merge 2 commits into
agentscope-ai:mainfrom
xyf2020:up

Conversation

@xyf2020

@xyf2020 xyf2020 commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator
  • Add answer_reach_limit parameter to BaseAgentWrapper.reply() abstract method
  • Add pylint disable comments for protected-access in as_agent_wrapper
  • Add 24 unit tests for answer_reach_limit in tests/unit/test_answer_reach_limit.py

xyf2020 added 2 commits July 15, 2026 01:33
- Add answer_reach_limit parameter to BaseAgentWrapper.reply() abstract method
- Add pylint disable comments for protected-access in as_agent_wrapper
- Add 24 unit tests for answer_reach_limit in tests/unit/test_answer_reach_limit.py
The uncommented as_embedding component was eagerly instantiated at startup and failed without embedding credentials (windows smoke test). It was orphaned anyway since file_store.embedding_store is empty, so revert to commented state to restore local-first startup.
@jinliyl

jinliyl commented Jul 16, 2026

Copy link
Copy Markdown
Member

总体结论

这个功能目标是合理的:默认情况下完全保持现有行为;只有显式开启参数,并且 agent 确实因为达到最大迭代/最大 turn 数而退出时,才追加一条 hint message,禁止继续调用工具,并额外调用一次 LLM,让它基于已有上下文生成最终回答。

不过当前实现还没有完全满足这个契约,尤其是“默认行为不变”、配置参数生效范围、异常处理边界以及流式路径。建议修改后再合并。

一、参数命名

answer_reach_limit 不太自然,也存在语义歧义。它读起来更像是“answer 达到了某个 limit”,没有清楚表达“达到执行限制后,再生成一次最终回答”。

建议改为:

generate_final_answer_on_limit: bool = False

这个名字能比较准确地表达实际行为:

  1. 触发条件是 agent 达到执行限制;
  2. 触发后会额外生成一次回答;
  3. 生成的是最终回答,而不是继续正常的 reasoning/tool loop。

如果希望名字更短,也可以考虑:

finalize_on_limit: bool = False

finalize 的具体含义需要在 docstring 中解释清楚。summarize_on_limit 不太建议,因为 “summarize” 容易与上下文压缩、memory summary 或结构化总结混淆;这里实际需要的是“基于当前上下文直接给出最终回答”,不一定只是摘要。

如果未来可能增加多种超限策略,则可以进一步设计为:

limit_reached_behavior: Literal["return", "generate_final_answer"] = "return"

但当前只有开/关两种行为,使用 generate_final_answer_on_limit 这个布尔参数更简单。

二、期望的行为契约

建议明确以下行为:

默认关闭

generate_final_answer_on_limit=False

必须完整保留原有逻辑,包括:

  • 返回值来源不变;
  • 原来会抛出的异常继续抛出;
  • session/context 的保存方式不变;
  • structured output 行为不变;
  • 不增加额外的模型调用。

显式开启

只有同时满足以下条件时,才执行额外调用:

  1. generate_final_answer_on_limit=True
  2. 后端明确报告命中了 max_itersmax_turns
  3. 已有上下文/session 足够支持继续生成。

额外调用应该:

  • 在原有上下文/session 后追加一条 hint;
  • 明确要求直接回答,不再继续执行任务;
  • 禁止工具调用;
  • 最多额外调用一次 LLM,不能再次进入无限 reasoning/tool loop;
  • 只有额外调用成功产生有效文本时,才替换原结果;
  • 额外调用失败时,应保留原来的结果或原来的异常语义,而不是隐藏无关错误。

推荐的整体流程是:

先执行原有 reply 逻辑
        ↓
默认仍按原逻辑构造返回值
        ↓
参数开启,并且明确命中迭代上限
        ↓
追加 hint,禁用工具,额外调用一次 LLM
        ↓
调用成功才使用新的最终回答
        ↓
调用失败则保留原行为

三、AgentScope 后端:默认逻辑已经发生变化

原实现是:

await agent.reply()
await self._dump_state(agent.state)
last_msg = agent.state.context[-1]

PR 改成了使用:

final_msg = await agent.reply()
result_text = final_msg.get_text_content()

这两个值在正常完成时可能相同,但达到 max_iters 时并不等价。AgentScope 在超限时返回的 final_msg 是类似下面的占位消息:

Executed maximum iterations of reasoning-acting loop without finishing the task.

而原实现从 agent.state.context[-1] 取值。因此即使参数保持默认 False,超限路径的返回结果也已经改变,不符合“默认保持原逻辑”的要求。

建议保留原有返回值构造方式,只在参数开启且确认达到上限后,用额外生成的回答覆盖 result 和对应的 last_message

测试中的 A3 当前断言默认关闭时返回超限占位消息,这实际上固定了新的行为,无法验证向后兼容。建议增加回归测试,明确断言参数关闭时的返回结构与修改前一致。

四、参数放在组件配置中可能不会生效

当前签名类似:

async def reply(
    self,
    inputs: Any,
    answer_reach_limit: bool = False,
    **kwargs,
) -> dict:

随后虽然调用了:

kwargs = self._merged_kwargs(kwargs)

但合并后的配置不会赋值回局部变量 answer_reach_limit。因此如果用户在组件配置中写:

agent_wrapper:
  default:
    generate_final_answer_on_limit: true

这个值会进入 self.kwargs,但 reply() 中的局部参数仍然是默认的 False。目前只有每次显式调用下面的形式才会真正开启:

await wrapper.reply(
    prompt,
    generate_final_answer_on_limit=True,
)

需要明确这个参数究竟只支持 call-time,还是同时支持组件配置。如果两种方式都支持,建议统一解析优先级:

本次 reply 显式参数 > component 配置 > False

为了区分“本次调用没有指定”和“本次调用显式传入 False”,可以将公开参数定义为 bool | None = None,然后在方法内部解析最终值;也可以继续把它作为普通 wrapper kwarg,统一从合并后的 kwargs 中读取。

五、Claude Code 后端:异常吞掉范围过大

当前代码在 query iterator 抛出异常时使用了类似条件:

if not (answer_reach_limit and last_msg is not None):
    raise

这意味着只要参数开启且已经收到过任意一个 ResultMessage,后续任何异常都会被吞掉。即使 last_msg.subtype == "success",后续发生连接错误、CLI 进程错误或其他真实故障,也会被忽略。

建议至少限制为:

if not (
    generate_final_answer_on_limit
    and last_msg is not None
    and self._is_max_turns_result(last_msg)
):
    raise

如果 SDK 提供了可识别的尾随 ProcessError/错误文本,还应该进一步确认异常确实是 error_max_turns 对应的预期非零退出,而不是仅凭 last_msg 推断。

测试 C8 当前把“成功 ResultMessage 后发生任意异常也应吞掉”作为预期,这会掩盖上述问题。建议改成:

  • error_max_turns 后的已知尾随退出可以按设计处理;
  • success 后的异常必须继续抛出;
  • 非 max-turns 的 error result 后的异常也不能因为这个参数而被吞掉。

六、Claude Code 后端:第二次调用应该保留完整结果语义

_force_answer() 当前只返回第二次调用的字符串,然后继续复用第一次 error_max_turnsResultMessage。这样返回结构中可能出现不一致:

  • result 已经是第二次生成的最终回答;
  • subtype 仍然是第一次的 error_max_turns
  • is_error 仍然是 True
  • usage、cost、stop reason 仍然属于第一次调用;
  • structured output 也没有反映第二次调用。

建议 _force_answer() 返回第二次调用的完整 ResultMessage,先确认第二次结果不是错误且包含有效文本,再基于第二次结果构造最终返回值。至少也要明确哪些字段代表整个操作、哪些字段代表第一次或第二次模型调用,避免 resultlast_message 自相矛盾。

另外,第二次调用本身也可能再次返回 error_max_turns 或其他错误,不能只判断 last_msg.result 非空就视为成功。

七、流式路径尚未支持

当前改动只实现了 reply()reply_stream() 没有对应逻辑。因此:

  • 非流式调用开启参数后可能生成最终回答;
  • 流式调用达到上限后仍然直接结束;
  • 相同 wrapper 配置在两条路径上的行为不一致。

需要明确此功能是否要求支持 streaming:

  • 如果要求支持,应在收到明确的超限事件后执行一次最终生成,并将新回答作为 content chunks 输出,然后正确结束 stream;
  • 如果暂时只支持非流式,应在参数文档中明确说明,并避免让调用方误以为 reply_stream() 也支持该配置。

八、AgentScope 的额外模型调用与流式响应处理

当前实现通过 AgentScope 的 protected methods:

agent._prepare_model_input()
agent._call_model(...)

功能上可以复用 model retry 和 middleware,但这会直接依赖 AgentScope 的内部 API,升级依赖时容易失效。建议优先确认是否有公开接口能够表达“基于现有上下文、禁用工具、只生成一次最终回答”。如果确实只能使用内部接口,应把这部分封装成一个很窄的 helper,并增加与固定 AgentScope 版本对应的测试。

此外,流式 ChatResponse 不宜笼统地取“最后一个 chunk”,而应确认拿到的是 is_last=True 的完整累计响应。测试最好使用与 AgentScope 2.0.4 实际 streaming contract 一致的响应序列,而不只是两个各自包含完整文本的 fake chunks。

九、测试建议

目前测试数量很多,但主要基于高度简化的 mock,部分测试还把需要修复的行为写成了预期。建议重点补充以下契约测试:

  1. 参数默认关闭时,两个后端的正常、超限和异常行为均与修改前一致;
  2. 参数开启但未达到上限时,不发生额外模型调用;
  3. 只有明确的 max_iters/error_max_turns 才触发额外调用;
  4. 组件配置和 call-time 参数按约定优先级生效;
  5. call-time 显式 False 能覆盖组件配置中的 True
  6. 最终生成禁止工具调用,并且最多额外调用一次;
  7. 最终生成失败或返回空内容时,不伪装成成功;
  8. Claude Code 的正常结果后出现异常时仍然抛出;
  9. 第二次 Claude Code 调用的错误结果不能覆盖成成功;
  10. AgentScope 使用真实 ChatResponse streaming 形态测试最终累计结果;
  11. 如果承诺支持 streaming,补充 reply_stream() 的超限测试;如果不支持,则补充明确的边界测试和文档。

十、无关改动

default.yaml 中 embedding store 的改动与本功能无关,建议从这个 PR 中移除,避免功能 PR 混入独立配置修复。如果该修改确实需要保留,最好拆成单独 PR,并单独说明原因和验证结果。

建议的最小修改方向

  1. 将参数重命名为 generate_final_answer_on_limit
  2. 默认值保持 False,并确保默认路径逐行保持原有语义;
  3. 明确并实现 component config 与 call-time 参数优先级;
  4. 仅在明确达到上限时追加 hint 并额外调用一次;
  5. 第二次调用禁用所有工具,且不会再次进入 agent loop;
  6. 只在第二次调用真正成功时替换结果;
  7. 严格限制 Claude Code 可忽略的尾随异常类型;
  8. 决定并说明 streaming 是否属于本次功能范围;
  9. 修正 A3、C8 等当前固化错误行为的测试;
  10. 移除无关的 default.yaml 改动。

在这些问题修复后,这个功能的边界会更清晰,也能真正保证不开启参数时完全向后兼容。

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