mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): make /goal completion and grading reliable (#4691)
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>
This commit is contained in:
@@ -20,7 +20,6 @@ from deepagents.middleware import (
|
||||
GRADER_SYSTEM_PROMPT,
|
||||
FilesystemMiddleware,
|
||||
MemoryMiddleware,
|
||||
RubricMiddleware,
|
||||
SkillsMiddleware,
|
||||
)
|
||||
|
||||
@@ -88,6 +87,7 @@ from deepagents_code.local_context import (
|
||||
_ExecutableBackend,
|
||||
)
|
||||
from deepagents_code.project_utils import ProjectContext, get_server_project_context
|
||||
from deepagents_code.reliable_rubric import ReliableRubricMiddleware
|
||||
from deepagents_code.subagents import list_subagents
|
||||
from deepagents_code.unicode_security import (
|
||||
check_url_safety,
|
||||
@@ -1954,7 +1954,7 @@ def create_cli_agent(
|
||||
}
|
||||
if rubric_max_iterations is not None:
|
||||
rubric_kwargs["max_iterations"] = rubric_max_iterations
|
||||
agent_middleware.append(RubricMiddleware(**rubric_kwargs))
|
||||
agent_middleware.append(ReliableRubricMiddleware(**rubric_kwargs))
|
||||
|
||||
# Create the agent
|
||||
all_subagents: list[SubAgent | CompiledSubAgent | AsyncSubAgent] = [
|
||||
|
||||
@@ -8808,8 +8808,8 @@ class DeepAgentsApp(App):
|
||||
def _clear_all_goal_rubric_state(self) -> None:
|
||||
"""Clear every goal and rubric field (sticky, one-shot, goal, pending).
|
||||
|
||||
Single reset point so the clear paths cannot drift out of sync over the
|
||||
ten correlated fields. Grader settings (`_rubric_model` and
|
||||
Single reset point so the clear paths cannot drift across the correlated
|
||||
fields. Grader settings (`_rubric_model` and
|
||||
`_rubric_max_iterations`) are intentionally left untouched — they are
|
||||
configured separately via `/rubric model` and `/rubric max-iterations`
|
||||
and survive `/rubric clear` and `/clear`.
|
||||
@@ -8920,21 +8920,56 @@ class DeepAgentsApp(App):
|
||||
note: str,
|
||||
*,
|
||||
previous_status: str | None,
|
||||
) -> None:
|
||||
"""Mark a rubric-approved completion request complete."""
|
||||
self._goal_status = "complete"
|
||||
self._goal_status_note = note
|
||||
self._pending_goal_completion_note = None
|
||||
) -> bool:
|
||||
"""Clear a rubric-approved goal after its completion is saved.
|
||||
|
||||
Returns:
|
||||
`True` when the cleared state was persisted, or `False` when the active
|
||||
goal was restored for retry.
|
||||
"""
|
||||
active_goal = self._active_goal
|
||||
goal_status = self._goal_status
|
||||
goal_status_note = self._goal_status_note
|
||||
active_rubric = self._active_rubric
|
||||
pending_goal_completion_note = self._pending_goal_completion_note
|
||||
pending_goal_objective = self._pending_goal_objective
|
||||
pending_goal_rubric = self._pending_goal_rubric
|
||||
next_rubric = self._next_rubric
|
||||
last_consumed_next_rubric = self._last_consumed_next_rubric
|
||||
last_consumed_next_previous_rubric = self._last_consumed_next_previous_rubric
|
||||
|
||||
self._clear_all_goal_rubric_state()
|
||||
self._sync_status_rubric()
|
||||
persisted = await self._persist_goal_rubric_state()
|
||||
await self._announce_goal_status_transition(previous_status)
|
||||
if not persisted:
|
||||
await self._mount_message(
|
||||
ErrorMessage(
|
||||
"Goal marked complete for this session, but it could not "
|
||||
"be saved to the thread."
|
||||
)
|
||||
if persisted:
|
||||
if previous_status != "complete":
|
||||
text = "Goal marked complete by the agent."
|
||||
if note:
|
||||
text = f"{text}\n\n{note}"
|
||||
await self._mount_message(AppMessage(text))
|
||||
return True
|
||||
|
||||
# The checkpoint still contains the active goal and pending completion
|
||||
# request. Restore that exact local state so the UI and next grading
|
||||
# turn cannot diverge from it, and so a later sync can safely retry.
|
||||
self._active_goal = active_goal
|
||||
self._goal_status = goal_status
|
||||
self._goal_status_note = goal_status_note
|
||||
self._active_rubric = active_rubric
|
||||
self._pending_goal_completion_note = pending_goal_completion_note or note
|
||||
self._pending_goal_objective = pending_goal_objective
|
||||
self._pending_goal_rubric = pending_goal_rubric
|
||||
self._next_rubric = next_rubric
|
||||
self._last_consumed_next_rubric = last_consumed_next_rubric
|
||||
self._last_consumed_next_previous_rubric = last_consumed_next_previous_rubric
|
||||
self._sync_status_rubric()
|
||||
await self._mount_message(
|
||||
ErrorMessage(
|
||||
"Goal completion could not be saved, so the goal remains active "
|
||||
"and its completion request is still pending for retry."
|
||||
)
|
||||
)
|
||||
return False
|
||||
|
||||
async def _clear_pending_goal_completion(self, message: str) -> None:
|
||||
"""Clear a completion request that did not become complete."""
|
||||
@@ -8957,6 +8992,28 @@ class DeepAgentsApp(App):
|
||||
if not self._active_goal or not note or self._goal_status == "complete":
|
||||
return False
|
||||
|
||||
if rubric_status == "grader_error":
|
||||
await self._mount_message(
|
||||
ErrorMessage(
|
||||
"Acceptance-criteria grading failed because of a grader or "
|
||||
"infrastructure error. The goal remains active, and its completion "
|
||||
"request is still pending; it will be re-graded on your next turn."
|
||||
)
|
||||
)
|
||||
return False
|
||||
if rubric_status == "max_iterations_reached":
|
||||
await self._clear_pending_goal_completion(
|
||||
"Goal completion was not recorded: the iteration limit was reached "
|
||||
"with unmet criteria. The goal remains active for resume, amendment, "
|
||||
"retry, or clearing."
|
||||
)
|
||||
return False
|
||||
if rubric_status == "failed":
|
||||
await self._clear_pending_goal_completion(
|
||||
"Goal completion was not recorded because the grader could not "
|
||||
"evaluate the rubric. The goal remains active."
|
||||
)
|
||||
return False
|
||||
if rubric_status != "satisfied":
|
||||
await self._clear_pending_goal_completion(
|
||||
"Goal completion was not recorded because the rubric was not satisfied."
|
||||
@@ -8964,11 +9021,10 @@ class DeepAgentsApp(App):
|
||||
return False
|
||||
|
||||
if self._session_state is not None and self._session_state.auto_approve:
|
||||
await self._commit_pending_goal_completion(
|
||||
return await self._commit_pending_goal_completion(
|
||||
note,
|
||||
previous_status=previous_status,
|
||||
)
|
||||
return True
|
||||
|
||||
action_requests = [
|
||||
{
|
||||
@@ -8995,17 +9051,15 @@ class DeepAgentsApp(App):
|
||||
decision_type = decision.get("type") if isinstance(decision, dict) else None
|
||||
if decision_type == "auto_approve_all":
|
||||
await self._on_auto_approve_enabled()
|
||||
await self._commit_pending_goal_completion(
|
||||
return await self._commit_pending_goal_completion(
|
||||
note,
|
||||
previous_status=previous_status,
|
||||
)
|
||||
return True
|
||||
if decision_type == "approve":
|
||||
await self._commit_pending_goal_completion(
|
||||
return await self._commit_pending_goal_completion(
|
||||
note,
|
||||
previous_status=previous_status,
|
||||
)
|
||||
return True
|
||||
|
||||
reject_message = decision.get("message") if isinstance(decision, dict) else None
|
||||
if isinstance(reject_message, str) and reject_message.strip():
|
||||
@@ -9497,6 +9551,7 @@ class DeepAgentsApp(App):
|
||||
self._active_goal = objective
|
||||
self._goal_status = "active"
|
||||
self._goal_status_note = None
|
||||
self._pending_goal_completion_note = None
|
||||
self._active_rubric = rubric
|
||||
self._next_rubric = None
|
||||
self._pending_goal_objective = None
|
||||
@@ -11335,6 +11390,7 @@ class DeepAgentsApp(App):
|
||||
sandbox_type=self._sandbox_type,
|
||||
message_kwargs=message_kwargs,
|
||||
rubric=rubric,
|
||||
goal_active=bool(self._active_goal),
|
||||
blocked_goal_retry_context=blocked_goal_retry_context,
|
||||
# `auto_approve` is intentionally omitted here: execute_task_textual
|
||||
# writes it into this context from `session_state.auto_approve` at
|
||||
|
||||
@@ -745,15 +745,26 @@ def _process_rubric_event(
|
||||
detail = f" — {gap}" if gap else ""
|
||||
console.print(f"[yellow] ✗ {name}{detail}[/yellow]", highlight=False)
|
||||
elif result == "max_iterations_reached":
|
||||
console.print(
|
||||
"[yellow]⚠ Acceptance criteria not satisfied "
|
||||
"(iteration limit reached)[/yellow]",
|
||||
highlight=False,
|
||||
)
|
||||
elif result in {"failed", "grader_error"}:
|
||||
label = "grader failed" if result == "failed" else "grader error"
|
||||
suffix = f": {escape_markup(explanation)}" if explanation else ""
|
||||
console.print(f"[red]⚠ Rubric {label}{suffix}[/red]", highlight=False)
|
||||
message = (
|
||||
"[yellow]⚠ Iteration limit reached with unmet acceptance criteria"
|
||||
f"{suffix}[/yellow]"
|
||||
)
|
||||
console.print(message, highlight=False)
|
||||
for criterion in data.get("criteria", []):
|
||||
if isinstance(criterion, dict) and not criterion.get("passed", True):
|
||||
name = escape_markup(str(criterion.get("name", "criterion")))
|
||||
gap = escape_markup(str(criterion.get("gap", "")).strip())
|
||||
detail = f" — {gap}" if gap else ""
|
||||
console.print(f"[yellow] ✗ {name}{detail}[/yellow]", highlight=False)
|
||||
elif result == "failed":
|
||||
suffix = f": {escape_markup(explanation)}" if explanation else ""
|
||||
console.print(f"[red]⚠ Rubric grader failed{suffix}[/red]", highlight=False)
|
||||
elif result == "grader_error":
|
||||
suffix = f": {escape_markup(explanation)}" if explanation else ""
|
||||
console.print(
|
||||
f"[red]⚠ Grader/infrastructure failure{suffix}[/red]", highlight=False
|
||||
)
|
||||
elif result is not None:
|
||||
# A `rubric_evaluation_end` with an unrecognized result is still a
|
||||
# terminal grading event; surface it rather than letting the run go
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""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)
|
||||
@@ -223,9 +223,15 @@ def _is_summarization_chunk(metadata: dict | None) -> bool:
|
||||
return metadata.get("lc_source") == "summarization"
|
||||
|
||||
|
||||
def _format_rubric_event(data: dict[str, Any]) -> str | None:
|
||||
def _format_rubric_event(
|
||||
data: dict[str, Any], *, goal_active: bool = False
|
||||
) -> str | None:
|
||||
"""Format a rubric custom-stream event for the chat transcript.
|
||||
|
||||
Args:
|
||||
data: Custom-stream rubric event payload.
|
||||
goal_active: Whether the rubric belongs to an unfinished `/goal`.
|
||||
|
||||
Returns:
|
||||
A user-visible message for rubric events, or `None` for custom-stream
|
||||
events that are not rubric events.
|
||||
@@ -264,16 +270,29 @@ def _format_rubric_event(data: dict[str, Any]) -> str | None:
|
||||
lines.append(f" {glyphs.error} {name}" + (f" — {gap}" if gap else ""))
|
||||
return "\n".join(lines)
|
||||
if result == "max_iterations_reached":
|
||||
return (
|
||||
f"{glyphs.warning} Acceptance criteria not satisfied "
|
||||
"(iteration limit reached)"
|
||||
)
|
||||
if result in {"failed", "grader_error"}:
|
||||
label = "grader failed" if result == "failed" else "grader error"
|
||||
return (
|
||||
f"{glyphs.warning} Rubric "
|
||||
+ label
|
||||
lines = [
|
||||
f"{glyphs.warning} Iteration limit reached with unmet acceptance criteria"
|
||||
+ (f": {explanation}" if explanation else "")
|
||||
]
|
||||
for criterion in data.get("criteria", []):
|
||||
if isinstance(criterion, dict) and criterion.get("passed") is False:
|
||||
name = str(criterion.get("name", "criterion"))
|
||||
gap = str(criterion.get("gap", "")).strip()
|
||||
lines.append(f" {glyphs.error} {name}" + (f" — {gap}" if gap else ""))
|
||||
if goal_active:
|
||||
lines.append(
|
||||
"The goal remains active. Continue with another prompt to resume or "
|
||||
"retry, use `/goal <objective>` to amend it, or `/goal clear` to "
|
||||
"clear it."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
if result == "failed":
|
||||
return f"{glyphs.warning} Rubric grader failed" + (
|
||||
f": {explanation}" if explanation else ""
|
||||
)
|
||||
if result == "grader_error":
|
||||
return f"{glyphs.warning} Grader/infrastructure failure" + (
|
||||
f": {explanation}" if explanation else ""
|
||||
)
|
||||
# A `rubric_evaluation_end` with an unrecognized result is still a terminal
|
||||
# grading event; surface it rather than silently dropping it (e.g. if the
|
||||
@@ -513,6 +532,7 @@ async def execute_task_textual(
|
||||
sandbox_type: str | None = None,
|
||||
message_kwargs: dict[str, Any] | None = None,
|
||||
rubric: str | None = None,
|
||||
goal_active: bool = False,
|
||||
blocked_goal_retry_context: str | None = None,
|
||||
turn_stats: SessionStats | None = None,
|
||||
) -> SessionStats:
|
||||
@@ -541,6 +561,7 @@ async def execute_task_textual(
|
||||
in the checkpoint).
|
||||
rubric: Acceptance criteria supplied to `RubricMiddleware` via graph
|
||||
input state.
|
||||
goal_active: Whether the rubric belongs to an unfinished `/goal`.
|
||||
blocked_goal_retry_context: One-turn model context for retrying a
|
||||
previously blocked goal. This is carried via runtime context so it
|
||||
is not parsed for file mentions or checkpointed as human input.
|
||||
@@ -802,7 +823,12 @@ async def execute_task_textual(
|
||||
if current_stream_mode == "custom":
|
||||
rubric_message = data if isinstance(data, dict) else None
|
||||
formatted_rubric_event = (
|
||||
_format_rubric_event(rubric_message) if rubric_message else None
|
||||
_format_rubric_event(
|
||||
rubric_message,
|
||||
goal_active=goal_active,
|
||||
)
|
||||
if rubric_message
|
||||
else None
|
||||
)
|
||||
if formatted_rubric_event is not None and is_main_agent:
|
||||
await adapter._mount_message(AppMessage(formatted_rubric_event))
|
||||
|
||||
@@ -3554,7 +3554,7 @@ class TestCreateCliAgentInterpreterWiring:
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch("deepagents_code.agent.RubricMiddleware") as mock_rubric,
|
||||
patch("deepagents_code.agent.ReliableRubricMiddleware") as mock_rubric,
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent",
|
||||
return_value=mock_agent,
|
||||
|
||||
@@ -6062,6 +6062,7 @@ class TestGoalCommand:
|
||||
app = DeepAgentsApp(agent=MagicMock())
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._pending_goal_completion_note = "evidence for the previous goal"
|
||||
app._pending_goal_objective = "add refresh tokens"
|
||||
app._pending_goal_rubric = "- tests pass"
|
||||
request = AsyncMock(
|
||||
@@ -6080,6 +6081,7 @@ class TestGoalCommand:
|
||||
assert app._active_goal == "add refresh tokens"
|
||||
assert app._goal_status == "active"
|
||||
assert app._active_rubric == "- tests pass"
|
||||
assert app._pending_goal_completion_note is None
|
||||
assert app._pending_goal_objective is None
|
||||
assert app._pending_goal_rubric is None
|
||||
assert app._status_bar is not None
|
||||
@@ -6470,9 +6472,9 @@ class TestGoalCommand:
|
||||
fetch = AsyncMock(
|
||||
return_value={
|
||||
"_goal_objective": "add refresh tokens",
|
||||
"_goal_status": "complete",
|
||||
"_goal_status": "blocked",
|
||||
"_goal_rubric": "- tests pass",
|
||||
"_goal_status_note": "tests pass",
|
||||
"_goal_status_note": "waiting for credentials",
|
||||
}
|
||||
)
|
||||
with patch.object(app, "_get_thread_state_values", fetch):
|
||||
@@ -6480,8 +6482,8 @@ class TestGoalCommand:
|
||||
|
||||
fetch.assert_awaited_once_with("thread-1")
|
||||
assert app._active_goal == "add refresh tokens"
|
||||
assert app._goal_status == "complete"
|
||||
assert app._goal_status_note == "tests pass"
|
||||
assert app._goal_status == "blocked"
|
||||
assert app._goal_status_note == "waiting for credentials"
|
||||
assert app._active_rubric == "- tests pass"
|
||||
|
||||
async def test_sync_goal_completion_auto_commits_after_rubric_satisfied(
|
||||
@@ -6512,13 +6514,177 @@ class TestGoalCommand:
|
||||
with patch.object(app, "_get_thread_state_values", fetch):
|
||||
await app._sync_goal_rubric_state_from_thread()
|
||||
|
||||
assert app._goal_status == "complete"
|
||||
assert app._goal_status_note == "tests pass"
|
||||
assert app._active_goal is None
|
||||
assert app._goal_status is None
|
||||
assert app._goal_status_note is None
|
||||
assert app._active_rubric is None
|
||||
assert app._pending_goal_completion_note is None
|
||||
assert updater.aupdate_state.await_args is not None
|
||||
state_update = updater.aupdate_state.await_args.args[1]
|
||||
assert state_update["_goal_status"] == "complete"
|
||||
assert state_update["_pending_goal_completion_note"] is None
|
||||
assert state_update == {
|
||||
"rubric": None,
|
||||
"_sticky_rubric": None,
|
||||
"_goal_objective": None,
|
||||
"_goal_status": None,
|
||||
"_goal_rubric": None,
|
||||
"_goal_status_note": None,
|
||||
"_pending_goal_completion_note": None,
|
||||
"_pending_goal_objective": None,
|
||||
"_pending_goal_rubric": None,
|
||||
}
|
||||
|
||||
async def test_completed_goal_is_cleared_before_next_turn(self) -> None:
|
||||
app = DeepAgentsApp(agent=MagicMock(), auto_approve=True)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._active_goal = "add refresh tokens"
|
||||
app._goal_status = "active"
|
||||
app._active_rubric = "- tests pass"
|
||||
|
||||
await app._commit_pending_goal_completion(
|
||||
"all acceptance tests pass",
|
||||
previous_status="active",
|
||||
)
|
||||
|
||||
assert app._active_goal is None
|
||||
assert app._active_rubric is None
|
||||
assert app._status_bar is not None
|
||||
assert app._status_bar.rubric_label == ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"deepagents_code.tui.textual_adapter.execute_task_textual",
|
||||
new=AsyncMock(return_value=SessionStats()),
|
||||
) as execute,
|
||||
patch.object(
|
||||
app,
|
||||
"_sync_goal_rubric_state_from_thread",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
await app._run_agent_task("start something else")
|
||||
|
||||
assert execute.await_args is not None
|
||||
assert execute.await_args.kwargs["rubric"] is None
|
||||
assert execute.await_args.kwargs["goal_active"] is False
|
||||
|
||||
await app._show_goal_state()
|
||||
await pilot.pause()
|
||||
rendered = "\n".join(str(w._content) for w in app.query(AppMessage))
|
||||
assert "No goal set." in rendered
|
||||
assert "Last completed goal:" not in rendered
|
||||
|
||||
async def test_failed_completion_persistence_restores_retryable_goal(self) -> None:
|
||||
"""A failed clear write must leave local state aligned for retry."""
|
||||
app = DeepAgentsApp(agent=MagicMock(), auto_approve=True)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._agent = SimpleNamespace(
|
||||
aupdate_state=AsyncMock(side_effect=RuntimeError("down"))
|
||||
)
|
||||
app._lc_thread_id = "thread-1"
|
||||
app._active_goal = "add refresh tokens"
|
||||
app._goal_status = "active"
|
||||
app._active_rubric = "- tests pass"
|
||||
app._pending_goal_completion_note = "all acceptance tests pass"
|
||||
|
||||
committed = await app._resolve_pending_goal_completion(
|
||||
rubric_status="satisfied",
|
||||
previous_status="active",
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
assert committed is False
|
||||
assert app._active_goal == "add refresh tokens"
|
||||
assert app._goal_status == "active"
|
||||
assert app._active_rubric == "- tests pass"
|
||||
assert app._pending_goal_completion_note == "all acceptance tests pass"
|
||||
rendered = "\n".join(str(w._content) for w in app.query(AppMessage))
|
||||
assert "Goal marked complete by the agent." not in rendered
|
||||
errors = "\n".join(str(w._content) for w in app.query(ErrorMessage))
|
||||
assert "goal remains active" in errors
|
||||
assert "completion request is still pending" in errors
|
||||
|
||||
async def test_grader_error_keeps_goal_and_completion_request_active(self) -> None:
|
||||
app = DeepAgentsApp(agent=MagicMock(), auto_approve=True)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._active_goal = "add refresh tokens"
|
||||
app._goal_status = "active"
|
||||
app._active_rubric = "- tests pass"
|
||||
app._pending_goal_completion_note = "tests pass"
|
||||
|
||||
committed = await app._resolve_pending_goal_completion(
|
||||
rubric_status="grader_error",
|
||||
previous_status="active",
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
assert committed is False
|
||||
assert app._active_goal == "add refresh tokens"
|
||||
assert app._goal_status == "active"
|
||||
assert app._active_rubric == "- tests pass"
|
||||
assert app._pending_goal_completion_note == "tests pass"
|
||||
rendered = "\n".join(str(w._content) for w in app.query(ErrorMessage))
|
||||
assert "grader or infrastructure error" in rendered
|
||||
assert "completion request is still pending" in rendered
|
||||
assert "rubric was not satisfied" not in rendered
|
||||
|
||||
async def test_iteration_cap_keeps_goal_active_and_does_not_complete(self) -> None:
|
||||
app = DeepAgentsApp(agent=MagicMock(), auto_approve=True)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._active_goal = "add refresh tokens"
|
||||
app._goal_status = "active"
|
||||
app._active_rubric = "- tests pass"
|
||||
app._pending_goal_completion_note = "tests pass"
|
||||
|
||||
committed = await app._resolve_pending_goal_completion(
|
||||
rubric_status="max_iterations_reached",
|
||||
previous_status="active",
|
||||
)
|
||||
await app._show_goal_state()
|
||||
await pilot.pause()
|
||||
|
||||
assert committed is False
|
||||
assert app._active_goal == "add refresh tokens"
|
||||
assert app._goal_status == "active"
|
||||
assert app._active_rubric == "- tests pass"
|
||||
assert app._pending_goal_completion_note is None
|
||||
rendered = "\n".join(str(w._content) for w in app.query(AppMessage))
|
||||
assert "iteration limit was reached with unmet criteria" in rendered
|
||||
assert (
|
||||
"remains active for resume, amendment, retry, or clearing" in rendered
|
||||
)
|
||||
assert "Goal marked complete" not in rendered
|
||||
assert "Commands:\n/goal clear\n/goal show" in rendered
|
||||
|
||||
async def test_grader_failed_keeps_goal_active_with_evaluation_error(self) -> None:
|
||||
"""A `failed` grade must keep the goal active and blame the grader."""
|
||||
app = DeepAgentsApp(agent=MagicMock(), auto_approve=True)
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
app._active_goal = "add refresh tokens"
|
||||
app._goal_status = "active"
|
||||
app._active_rubric = "- tests pass"
|
||||
app._pending_goal_completion_note = "tests pass"
|
||||
|
||||
committed = await app._resolve_pending_goal_completion(
|
||||
rubric_status="failed",
|
||||
previous_status="active",
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
assert committed is False
|
||||
assert app._active_goal == "add refresh tokens"
|
||||
assert app._goal_status == "active"
|
||||
assert app._active_rubric == "- tests pass"
|
||||
assert app._pending_goal_completion_note is None
|
||||
rendered = "\n".join(str(w._content) for w in app.query(AppMessage))
|
||||
assert "grader could not evaluate the rubric" in rendered
|
||||
assert "The goal remains active." in rendered
|
||||
assert "Goal marked complete" not in rendered
|
||||
assert "rubric was not satisfied" not in rendered
|
||||
|
||||
async def test_sync_goal_completion_rejects_when_rubric_not_satisfied(
|
||||
self,
|
||||
@@ -6593,7 +6759,9 @@ class TestGoalCommand:
|
||||
action_requests = request_approval.await_args.args[0]
|
||||
assert action_requests[0]["name"] == "update_goal"
|
||||
assert action_requests[0]["args"]["status"] == "complete"
|
||||
assert app._goal_status == "complete"
|
||||
assert app._active_goal is None
|
||||
assert app._goal_status is None
|
||||
assert app._active_rubric is None
|
||||
assert app._pending_goal_completion_note is None
|
||||
|
||||
async def test_sync_goal_rubric_state_drops_unknown_status(self) -> None:
|
||||
@@ -6798,6 +6966,8 @@ class TestGoalCommand:
|
||||
rendered = "\n".join(str(w._content) for w in app.query(AppMessage))
|
||||
assert "Goal marked blocked by the agent." in rendered
|
||||
assert "missing staging credentials" in rendered
|
||||
assert app._active_goal == "ship the feature"
|
||||
assert app._goal_status == "blocked"
|
||||
|
||||
async def test_announce_goal_status_no_message_when_unchanged(self) -> None:
|
||||
"""A status equal to the previous one must not re-announce."""
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests for transient rubric grader transport retries."""
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from deepagents.middleware.rubric import GraderResponse, RubricState
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deepagents_code.reliable_rubric import (
|
||||
ReliableRubricMiddleware,
|
||||
_is_transient_grader_transport_error,
|
||||
)
|
||||
|
||||
|
||||
def _read_error() -> httpx.ReadError:
|
||||
return httpx.ReadError(
|
||||
"connection closed while reading",
|
||||
request=httpx.Request("POST", "https://grader.test"),
|
||||
)
|
||||
|
||||
|
||||
def _typed_error(module: str, name: str, message: str = "boom") -> Exception:
|
||||
"""Build an exception whose type mimics an external library's error class."""
|
||||
error_type = type(name, (Exception,), {"__module__": module})
|
||||
return error_type(message)
|
||||
|
||||
|
||||
def _state() -> RubricState:
|
||||
return cast(
|
||||
"RubricState",
|
||||
{
|
||||
"rubric": "tests pass",
|
||||
"messages": [
|
||||
HumanMessage(content="implement it"),
|
||||
AIMessage(content="implementation complete"),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _satisfied_result() -> dict[str, Any]:
|
||||
return {
|
||||
"structured_response": GraderResponse(
|
||||
result="satisfied",
|
||||
explanation="all checks pass",
|
||||
criteria=[],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class TestTransientGraderTransportClassification:
|
||||
def test_read_error_is_transient(self) -> None:
|
||||
assert _is_transient_grader_transport_error(_read_error()) is True
|
||||
|
||||
def test_remote_protocol_error_is_transient(self) -> None:
|
||||
assert (
|
||||
_is_transient_grader_transport_error(httpx.RemoteProtocolError("boom"))
|
||||
is True
|
||||
)
|
||||
|
||||
def test_httpcore_read_error_is_transient(self) -> None:
|
||||
# httpcore errors cannot be caught via a stable isinstance, so the
|
||||
# classifier matches them by module/name; exercise that path directly.
|
||||
error = _typed_error("httpcore", "ReadError")
|
||||
|
||||
assert _is_transient_grader_transport_error(error) is True
|
||||
|
||||
def test_httpcore_remote_protocol_error_is_transient(self) -> None:
|
||||
error = _typed_error("httpcore._exceptions", "RemoteProtocolError")
|
||||
|
||||
assert _is_transient_grader_transport_error(error) is True
|
||||
|
||||
def test_read_error_in_exception_group_is_transient(self) -> None:
|
||||
group = ExceptionGroup(
|
||||
"grading failed", [ValueError("unrelated"), _read_error()]
|
||||
)
|
||||
|
||||
assert _is_transient_grader_transport_error(group) is True
|
||||
|
||||
def test_read_error_in_context_chain_is_transient(self) -> None:
|
||||
wrapper = RuntimeError("grader request failed")
|
||||
wrapper.__context__ = _read_error()
|
||||
|
||||
assert _is_transient_grader_transport_error(wrapper) is True
|
||||
|
||||
def test_transfer_encoding_error_in_cause_chain_is_transient(self) -> None:
|
||||
error_type = type(
|
||||
"TransferEncodingError",
|
||||
(Exception,),
|
||||
{"__module__": "aiohttp.http_exceptions"},
|
||||
)
|
||||
cause = error_type("Not enough data to satisfy transfer length header")
|
||||
wrapper = RuntimeError("grader request failed")
|
||||
wrapper.__cause__ = cause
|
||||
|
||||
assert _is_transient_grader_transport_error(wrapper) is True
|
||||
|
||||
def test_unrelated_exception_is_not_transient(self) -> None:
|
||||
assert _is_transient_grader_transport_error(RuntimeError("bug")) is False
|
||||
|
||||
|
||||
class TestReliableRubricMiddleware:
|
||||
async def test_retries_only_grading_without_mutating_agent_transcript(self) -> None:
|
||||
middleware = ReliableRubricMiddleware(model="fake-model")
|
||||
error = httpx.ReadError(
|
||||
"connection closed while reading",
|
||||
request=httpx.Request("POST", "https://grader.test"),
|
||||
)
|
||||
grader = AsyncMock()
|
||||
grader.ainvoke.side_effect = [error, _satisfied_result()]
|
||||
middleware._grader = grader
|
||||
state = _state()
|
||||
messages_before = list(state["messages"])
|
||||
|
||||
result = await middleware._agrade(state, 0)
|
||||
|
||||
assert result.result == "satisfied"
|
||||
assert grader.ainvoke.await_count == 2
|
||||
assert state["messages"] == messages_before
|
||||
|
||||
async def test_does_not_retry_unrelated_exception(self) -> None:
|
||||
middleware = ReliableRubricMiddleware(model="fake-model")
|
||||
grader = AsyncMock()
|
||||
grader.ainvoke.side_effect = RuntimeError("programming error")
|
||||
middleware._grader = grader
|
||||
|
||||
with pytest.raises(RuntimeError, match="programming error"):
|
||||
await middleware._agrade(_state(), 0)
|
||||
|
||||
grader.ainvoke.assert_awaited_once()
|
||||
|
||||
def test_sync_grade_retries_transient_transport_failure(self) -> None:
|
||||
middleware = ReliableRubricMiddleware(model="fake-model")
|
||||
grader = MagicMock()
|
||||
grader.invoke.side_effect = [_read_error(), _satisfied_result()]
|
||||
middleware._grader = grader
|
||||
|
||||
result = middleware._grade(_state(), 0)
|
||||
|
||||
assert result.result == "satisfied"
|
||||
assert grader.invoke.call_count == 2
|
||||
|
||||
async def test_second_transient_failure_propagates_async(self) -> None:
|
||||
# The retry is bounded to one attempt: a second transient failure must
|
||||
# surface so the base middleware can report it as a grader_error.
|
||||
middleware = ReliableRubricMiddleware(model="fake-model")
|
||||
grader = AsyncMock()
|
||||
grader.ainvoke.side_effect = [_read_error(), _read_error()]
|
||||
middleware._grader = grader
|
||||
|
||||
with pytest.raises(httpx.ReadError):
|
||||
await middleware._agrade(_state(), 0)
|
||||
|
||||
assert grader.ainvoke.await_count == 2
|
||||
|
||||
def test_second_transient_failure_propagates_sync(self) -> None:
|
||||
middleware = ReliableRubricMiddleware(model="fake-model")
|
||||
grader = MagicMock()
|
||||
grader.invoke.side_effect = [_read_error(), _read_error()]
|
||||
middleware._grader = grader
|
||||
|
||||
with pytest.raises(httpx.ReadError):
|
||||
middleware._grade(_state(), 0)
|
||||
|
||||
assert grader.invoke.call_count == 2
|
||||
@@ -308,7 +308,7 @@ class TestProcessRubricEvent:
|
||||
out = _render_event(
|
||||
{"type": "rubric_evaluation_end", "result": "max_iterations_reached"}
|
||||
)
|
||||
assert "iteration limit reached" in out
|
||||
assert "Iteration limit reached with unmet acceptance criteria" in out
|
||||
|
||||
def test_failed(self) -> None:
|
||||
out = _render_event(
|
||||
@@ -331,7 +331,7 @@ class TestProcessRubricEvent:
|
||||
"explanation": "provider 500",
|
||||
}
|
||||
)
|
||||
assert "grader error" in out
|
||||
assert "Grader/infrastructure failure" in out
|
||||
assert "provider 500" in out
|
||||
|
||||
def test_unrecognized_terminal_result_surfaced(self) -> None:
|
||||
|
||||
@@ -1298,12 +1298,29 @@ class TestFormatRubricEvent:
|
||||
)
|
||||
|
||||
def test_max_iterations_reached_event(self) -> None:
|
||||
"""Hitting the iteration cap should warn the user it is unsatisfied."""
|
||||
"""Hitting the iteration cap should show gaps and preserve the goal."""
|
||||
assert (
|
||||
_format_rubric_event(
|
||||
{"type": "rubric_evaluation_end", "result": "max_iterations_reached"},
|
||||
{
|
||||
"type": "rubric_evaluation_end",
|
||||
"result": "max_iterations_reached",
|
||||
"explanation": "coverage is still missing",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "tests pass",
|
||||
"passed": False,
|
||||
"gap": "integration test failed",
|
||||
},
|
||||
{"name": "docs updated", "passed": True},
|
||||
],
|
||||
},
|
||||
goal_active=True,
|
||||
)
|
||||
== "⚠ Acceptance criteria not satisfied (iteration limit reached)"
|
||||
== "⚠ Iteration limit reached with unmet acceptance criteria: "
|
||||
"coverage is still missing\n"
|
||||
" ✗ tests pass — integration test failed\n"
|
||||
"The goal remains active. Continue with another prompt to resume or "
|
||||
"retry, use `/goal <objective>` to amend it, or `/goal clear` to clear it."
|
||||
)
|
||||
|
||||
def test_grader_failure_results_render_warning(self) -> None:
|
||||
@@ -1322,7 +1339,7 @@ class TestFormatRubricEvent:
|
||||
_format_rubric_event(
|
||||
{"type": "rubric_evaluation_end", "result": "grader_error"},
|
||||
)
|
||||
== "⚠ Rubric grader error"
|
||||
== "⚠ Grader/infrastructure failure"
|
||||
)
|
||||
|
||||
def test_unknown_terminal_result_renders_fallback(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user