mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
9da63c64a5
Fixed `/goal` so completed objectives no longer grade later prompts, while unfinished goals and completion requests that could not be saved remain recoverable with clear guidance about what happens next. --- `/goal` turns a plain-language objective into acceptance criteria and keeps checking each follow-up turn until the work is done. This PR fixes what happens when that lifecycle ends—or when grading or checkpoint persistence fails. Previously, a goal could remain active even after its completion was approved. The next prompt would then still be graded against work that was already finished. Grader connection failures also looked like unmet criteria, and reaching the grading iteration limit did not explain what was left to do. Now, an approved completion clears the goal and its rubric after that transition is saved, so later prompts start clean and `/goal show` reports that no goal is set. The completion message and evidence remain in the thread transcript. Blocked goals stay active because they still need user input and should remain resumable. If the completion cannot be saved, dcode restores the active goal and its pending completion request instead of letting the UI diverge from the thread checkpoint. The request is graded again on the next turn. Grading is also more reliable and actionable: - A transient mid-response transport failure retries the grader once without rerunning the agent turn, so edits and tool calls are not repeated. - A persistent grader or infrastructure failure is identified as such and leaves the unfinished goal intact for a safe retry. - If grading reaches its iteration limit, dcode shows the unmet criteria and how to resume, amend, or clear the goal. Headless runs show the same criterion-level gaps. ## User stories and before/after examples <details> <summary>Completed goals stop grading unrelated follow-up prompts</summary> **User story:** As a user who has finished a goal, I want it cleared so later prompts start clean and `/goal show` reflects that there is no current goal. **Before** 1. I complete “Add refresh-token support,” and dcode approves the completion. 2. I ask, “Now explain how cache eviction works.” 3. dcode still grades that explanation against the refresh-token acceptance criteria. **After** 1. Once completion is approved and saved, dcode clears the goal, rubric, status, and pending completion state. 2. “Now explain how cache eviction works” runs without the completed goal attached. 3. `/goal show` reports “No goal set.” The earlier completion message and evidence remain in the thread transcript. </details> <details> <summary>A completion that cannot be saved remains safe to retry</summary> **User story:** As a user whose thread checkpoint is temporarily unavailable, I want dcode’s displayed state to match the saved thread so resuming cannot silently lose or misreport my goal. **Before** 1. The grader approves completion, but saving the completed state fails. 2. dcode can look complete in the current session even though the thread still contains an active goal and pending completion. **After** 1. dcode restores the active goal, rubric, and pending completion request in the UI. 2. It reports: “Goal completion could not be saved, so the goal remains active and its completion request is still pending for retry.” 3. The completion request is graded again on the next turn. </details> <details> <summary>A dropped grader response retries grading without repeating the work</summary> **User story:** As a user whose grader connection drops while reading a response, I want dcode to retry the check without rerunning edits or tool calls that already succeeded. **Before** 1. The agent finishes the requested work. 2. The grader connection closes partway through its response. 3. The grading attempt ends even though the agent turn itself succeeded. **After** 1. dcode retries the grader once for recognized mid-response transport failures. 2. Only the grading subagent runs again; the task agent’s edits and tool calls are not repeated. 3. If the retry succeeds, the normal rubric result is used. </details> <details> <summary>Persistent grader failures are not presented as missing user work</summary> **User story:** As a user facing a grader or infrastructure outage, I want dcode to identify the outage instead of telling me that my implementation failed its criteria. **Before** A grader connection failure can look like an unmet rubric, leaving it unclear whether the implementation or the grading service failed. **After** - The transcript identifies a persistent transport problem as “Grader/infrastructure failure.” - If the grader cannot evaluate the rubric, dcode says so explicitly rather than reporting ordinary unmet criteria. - The goal remains active. For an infrastructure failure, its completion request also remains pending and is re-graded on the next turn. </details> <details> <summary>Iteration limits show the remaining gaps and recovery choices</summary> **User story:** As a user whose goal reaches the grading limit, I want to know what is still missing and how to continue. **Before** > ⚠ Acceptance criteria not satisfied (iteration limit reached) The message does not identify the failing criteria or explain what happens to the goal. **After** > ⚠ Iteration limit reached with unmet acceptance criteria > ✗ Refresh-token tests — the expiration case is still failing > The goal remains active. Continue with another prompt to resume or retry, use `/goal <objective>` to amend it, or `/goal clear` to clear it. Interactive and headless runs both show criterion-level gaps, and the unfinished goal remains available for the next action. </details>
98 lines
3.6 KiB
Python
98 lines
3.6 KiB
Python
"""Rubric middleware retries for transient grader transport failures."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
import httpx
|
|
from deepagents.middleware.rubric import GraderResponse, RubricMiddleware, RubricState
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _exception_chain(exc: BaseException) -> Iterator[BaseException]:
|
|
"""Yield an exception, its explicit/implicit causes, and group members once.
|
|
|
|
Descends into `BaseExceptionGroup` members as well as `__cause__` and
|
|
`__context__`, so a transient transport error wrapped in an async task group
|
|
is still discovered. Each exception is yielded at most once.
|
|
"""
|
|
pending = [exc]
|
|
seen: set[int] = set()
|
|
while pending:
|
|
current = pending.pop()
|
|
if id(current) in seen:
|
|
continue
|
|
seen.add(id(current))
|
|
yield current
|
|
if isinstance(current, BaseExceptionGroup):
|
|
pending.extend(current.exceptions)
|
|
if current.__cause__ is not None:
|
|
pending.append(current.__cause__)
|
|
elif current.__context__ is not None:
|
|
pending.append(current.__context__)
|
|
|
|
|
|
def _is_transient_grader_transport_error(exc: BaseException) -> bool:
|
|
"""Return whether a grader failure is a retryable transport/read error.
|
|
|
|
Matches response-read faults (`httpx`/`httpcore` `ReadError`) and
|
|
response-framing faults (`RemoteProtocolError`, aiohttp
|
|
`TransferEncodingError`). Connect/timeout errors are intentionally excluded
|
|
so only mid-response transport failures trigger the retry.
|
|
"""
|
|
for current in _exception_chain(exc):
|
|
if isinstance(current, (httpx.ReadError, httpx.RemoteProtocolError)):
|
|
return True
|
|
error_type = type(current)
|
|
if error_type.__module__.startswith("httpcore") and error_type.__name__ in {
|
|
"ReadError",
|
|
"RemoteProtocolError",
|
|
}:
|
|
return True
|
|
if (
|
|
error_type.__module__ == "aiohttp.http_exceptions"
|
|
and error_type.__name__ == "TransferEncodingError"
|
|
and "Not enough data to satisfy transfer length header" in str(current)
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
class ReliableRubricMiddleware(RubricMiddleware):
|
|
"""Retry one transient grader transport failure without rerunning agent work.
|
|
|
|
The retry re-invokes only the grader sub-agent, never the task agent, so it
|
|
relies on the grader's own tools being read-only/idempotent. A second
|
|
failure — transient or not — propagates to the base middleware, which
|
|
surfaces it as a `grader_error` result rather than a silent success.
|
|
"""
|
|
|
|
def _grade(self, state: RubricState, iteration: int) -> GraderResponse:
|
|
try:
|
|
return super()._grade(state, iteration)
|
|
except Exception as exc:
|
|
if not _is_transient_grader_transport_error(exc):
|
|
raise
|
|
logger.warning(
|
|
"Rubric grader transport failed; retrying grading once",
|
|
exc_info=True,
|
|
)
|
|
return super()._grade(state, iteration)
|
|
|
|
async def _agrade(self, state: RubricState, iteration: int) -> GraderResponse:
|
|
try:
|
|
return await super()._agrade(state, iteration)
|
|
except Exception as exc:
|
|
if not _is_transient_grader_transport_error(exc):
|
|
raise
|
|
logger.warning(
|
|
"Rubric grader transport failed; retrying grading once",
|
|
exc_info=True,
|
|
)
|
|
return await super()._agrade(state, iteration)
|