Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 117 additions & 32 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8996,25 +8996,36 @@ def _dismiss_orphaned_screen(self, screen: ModalScreen[Any]) -> None:
if self.screen is screen:
screen.dismiss()

async def _ensure_goal_auto_accept_preference(self) -> None:
"""Ask once, on the first Auto-mode goal, how to handle criteria.
def _goal_auto_accept_prompt_pending(self) -> bool:
"""Return whether a `/goal` action still owes the Auto criteria prompt.

Only prompts in Auto mode: the preference has no effect in Manual
Only Auto mode is prompted: the preference has no effect in Manual
(always reviews) or YOLO (always applies), so a Manual/YOLO user is
asked the first time they act on a goal after switching to Auto instead.

Fail closed to review when the modal times out or fails to mount. Saving
still records the one-time marker so a wedged prompt cannot loop, and the
orphaned modal is dismissed so it cannot linger over goal drafting.
Callers use this to decide whether the goal flow must run off the App
message pump before it can open the modal.
"""
if self._session_state is None:
return
return False
from deepagents_code.approval_mode import ApprovalMode

mode = getattr(self._session_state, "approval_mode", None)
if mode is not ApprovalMode.AUTO:
return
if not self._should_prompt_goal_auto_accept_preference():
return False
return self._should_prompt_goal_auto_accept_preference()

async def _ensure_goal_auto_accept_preference(self) -> None:
"""Ask once, on the first Auto-mode goal, how to handle criteria.

Fail closed to review when the modal times out or fails to mount. Saving
still records the one-time marker so a wedged prompt cannot loop, and the
orphaned modal is dismissed so it cannot linger over goal drafting.

Must not be awaited on the App message pump — see
`_start_goal_proposal`.
"""
if not self._goal_auto_accept_prompt_pending():
return

from deepagents_code.tui.widgets.launch_init import (
Expand Down Expand Up @@ -11573,15 +11584,9 @@ async def _handle_goal_command(self, command: str) -> None:
if not grader_arg:
await self._mount_message(AppMessage("Usage: /goal amend <feedback>"))
return
await self._ensure_goal_auto_accept_preference()
self._cancel_goal_proposal_worker()
await self._cancel_pending_goal_review(context="goal-amend cleanup")
async with self._goal_state_mutation_boundary():
self._clear_pending_goal_rubric()
await self._persist_goal_rubric_state()
self._goal_proposal_worker = self.run_worker(
self._propose_goal_amendment(grader_arg),
exclusive=False,
await self._start_goal_proposal(
lambda: self._propose_goal_amendment(grader_arg),
cleanup_context="goal-amend cleanup",
)
return

Expand Down Expand Up @@ -11624,16 +11629,97 @@ async def _handle_goal_command(self, command: str) -> None:

objective = remainder
await self._mount_message(UserMessage(command))
await self._ensure_goal_auto_accept_preference()
await self._start_goal_proposal(
lambda: self._propose_goal_rubric(objective),
cleanup_context="goal replacement cleanup",
)

async def _start_goal_proposal(
self,
proposal: Callable[[], Coroutine[Any, Any, None]],
*,
cleanup_context: str,
) -> None:
"""Begin a goal proposal, asking for the Auto criteria policy first.

The one-time preference modal is awaited, and `/goal` is dispatched from
`on_chat_input_submitted`, which is itself awaited inline on the App
message pump. Awaiting the modal there blocks the pump, so it never
receives the Enter/Esc key events it needs to resolve and the whole app
looks frozen until the modal watchdog fires. When the prompt is still
owed, the flow therefore hands off to `_schedule_off_message_pump` so
the handler returns and the pump stays free to route keys to the modal.
This mirrors the `/update` and `/install --package` confirmations, which
detach for the same reason.

Without a pending prompt (the overwhelmingly common case) nothing opens a
modal, so the flow stays inline and `/goal` keeps drafting within the
submission that asked for it.

Args:
proposal: Builds the drafting coroutine to hand to the goal proposal
worker. Deferred so a preflight failure cannot strand an
un-awaited coroutine.
cleanup_context: Label for the pending-review cancellation log.
"""
if self._goal_auto_accept_prompt_pending():
self._schedule_off_message_pump(
self._prompt_then_start_goal_proposal(
proposal,
cleanup_context=cleanup_context,
),
context="goal:criteria-preference",
)
return
await self._start_goal_proposal_now(proposal, cleanup_context=cleanup_context)

async def _prompt_then_start_goal_proposal(
self,
proposal: Callable[[], Coroutine[Any, Any, None]],
*,
cleanup_context: str,
) -> None:
"""Answer the Auto criteria prompt, then start the goal proposal.

Must be scheduled via `_schedule_off_message_pump`, never awaited from a
command handler, so the preference modal can receive keys. It therefore
catches its own exceptions, because the handler's `try/except` no longer
wraps it.

Args:
proposal: Builds the drafting coroutine for the proposal worker.
cleanup_context: Label for the pending-review cancellation log.
"""
try:
await self._ensure_goal_auto_accept_preference()
await self._start_goal_proposal_now(
proposal,
cleanup_context=cleanup_context,
)
except Exception:
logger.exception("Failed to start goal proposal after criteria prompt")
await self._mount_message(
ErrorMessage("Could not start the goal. Please try again."),
)

async def _start_goal_proposal_now(
self,
proposal: Callable[[], Coroutine[Any, Any, None]],
*,
cleanup_context: str,
) -> None:
"""Clear stale goal proposal state and start the drafting worker.

Args:
proposal: Builds the drafting coroutine for the proposal worker.
cleanup_context: Label for the pending-review cancellation log.
"""
self._cancel_goal_proposal_worker()
await self._cancel_pending_goal_review(context="goal replacement cleanup")
await self._cancel_pending_goal_review(context=cleanup_context)
async with self._goal_state_mutation_boundary():
self._clear_pending_goal_rubric()
await self._persist_goal_rubric_state()
self._goal_proposal_worker = self.run_worker(
self._propose_goal_rubric(objective),
exclusive=False,
)
self._goal_proposal_worker = self.run_worker(proposal(), exclusive=False)

@staticmethod
def _goal_usage_text() -> str:
Expand Down Expand Up @@ -21748,9 +21834,9 @@ def _schedule_off_message_pump(

Because the command handler now returns while the modal is still open,
the continuation participates in the app's busy state until it ends.
Only one continuation is allowed globally, so differently keyed install
and update commands cannot show overlapping modals or mutate the tool
environment concurrently.
Only one continuation is allowed globally, so differently keyed install,
update, and goal commands cannot show overlapping modals or mutate
shared state concurrently.

The continuation runs outside the command handler's `try/except` and is
cancelled at app exit, possibly mid-install, so each one must catch its
Expand All @@ -21770,12 +21856,11 @@ def _schedule_off_message_pump(
if self._modal_command_running():
coro.close()
# Covers the whole continuation, not just its modal: after the
# prompt is answered the install or refresh can still be running for
# minutes, and pointing the user at a prompt that has already closed
# would read as a bug.
# prompt is answered the install, refresh, or goal proposal can
# still be running for minutes, and pointing the user at a prompt
# that has already closed would read as a bug.
self.notify(
"Another install or update is in progress — answer its prompt if "
"one is open.",
"Another command is waiting on a prompt — answer it first.",
severity="warning",
timeout=5,
)
Expand Down
Loading