Repository navigation
fix(dspy): record agent finish reasons for ReAct and ReActV2 - #885
Thanatos9404 wants to merge 5 commits into
Conversation
Assisted-by: Claude Opus 5.5
Pull request dashboard statusWaiting on reviewers · refreshed 2026-10-16 15:46 UTC Review the latest changes. Status above doesn't look right?
|
| return None | ||
|
|
||
|
|
||
| class _AgentRun(ABC): |
There was a problem hiding this comment.
camn we pass in functions isntead of using classes ? I feel like that would be simpler.. also can we improve the typing of these functions so the arguments aren't of type object but something more specific..
| if isinstance(messages, Sequence) and not isinstance( | ||
| messages, (str, bytes) | ||
| ): | ||
| return len(cast(Sequence[object], messages)) |
There was a problem hiding this comment.
i dont think this cast statement is needed.
| role="assistant", | ||
| parts=[TextPart(content=output_str)], | ||
| finish_reason="stop", | ||
| finish_reason=finish_reason, |
There was a problem hiding this comment.
OutputMessage.finish_reason is deprecated in util in favor of gen_ai.response.finish_reasons, which this PR now sets, so we could drop it here (and the matching assertion in assert_agent_finish_reason).
| finish_reason=finish_reason, |
| def _react_finish_reason( | ||
| result: Prediction, max_iters: int | None, input_history_length: int | ||
| ) -> str | None: | ||
| trajectory: Mapping[str, Any] | None = getattr(result, "trajectory", None) | ||
| if not isinstance(trajectory, Mapping): | ||
| return None | ||
| tool_names = [ | ||
| value | ||
| for key, value in trajectory.items() | ||
| if key.startswith("tool_name_") | ||
| ] | ||
| if tool_names and tool_names[-1] == "finish": | ||
| return "stop" | ||
| if max_iters is None: | ||
| return None | ||
| # Truncation drops the oldest trajectory steps but keeps the newest. | ||
| if max_iters <= 0 or f"tool_name_{max_iters - 1}" in trajectory: | ||
| return "max_iters" | ||
| return "error" |
There was a problem hiding this comment.
Telling max_iters apart by checking whether the step count reached the limit is a weak signal. It depends on how DSPy numbers and truncates steps, and it guesses at a cause DSPy doesn't report. Not reaching finish is an abnormal end either way, which semconv covers with error:
| def _react_finish_reason( | |
| result: Prediction, max_iters: int | None, input_history_length: int | |
| ) -> str | None: | |
| trajectory: Mapping[str, Any] | None = getattr(result, "trajectory", None) | |
| if not isinstance(trajectory, Mapping): | |
| return None | |
| tool_names = [ | |
| value | |
| for key, value in trajectory.items() | |
| if key.startswith("tool_name_") | |
| ] | |
| if tool_names and tool_names[-1] == "finish": | |
| return "stop" | |
| if max_iters is None: | |
| return None | |
| # Truncation drops the oldest trajectory steps but keeps the newest. | |
| if max_iters <= 0 or f"tool_name_{max_iters - 1}" in trajectory: | |
| return "max_iters" | |
| return "error" | |
| def _react_finish_reason(result: Prediction) -> str: | |
| trajectory: object = getattr(result, "trajectory", None) | |
| if isinstance(trajectory, Mapping): | |
| tool_names = [ | |
| value | |
| for key, value in trajectory.items() | |
| if key.startswith("tool_name_") | |
| ] | |
| if tool_names and tool_names[-1] == "finish": | |
| return "stop" | |
| return "error" |
| def _react_v2_finish_reason( | ||
| result: Prediction, max_iters: int | None, input_history_length: int | ||
| ) -> str | None: | ||
| termination_reason: str | None = getattr( | ||
| result, "termination_reason", None | ||
| ) | ||
| if termination_reason == "submit": | ||
| return "stop" | ||
| if termination_reason == "forced_submit": | ||
| history_length = _count_history_messages( | ||
| getattr(result, "history", None) | ||
| ) | ||
| if max_iters is None or history_length is None: | ||
| return None | ||
| # Each completed turn and the forced submit add one history event. | ||
| turns = history_length - input_history_length - 1 | ||
| return "max_iters" if turns >= max_iters else "error" | ||
| # DSPy < 3.4 returns the break reason when the forced submit fails. | ||
| if termination_reason == "max_iters": | ||
| return "max_iters" | ||
| if termination_reason in ( | ||
| "parse_error", | ||
| "context_window_exceeded", | ||
| "empty_tool_calls", | ||
| ): | ||
| return "error" | ||
| return None |
There was a problem hiding this comment.
Counting history events to tell max_iters from other forced submits depends on DSPy internals. A forced submit means the agent didn't finish on its own, which semconv's error ("ended abnormally ... inferred by the instrumentation") already covers. How about mapping the known values and passing the rest through, like the anthropic and bedrock instrumentations do?
| def _react_v2_finish_reason( | |
| result: Prediction, max_iters: int | None, input_history_length: int | |
| ) -> str | None: | |
| termination_reason: str | None = getattr( | |
| result, "termination_reason", None | |
| ) | |
| if termination_reason == "submit": | |
| return "stop" | |
| if termination_reason == "forced_submit": | |
| history_length = _count_history_messages( | |
| getattr(result, "history", None) | |
| ) | |
| if max_iters is None or history_length is None: | |
| return None | |
| # Each completed turn and the forced submit add one history event. | |
| turns = history_length - input_history_length - 1 | |
| return "max_iters" if turns >= max_iters else "error" | |
| # DSPy < 3.4 returns the break reason when the forced submit fails. | |
| if termination_reason == "max_iters": | |
| return "max_iters" | |
| if termination_reason in ( | |
| "parse_error", | |
| "context_window_exceeded", | |
| "empty_tool_calls", | |
| ): | |
| return "error" | |
| return None | |
| def _react_v2_finish_reason(result: Prediction) -> str | None: | |
| termination_reason: object = getattr(result, "termination_reason", None) | |
| if termination_reason == "submit": | |
| return "stop" | |
| if termination_reason in ("forced_submit", "parse_error"): | |
| return "error" | |
| return termination_reason if isinstance(termination_reason, str) else None |
This also removes _count_history_messages, _get_max_iters, and the extra arguments on _FinishReasonFn.
ReAct and ReActV2 invoke_agent spans never set gen_ai.response.finish_reasons, and the output message always claimed "stop" regardless of how the run ended. Derive the finish reason from the returned prediction, independently of content capture: "stop" when the agent calls finish/submit, "max_iters" when it uses up its iteration budget, and "error" when the loop ends early. The output message finish_reason uses the same value. Fixes open-telemetry#874 Assisted-by: Claude Opus 5.5
Assisted-by: Claude Opus 5.5
Replace the _AgentRun, _ReActRun and _ReActV2Run classes with the module-level functions _react_finish_reason and _react_v2_finish_reason, which _react_forward and _react_aforward now take as an argument. The wrappers read max_iters and the caller's history length before calling the wrapped method, since ReActV2 appends to a caller-provided History in place, and pass both to the function with the returned Prediction. Type the helpers with the DSPy types (Prediction, History, Module and Mapping[str, Any]) instead of object, and drop the cast() calls by narrowing the history messages with isinstance(..., Sized). Assisted-by: Claude Opus 5.5
OutputMessage.finish_reason is deprecated in util in favor of gen_ai.response.finish_reasons, which the agent span already sets. Stop setting it on the ReAct and ReActV2 output message, and have assert_agent_finish_reason check that it is unset instead of comparing it with the span's finish reason. Assisted-by: Claude Opus 5.5
Stop inferring max_iters from step counts and history length, and map only what DSPy reports: - ReAct: stop when the last tool is finish, error otherwise. - ReActV2: submit maps to stop, forced_submit and parse_error map to error, other termination reasons pass through, and anything else gives no finish reason. This removes _count_history_messages, _get_max_iters and the extra _FinishReasonFn arguments. Drop the tests for the max_iters inference (trajectory truncation and caller history), add unit tests for both mappings, and update the changelog fragment to match. Assisted-by: Claude Opus 5.5
6a21a00 to
856a86d
Compare
Description
Fixes #874.
dspy.ReActanddspy.ReActV2agent spans now setgen_ai.response.finish_reasonsfrom how the run ended, whether or not content capture is on. The value isstopwhen the agent callsfinish/submit,max_iterswhen it runs out of iterations, anderrorwhen its loop ends early (invalid tool selection, parse failure, context overflow, or no tool calls). The output messagefinish_reasonnow uses the same value instead of alwaysstop.Known gaps:
max_itersis a custom value, not one of the semconv finish reasons. It follows DSPy's parameter name and themax_stepsvalue smolagents uses.forced_submit. The run counts asmax_iterswhen the number of turns recorded inhistoryreaches the limit. Other forced submits becomeerror, even after a plain-text reply with no tool calls, because DSPy 3.4 doesn't say which cause it was.maindon't flag this attribute on internal agent spans, so whichever PR merges second should remove the react/react_v2finish_reasonsentries.Type of change
How has this been tested?
tox -e py310-test-instrumentation-genai-dspy-oldest(DSPy 3.3.0): 68 passed, 5 skipped (ReActV2 async needs DSPy 3.4)tox -e py314-test-instrumentation-genai-dspy-latest(DSPy 3.4.0): 73 passedtox -e py310-test-instrumentation-genai-dspy-latest: 73 passedtox -e precommitandtox -e typecheck(Python 3.14): passmain: 23 of 25 fail on latest, and 20 of 20 fail on oldest. The two that pass on latest cover the DSPy 3.4 forced-submit exception path, which this change leaves as is.-conformancewas not run locally because Weaver isn't installed and the env's git dependencies hit Windows path limits. Running the react/react_v2 scenarios in-process now gives("stop",); onmainthe attribute is missing.Checklist
AI-assisted (Claude Opus 5.5), disclosed with an
Assisted-by:commit trailer; it was checked with a separate AI review pass and the gates above. I directed this work and will follow up on review.