fix(agent): restore structured-output self-healing retry in fallback path#2047
fix(agent): restore structured-output self-healing retry in fallback path#2047zhangliyuangit wants to merge 2 commits into
Conversation
…path The 1.x StructuredOutputHook guaranteed structured output on the tool-based path: when the model answered with plain text instead of calling generate_response, it injected a reminder message and retried the reasoning step with tool_choice forced to the synthetic tool (up to 3 attempts). This enforcement loop was dropped in the 2.0 rewrite while its supporting infrastructure survived unused: StructuredOutputReminder still documents TOOL_CHOICE as "the default and recommended option", MessageMetadataKeys.STRUCTURED_OUTPUT_REMINDER still exists, and compressStructuredOutputContext still cleans reminder messages up - but nothing ever injected them. As a result, doFallbackStructuredCall relies entirely on the model volunteering the generate_response call. When it answers in plain text the raw message is returned as-is, hasStructuredData() is false, and getStructuredData() throws IllegalStateException (see agentscope-ai#641, agentscope-ai#1103). Restore the 1.x semantics inside the ReAct loop: - When a fallback structured-output round finishes without any tool call, append a TOOL_CHOICE reminder message (tagged with the existing reminder metadata so context compression removes it) and re-enter reasoning, up to STRUCTURED_OUTPUT_MAX_RETRIES (3) times. - On the retry round (last input message is a TOOL_CHOICE reminder), force tool_choice to generate_response via GenerateOptions, so the constraint is enforced at the API level rather than by prompt alone. - After exhausting retries the plain-text message is returned unchanged, matching both the current give-up behaviour and 1.x. The native response_format path is unaffected (soTool == null there), and rounds that call business tools are normal ReAct steps and never trigger the reminder. Fixes agentscope-ai#641 Refs agentscope-ai#1103 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR restores the 1.x “self-healing” behavior for fallback structured output in ReActAgent: when the model ends a structured-output call without invoking the synthetic generate_response tool, the agent injects a reminder and retries (up to 3 times), forcing tool_choice on the retry round to guarantee the tool call at the API level.
Changes:
- Add a retry loop in the ReAct post-reasoning finish path to re-enter reasoning after injecting a structured-output reminder (bounded by
STRUCTURED_OUTPUT_MAX_RETRIES). - Force
GenerateOptions.toolChoice = Specific("generate_response")when the retry round is identified as a TOOL_CHOICE reminder round. - Add
ReActAgentStructuredOutputRetryTestcovering success-after-retry, give-up-after-max-retries, and no-retry-when-voluntary.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java | Implements structured-output fallback retries + forced tool_choice, plus helper reminder detection/building. |
| agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentStructuredOutputRetryTest.java | Regression tests verifying the retry loop and max-retry behavior. |
| state.contextMutable() | ||
| .add(createStructuredOutputReminder()); | ||
| return reasoning(iter + 1, true); |
There was a problem hiding this comment.
Intentional, keeping as-is. Retaining the plain-text attempt in the context is what lets the forced retry round restate the same content through the tool call — 1.x behaved identically (its compressMemory used the same isStructuredOutputRelated criteria and also kept the text attempt), and production ports of the 1.x hook rely on this echo for verbatim consistency between the text round and the tool round. Removing it would change 1.x parity semantics; happy to revisit if maintainers prefer.
| .add(createStructuredOutputReminder()); | ||
| return reasoning(iter + 1, true); | ||
| } | ||
| return Mono.justOrEmpty(eventMsg); |
There was a problem hiding this comment.
Fixed in e5d2b4a. The give-up path now strips reminder-tagged messages from the persisted context before returning, and the regression test asserts a follow-up turn's model input contains no leftover reminders. (Note this is stricter than 1.x, which also leaked reminders on give-up — but the cleanup is clearly right.)
| return Msg.builder() | ||
| .name("system") | ||
| .role(MsgRole.USER) | ||
| .content( |
There was a problem hiding this comment.
Keeping as-is: this is a verbatim port of the 1.x StructuredOutputHook.createReminderMessage (name("system") + role(USER)), and teams migrating from 1.x diff old/new logs against each other — changing the name would break that parity for no functional gain. Provider formatters key off role, which is USER here as before. Can change in a follow-up if maintainers prefer consistency over parity.
| return metadata != null | ||
| && StructuredOutputReminder.TOOL_CHOICE | ||
| .toString() | ||
| .equals( | ||
| metadata.get( | ||
| MessageMetadataKeys.STRUCTURED_OUTPUT_REMINDER_TYPE)); |
There was a problem hiding this comment.
Fixed in e5d2b4a — isToolChoiceReminder now requires STRUCTURED_OUTPUT_REMINDER == true in addition to the TYPE key. (1.x checked only the TYPE key, but the extra guard is strictly safer.)
…der check - Give-up path now removes the injected structured-output reminder messages from the persisted context, so exhausted retries don't pollute subsequent turns (they were only cleaned on success). - isToolChoiceReminder now also requires STRUCTURED_OUTPUT_REMINDER == true in addition to the TYPE key, so unrelated metadata can't accidentally force tool_choice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This PR has conflicts with the git fetch origin
git checkout fix/structured-output-fallback-retry
git rebase origin/main
# resolve conflicts, then:
git push --force-with-leaseThis is a one-time reminder. Feel free to @mention me for a re-review after conflicts are resolved. Automated notification by github-manager-bot |
Problem
On the tool-based (fallback) structured-output path, 2.0 relies entirely on the model volunteering the
generate_responsecall. When the model answers with plain text instead — which happens routinely with longer contexts and busier toolkits — the raw text message is returned as-is:hasStructuredData()isfalseandgetStructuredData()throwsIllegalStateException: No structured output in message metadata.This is a regression from 1.x, whose built-in
StructuredOutputHookguaranteed the call: on a no-tool-call round it injected a reminder message and retried the reasoning step withtool_choiceforced to the synthetic tool (up to 3 attempts).The enforcement loop was dropped in the 2.0 rewrite while its supporting infrastructure survived unused:
StructuredOutputReminderstill documentsTOOL_CHOICEas "the default and recommended option" — but nothing consumes it;MessageMetadataKeys.STRUCTURED_OUTPUT_REMINDER/STRUCTURED_OUTPUT_REMINDER_TYPEstill exist;compressStructuredOutputContext/isStructuredOutputRelatedstill clean reminder messages out of the final context — but nothing ever injected them.The v2 docs (
building-blocks/agent.md, Structured Output section) promise that tools and structured output compose transparently on either path, which the current implementation cannot guarantee.Fixes #641 (model emitted
<tool_call>pseudo-markup / fenced JSON as plain text →IllegalStateException)Refs #1103
Fix
Restore the 1.x semantics inside the ReAct loop (
ReActAgent):soTool != null) finishes with no tool call at all, append aTOOL_CHOICEreminder message (tagged with the existing reminder metadata, so context compression removes it as before) and re-enterreasoning(iter + 1, true), up toSTRUCTURED_OUTPUT_MAX_RETRIES(3, same as 1.x).tool_choiceon the retry round — when the last input message is aTOOL_CHOICEreminder, mergeToolChoice.Specific("generate_response")into the round'sGenerateOptions, enforcing the call at the API level rather than by prompt alone (port of 1.xhandlePreReasoning).Out of scope, unchanged by design:
response_formatpath (soTool == nullthere);StructuredOutputReminder.PROMPTmode remains unwired (this PR implements the documented default,TOOL_CHOICE).Tests
New
ReActAgentStructuredOutputRetryTest(TDD — both regression tests fail onmain):tool_choice→ structured data present;generate_responseon round 1 → single model call, no retry (guards existing behaviour).Full
agentscope-coresuite: 2206 tests, 0 failures (9 pre-existing skips). Spotless clean.🤖 Generated with Claude Code