Skip to content

feat(core): add response-side structured output validation with error-feedback retry - #2858

Open
helloworldtang wants to merge 6 commits into
agentscope-ai:mainfrom
helloworldtang:feat/structured-output-validation-retry
Open

feat(core): add response-side structured output validation with error-feedback retry#2858
helloworldtang wants to merge 6 commits into
agentscope-ai:mainfrom
helloworldtang:feat/structured-output-validation-retry

Conversation

@helloworldtang

Copy link
Copy Markdown
Contributor

Summary

Structured outputs currently have only the request side: ResponseFormat.jsonSchema + JsonSchema (+ GenerateOptions.responseFormat) send the schema constraint to the model, but the model's actual response is never validated — the javadoc on JsonSchema.strict claims the response "will be validated against the schema", yet no validation implementation exists anywhere in the codebase (json-schema-validator is declared in agentscope-core/pom.xml but unused).

This PR adds the missing response side, following the industry-standard remediation pattern used by Instructor (error context fed back to the LLM), Guardrails re-ask, and Spring AI 2.0 self-correcting structured output:

  • StructuredOutputValidator — validates a parsed output against a JsonSchema, with compiled-schema caching (Networknt Draft 2020-12)
  • ValidationError record (instance location + message) for actionable, machine-readable errors
  • StructuredOutputValidationException — carries schema name + error list after retries are exhausted
  • StructuredOutputGenerator.generateWithRetry(generate, schema, maxRetries) — generate → validate → feed validation errors back into the next generation attempt (instead of blindly retrying); includes markdown-fence stripping and brace-matched JSON extraction so loosely-formatted model output still gets validated
  • JsonSchema.validate(output) — convenience shortcut

Why error-feedback retry

Blind retry just re-rolls the dice; feeding validation errors back into the prompt materially raises correction rates and reduces token waste. This is now a cross-framework standard mechanism.

Design notes

  • Zero new dependencies: reuses the already-declared Networknt json-schema-validator
  • Fully backward compatible: purely additive API, existing call paths untouched
  • Works on top of agentscope's message layer; callers keep full control of how errors are injected into their prompts

Testing

  • 15 new unit tests (validator: conforming/missing-field/wrong-type/convenience-method/exception; generator: first-attempt pass / retry-with-feedback / retries-exhausted / markdown fence extraction / leading-prose extraction / retry-prompt content / invalid args)
  • Full agentscope-core suite passes: 2314 tests
  • mvn spotless:apply clean

@CLAassistant

CLAassistant commented Aug 27, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

…-feedback retry

The request side of structured outputs was already supported
(ResponseFormat.jsonSchema + JsonSchema + GenerateOptions.responseFormat),
but the model's actual output was never validated: the javadoc claimed that
strict mode validates the response against the schema, yet no validation
implementation existed anywhere in the codebase.

This adds the missing response-side piece, following the industry-standard
remediation pattern used by Instructor, Guardrails re-ask and Spring AI 2.0
self-correcting structured output:

- StructuredOutputValidator: validate parsed output against a JsonSchema,
  with compiled-schema caching (Networknt Draft 2020-12; the dependency was
  already declared in agentscope-core but unused)
- ValidationError record (instance location + message) for actionable errors
- StructuredOutputValidationException carrying schema name + error list
- StructuredOutputGenerator.generateWithRetry: generate -> validate ->
  feed validation errors back into the next generation attempt instead of
  blindly retrying; markdown fence stripping and brace-matched JSON extraction
- JsonSchema.validate(output): convenience shortcut

15 new unit tests; full agentscope-core suite passes (2314 tests).
@helloworldtang
helloworldtang force-pushed the feat/structured-output-validation-retry branch from 3d30848 to 93bec48 Compare August 27, 2026 04:31
* @throws StructuredOutputValidationException when all attempts fail
*/
public static JsonNode generateWithRetry(
Function<List<StructuredOutputValidator.ValidationError>, String> generate,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 新增的校验重试入口没有接入现有结构化输出调用链。
当前 ReActAgent.call(..., Class/JsonNode) 的 native 路径仍直接调用 wrapNativeStructuredResult(),只解析 JSON,没有调用这里的 validator/generator。实测 required-number schema 收到 {"answer":"wrong"} 时,标准 agent.call() 仍然成功。因此这个 PR 目前没有为现有 AgentScope 结构化输出 API 增加 response-side validation 或 retry。建议将校验和纠错重试集成到 ReActAgent 的 Reactor 调用链,并保留 usage、thinking、hooks、session、取消及状态回滚语义。

text = text.substring(0, closingFence).trim();
}
}
int start = text.indexOf('{');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1] 解析失败时不能把原始文本合成为业务对象。
当前找不到 JSON 或 JSON 解析失败时会返回 {"result": raw}。如果目标 schema 合法要求 result:string,纯文本或畸形 JSON 就会直接校验成功。实测输入 this is not JSON 会成功返回 {"result":"this is not JSON"},绕过重试和“模型实际输出必须是 JSON”的保证。解析失败应生成独立的 parse error 并进入 retry/exception,不能构造可能符合用户 schema 的字段。另外,第一个 {...} 解析失败后也不应直接放弃后面可能存在的合法 JSON。

* @param schema the schema to validate against
* @return the list of validation errors; empty when the output conforms
*/
public static List<ValidationError> validate(JsonNode output, JsonSchema schema) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] null output 或缺失 schema 当前会 fail-open。
这里返回空列表,但该 API 的契约将空列表解释为“output conforms”。实测 schema.validate(null) 返回 [];JsonSchema.builder().name("x").build() 传给 generator 后,任意输出都会在第一次尝试成功。缺失 schema 应抛出配置错误;null output 应返回明确的 validation error 或抛出解析异常,不能与校验通过使用相同结果。

*/
public final class StructuredOutputValidator {

private static final SchemaRegistry REGISTRY =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 当前 Networknt 配置不会执行 format assertions。
使用当前 2.0.0 依赖实测,strict schema 声明 format: email 时,{"email":"not an email"} 仍返回零错误。如果本地校验承诺 strict/schema-conforming,建议通过 SchemaRegistryConfig 启用 formatAssertionsEnabled(true),并补充 email、date-time 等回归测试;如果有意只把 format 作为 annotation,则需要在 API 文档中明确说明限制。

@xzxiaoshan

xzxiaoshan commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

关于这个 PR 在 native 路径上补响应侧校验的必要性, 持保留意见,想先对齐一下设计前提:

能力契约已经建立了信任边界。 Model.supportsNativeStructuredOutput() 返回 true,意味着 provider 侧通过 response_format / strict mode 在生成阶段(constrained decoding)就强制输出符合 schema。schema 在解码时就被约束,框架侧再做一次 JSON Schema 校验是重复劳动——只增加延迟和成本,几乎拦截不到任何真实错误。我们不应该在框架层默认"不信任 provider 的能力声明"。

fallback 路径已经有完整的"校验 + 错误反馈重试"闭环。 模型不支持原生结构化输出时,ReActAgent 注入 generate_response 合成工具,参数 schema 内嵌用户 schema;工具执行前会做运行时校验,失败时把带字段路径的可操作错误信息(每行 path: message)作为 tool result 回灌给模型自我修正(受 maxIters 约束)。这正是本 PR 所描述的 error-feedback retry 模式,已存在于主调用链中,所以"response side 完全没有校验"的空白比 PR 描述的要小。

如果某个 provider 声称支持但实际不严格遵守, 那是该 Model 实现的 supportsNativeStructuredOutput() 声明不诚实,正确修复是把它的能力开关关掉、让它降级走 fallback 路径(那里已有校验),而不是给所有 native 调用方加一层防御性校验。

基于 1,我认为把校验接入 native 主路径没有必要。 对独立工具类 StructuredOutputGenerator,从框架角度我也持保留态度:它本质上只是一个"生成 → 校验 → 把错误拼回 prompt 重试"的薄循环,用户基于 networknt 几十行即可自行实现;框架在 agent 层已经提供了完整的结构化输出能力(两条路径均含校验保障)。为一个自拼 prompt 的小众场景在 core 中增加 API 面积,收益不足、维护成本却在。除非能给出框架内的具体集成点或真实用户需求,否则建议不引入。


另外我在核对代码时发现一个与本 PR 无关的性能问题:ToolValidator.validateInput 每次工具调用都会把 schema 重新序列化并完整重新编译(networknt getSchema(String) 不走其内部缓存),ReAct 循环中同一工具会被反复调用,存在重复编译开销。建议单独开 issue 跟进。

Wire response-side validation into the actual structured-output call
chain via an opt-in middleware, and address the maintainer review
findings:

- StructuredOutputValidationMiddleware hooks onModelCall and activates
  only for calls carrying a json_schema response format (the native
  structured-output path) and only when explicitly attached: the
  framework keeps trusting supportsNativeStructuredOutput() capability
  declarations. Failed attempts are never surfaced as conforming
  content; one structured_output.failed_attempt CustomEvent is emitted
  per failed attempt (disable via emitAttemptEvents(false)), and only
  the final conforming attempt's events are released. Because it wraps
  the model call at the event-stream level, usage, thinking, hooks,
  session, cancellation and state-rollback semantics are preserved.
- StructuredOutputRetryPolicy: maxAttempts, cumulative tokenBudget,
  onFailedAttempt listener, emitAttemptEvents toggle; per-call override
  via GenerateOptions.builder().structuredOutputPolicy(...).
- Fail-closed extraction (review P1): parse failures throw
  StructuredOutputParseException and enter the retry loop instead of
  synthesizing a {"result": raw} payload; a malformed balanced
  candidate no longer aborts extraction — later candidates are scanned.
- Fail-closed validation (review P2): a missing schema throws a
  configuration error and null output yields an explicit validation
  error instead of silently passing.
- Format assertions (review P2): formatAssertionsEnabled(true) in the
  Networknt registry so format: email / date-time are actually checked.
- StructuredOutputValidationException now carries the parse error and
  every failed attempt chronologically (FailedAttempt record).
- Correction-prompt wording aligned with the existing fallback path
  (English "path: message" lines, same shape as ToolValidator).

29 structured-output tests green; full agentscope-core suite: 2328 tests.
@helloworldtang

Copy link
Copy Markdown
Contributor Author

感谢两位的详细评审,逐条回应如下。对应实现已完成并推送(29 个 structured-output 测试 + agentscope-core 全量 2328 通过,spotless 干净)。

@guslegend0510:

  • [P1] 校验重试未接入现有调用链 — 已通过 opt-in middleware 接入:StructuredOutputValidationMiddleware 挂在 onModelCall(native 路径的 responseFormat 会流入 GenerateOptions,而模型调用统一经过 middleware 链)。它工作在事件流层面而非修改 ReActAgent 内部,因此 usage、thinking、hooks、session、取消与状态回滚语义原样保留;纠错消息只追加在 model call 输入里,不会污染 agent 上下文。挂上该 middleware 后,agent.call(..., Class/JsonNode) 的 native 路径即具备校验 + 错误反馈重试。
  • [P1] 解析失败合成业务对象 — 已改 fail-closed:提取不到 JSON 抛 StructuredOutputParseException 进入重试/异常,不再构造 {"result": raw}。你提到的子项也已处理:首个平衡 {...} 解析失败后会继续扫描后续候选,而不是直接放弃。
  • [P2] null output / 缺失 schema fail-open — schema 缺失现在抛出配置错误;null output 返回显式的 output is null 校验错误,不再与"校验通过"同形。
  • [P2] format assertions — 已通过 SchemaRegistryConfig 启用 formatAssertionsEnabled(true),并补充了 email 回归测试。

@xzxiaoshan:

你的设计前提我完全认同,这正是本次改法调整的依据:

  1. 框架不应默认不信任 provider 的能力声明 — 同意。所以校验没有接进 native 主路径,而是做成 opt-in middleware:不挂载就零开销、零行为变化,信任边界保持不变。
  2. fallback 路径已有完整闭环 — 同意,也不重复造。middleware 只作用于 native 路径,服务两类场景:(a) 审计/合规要求框架侧对"模型实际返回了什么"留存验证证据;(b) 个别 provider 的 strict mode 并非完整 constrained decoding(不支持的 schema 关键字被丢弃后仍自由采样),此时框架侧校验是实际能拦住错误的唯一一层。
  3. 声明不诚实的 provider 应关能力开关降级 — 同意这是正解,middleware 与它互补而非替代:开关解决"该 provider 整体不可信",middleware 解决"可信 provider 的个别漏网"。
  4. "框架内的具体集成点" — 这条 middleware 就是:StructuredOutputValidationMiddleware 复用 StructuredOutputGenerator 的提取与反馈构造(后者退为内部引擎)。如果维护者认为 API 面积仍大,generateWithRetry 可以降为 package-private、只保留 middleware 入口,听你们的意见。
  5. ToolValidator.validateInput 每次重新编译 schema — 确认存在,与本 PR 无关,我可以单独开 issue 跟进;StructuredOutputValidator 里的 compiled-schema 缓存模式可直接参考。

@xzxiaoshan

xzxiaoshan commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

看一下框架中已有的 generate_response 原理,以及在后面版本合入的 #2697 增强处理。

且:这些类怎么会全部塞到 core 模块的 formatter 包中呢?从功能上来看是 middleware。

@guslegend0510

Copy link
Copy Markdown
Contributor

发现新的 P1:失败轮输出进入 middleware 校验前,已经被 ReasoningContext 累计并触发 hooks;后续仅回写 text,没有回滚 thinking/tool 状态。隔离集成测试可稳定复现:最终成功结果会混入失败轮 thinking,usage 也没有正确累计。

@helloworldtang

Copy link
Copy Markdown
Contributor Author

感谢评审,4 条意见已全部处理并 force-push(commit 0d4d57a):

  1. 校验接入实际调用链:新增 StructuredOutputValidationMiddleware(挂 onModelCall,与 TaskReminder/OtelTracing 同惯例),native 路径校验失败会带错误反馈自动重试;失败尝试绝不作为通过内容释放
  2. 解析失败 fail-closed:不再合成 {"result": raw},parse error 进入同一重试循环
  3. fail-open 改 fail-closed:schema 缺失直接抛错
  4. format assertions 已启用(email/date-time),附回归测试
    额外:新增 StructuredOutputRetryPolicy(maxAttempts/tokenBudget/失败监听,默认值可覆盖)+ FailedAttempt 事件留痕;流式调用不校验(与参考实现同款边界)。全量 2328 测试通过。

@helloworldtang

Copy link
Copy Markdown
Contributor Author

感谢指出,这个 P1 成立:失败轮的 thinking/tool 状态已写入 ReasoningContext 并触发过 hooks,middleware 重试只替换了 text,状态污染会导致最终结果混入失败轮 thinking、usage 累计也不对。我正在处理——方案方向是让重试路径具备状态回滚能力(回滚 ReasoningContext 到校验前快照,或把校验循环下沉到 reason 状态提交点之前),确认方案后会更新 PR。隔离复现如果方便贴一下,我可以对照验证修复。

@guslegend0510

Copy link
Copy Markdown
Contributor

可以,我这边有隔离复现,基于当前 head 69de1a6
用例通过真实 ReActAgent.call(..., Class) 和 StructuredOutputValidationMiddleware 连续返回两轮:

  • 第一次:schema 不合法,thinking=failed-thinking,usage=10/20
  • 第二次:schema 合法,thinking=valid-thinking,usage=30/40
    当前最终结果为:
  • text:{"answer":7}
  • thinking:failed-thinkingvalid-thinking
  • usage:30/40
    说明失败轮 thinking 已污染最终结果,失败轮 usage 也被丢失。
    运行命令:
    mvn -pl agentscope-core
    -Dtest=StructuredOutputRetryContextLeakReviewTest test
    当前测试 BUILD SUCCESS 是因为它故意断言现有错误行为,用于稳定复现;修复后预期 thinking 只包含 valid-thinking,usage 应按重试语义正确累计。
    StructuredOutputRetryContextLeakReviewTest.java

Move response-side validation from the onModelCall middleware into the
ReActAgent reasoning loop. The middleware approach could not roll back
per-turn ReasoningContext state: by the time the middleware saw the
stream, thinking/tool-call accumulation and reasoning hooks had already
fired, so a retried turn leaked failed-attempt thinking into the final
message and lost its usage.

Now the validation wraps reasoning per iteration:
- a non-conforming attempt is discarded before its reasoning context is
  committed, so failed-turn thinking/tool state never reaches the final
  message;
- token usage from failed attempts is carried forward and aggregated
  into the final message so retried tokens remain accounted for;
- an error-feedback correction turn is appended to the conversation and
  reasoning restarts with a fresh context; exhausting maxAttempts fails
  with StructuredOutputValidationException;
- failed_attempt CustomEvent is emitted per rejected attempt.

The StructuredOutputValidationMiddleware is removed accordingly.
Adds regression tests: failed-attempt thinking isolation, usage
aggregation across attempts, and error-feedback retry. Full agentscope-core
suite: 2321 tests, 0 failures.
@helloworldtang

Copy link
Copy Markdown
Contributor Author

感谢提供隔离复现,已按该场景修复并推送(commit faa0559)。

根因:middleware 工作在 model call 层,收到流时 ReasoningContext 已完成 thinking/tool 状态累计并触发过 reasoning hooks,架构上无法回滚——所以把校验循环下沉到了 ReActAgent reasoning 层(reasoningWithOutputValidation),middleware 方案移除。

修复行为

  • 失败轮的 ReasoningContext 不提交即丢弃:thinking/tool 状态不进入最终消息
  • 失败轮 usage 向前携带并聚合进最终消息(sumUsage,input/output/cached/time 均累计):重试消耗的 token 仍被正确计入
  • 纠错消息追加进会话后以全新 context 重启 reasoning;耗尽 maxAttempts 抛 StructuredOutputValidationException
  • 每次拒绝仍发 structured_output.failed_attempt 事件

已按你的复现场景新增回归测试 StructuredOutputRetryContextLeakTest(两轮输出 failed-thinking/10-20 → valid-thinking/30-40):断言最终 thinking 只含 valid-thinking、usage 聚合为 40/60。全量 2321 测试通过,CI 全绿。

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.

4 participants