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
2 changes: 1 addition & 1 deletion libs/code/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ aliases, descriptions, visibility, or hidden-command metadata.
| `/effort` | | Set reasoning effort for the current model |
| `/feedback` | | Send feedback or report an issue |
| `/force-clear` | | Stop active work, clear the chat, and start a new thread |
| `/goal` | | Set a persistent objective by drafting acceptance criteria |
| `/goal` | | Set and manage a persistent objective with acceptance criteria |
| `/help` | | Show help and available commands |
| `/install` | | Install an optional integration |
| `/mcp` | | Manage MCP servers and authentication |
Expand Down
819 changes: 705 additions & 114 deletions libs/code/deepagents_code/app.py

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions libs/code/deepagents_code/app.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,17 @@ ToolCallMessage.-ascii:hover {
background: $surface;
}

/* Persistent goal status */
.goal-status-panel {
height: auto;
max-height: 5;
margin: 0 1;
padding: 0 1;
color: $text;
background: $surface-darken-1;
border-left: thick $primary;
}

/* Goal review widget */
.goal-review-menu {
height: auto;
Expand Down
10 changes: 7 additions & 3 deletions libs/code/deepagents_code/command_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,16 @@ def to_entry(self) -> CommandEntry:
),
SlashCommand(
name="/goal",
description="Set a persistent objective by drafting acceptance criteria",
description="Set and manage a persistent objective with acceptance criteria",
bypass_tier=BypassTier.QUEUED,
hidden_keywords=(
"objective criteria acceptance rubric grader grading model iterations"
"objective criteria acceptance amend pause resume rubric grader grading "
"model iterations"
),
argument_hint=(
"[<objective>|amend <feedback>|pause|resume|show|clear|model|"
"max-iterations]"
),
argument_hint="[<objective>|show|clear|model|max-iterations]",
),
SlashCommand(
name="/editor",
Expand Down
90 changes: 89 additions & 1 deletion libs/code/deepagents_code/goal_rubric.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import Any
from typing import Any, TypedDict, cast

from langchain_core.messages import HumanMessage, SystemMessage

Expand All @@ -14,6 +14,21 @@
"user-visible behavior when relevant. Do not start implementing the goal."
)

GOAL_AMENDMENT_SYSTEM_PROMPT = (
"You amend an existing coding-agent goal from user feedback. Preserve every "
"unaffected acceptance criterion and explicit user constraint. Change only "
"the objective and criteria needed to incorporate the feedback. Return a "
"revised objective and a concise markdown bullet list of concrete, testable "
"acceptance criteria. Do not start implementing the goal."
)


class GoalAmendment(TypedDict):
"""Structured proposed update to an existing goal."""

objective: str
criteria: str


def _goal_rubric_human_prompt(
objective: str,
Expand Down Expand Up @@ -67,6 +82,79 @@ def _goal_rubric_human_prompt(
return "\n".join(parts)


def _goal_amendment_human_prompt(
objective: str,
criteria: str,
feedback: str,
) -> str:
"""Build the bounded prompt for amending an accepted goal.

Args:
objective: Current accepted objective.
criteria: Current accepted criteria.
feedback: User-requested changes.

Returns:
Prompt text with each user-controlled value in an explicit boundary.
"""
return (
f"<current_goal>\n{objective}\n</current_goal>\n\n"
f"<current_criteria>\n{criteria}\n</current_criteria>\n\n"
f"<user_feedback>\n{feedback}\n</user_feedback>"
)


def generate_goal_amendment(
objective: str,
criteria: str,
feedback: str,
*,
model_spec: str | None,
model_params: dict[str, Any] | None = None,
profile_override: dict[str, Any] | None = None,
) -> GoalAmendment:
"""Generate a proposed objective and criteria amendment.

Args:
objective: Current accepted objective.
criteria: Current accepted criteria.
feedback: User-requested changes.
model_spec: Model spec used to draft the amendment.
model_params: Optional model constructor kwargs.
profile_override: Optional profile metadata overrides.

Returns:
Proposed amended objective and criteria.
"""
from deepagents_code.config import create_model

result = create_model(
model_spec,
extra_kwargs=model_params,
profile_overrides=profile_override,
)
model = result.model.with_structured_output(GoalAmendment)
response = cast(
"GoalAmendment",
model.invoke(
[
SystemMessage(content=GOAL_AMENDMENT_SYSTEM_PROMPT),
HumanMessage(
content=_goal_amendment_human_prompt(
objective,
criteria,
feedback,
)
),
]
),
)
return {
"objective": str(response.get("objective", "")).strip(),
"criteria": str(response.get("criteria", "")).strip(),
}


def generate_goal_rubric(
objective: str,
*,
Expand Down
48 changes: 34 additions & 14 deletions libs/code/deepagents_code/goal_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
Use `get_rubric` to inspect active acceptance criteria before deciding whether work is
complete.
When a goal is active, use `get_goal` to inspect the objective and current status.
A paused goal is persisted for later but must not drive work until the user resumes it.
Use `update_goal` only when you have evidence that the goal is complete or blocked."""
"""Model-visible guidance injected before each request by `GoalToolsMiddleware`."""

Expand Down Expand Up @@ -91,11 +92,12 @@ class GoalSnapshot(TypedDict):
"""

active: bool
"""Whether the goal is unfinished.
"""Whether the goal is actionable (should drive work).

Derived from `status`: a set goal is active until it is `complete`. `False`
when no goal is set (the `objective is None` branch), where `status` is also
`None`.
Derived from `status`: `active` and `blocked` goals are actionable, while
`paused` and `complete` goals are not. Note a `paused` goal is unfinished
yet reports `active=False`. `False` when no goal is set (the
`objective is None` branch), where `status` is also `None`.
"""

objective: str | None
Expand All @@ -110,7 +112,7 @@ class GoalSnapshot(TypedDict):
"""

criteria: str | None
"""Accepted criteria (from the shared rubric snapshot)."""
"""Persisted goal criteria, or shared rubric criteria when no goal rubric exists."""

note: str | None
"""Latest evidence or blocker note recorded by `update_goal`."""
Expand Down Expand Up @@ -155,22 +157,25 @@ def _rubric_snapshot(state: dict[str, Any]) -> RubricSnapshot:
goal_rubric = _clean_state_text(state, "_goal_rubric")
sticky_rubric = _clean_state_text(state, "_sticky_rubric")
objective = _clean_state_text(state, "_goal_objective")
status = coerce_goal_status(state.get("_goal_status")) or "active"
goal_is_actionable = objective is not None and status in {"active", "blocked"}
sticky_is_goal_rubric = objective is not None and sticky_rubric == goal_rubric

source: RubricSource | None = None
if criteria is not None:
if objective is not None and goal_rubric == criteria:
if goal_is_actionable and goal_rubric == criteria:
source = "goal"
elif sticky_rubric == criteria:
elif sticky_rubric == criteria and not sticky_is_goal_rubric:
source = "sticky"
else:
source = "invocation"
# Fallback branches below run only when there is no public `rubric` input,
# so `invocation` is unreachable here by construction — the criteria can
# only be attributed to a `goal` or a `sticky` rubric.
elif objective is not None and goal_rubric is not None:
# only be attributed to an actionable `goal` or a standalone `sticky` rubric.
elif goal_is_actionable and goal_rubric is not None:
criteria = goal_rubric
source = "goal"
elif sticky_rubric is not None:
elif sticky_rubric is not None and not sticky_is_goal_rubric:
criteria = sticky_rubric
source = "sticky"

Expand Down Expand Up @@ -207,14 +212,15 @@ def _goal_snapshot(state: dict[str, Any]) -> GoalSnapshot:
# A set-but-unlabeled or unrecognized status defaults to "active"; an
# unknown persisted value never leaks to the model as a bogus status.
status: GoalStatus = coerce_goal_status(state.get("_goal_status")) or "active"
criteria = _clean_state_text(state, "_goal_rubric") or rubric["criteria"]
note = _clean_state_text(state, "_goal_status_note")
return {
# A goal is active until it is complete; `blocked` is still unfinished.
# Derive `active` from `status` so the two never disagree.
"active": status != "complete",
# Blocked goals remain actionable, while paused and complete goals do not
# drive work until the user changes their state.
"active": status in {"active", "blocked"},
Comment thread
open-swe[bot] marked this conversation as resolved.
"objective": objective,
"status": status,
"criteria": rubric["criteria"],
"criteria": criteria,
"note": note,
}

Expand Down Expand Up @@ -260,6 +266,20 @@ def _update_goal_command(
]
}
)
goal_status = coerce_goal_status(state.get("_goal_status")) or "active"
if goal_status in {"paused", "complete"}:
if goal_status == "paused":
message = (
"The goal is paused. The user must run `/goal resume` before its "
"status can be updated."
)
else:
message = "The goal is already complete and cannot be updated."
return Command(
update={
"messages": [ToolMessage(content=message, tool_call_id=tool_call_id)]
}
)
clean_note = note.strip()
if not clean_note:
# Evidence is required: refuse to commit a status with no justification
Expand Down
37 changes: 31 additions & 6 deletions libs/code/deepagents_code/resume_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
- `_sticky_rubric` — the TUI-owned persistent rubric. This is separate from
the public `rubric` graph input so one-shot rubric turns can be checkpointed
without being restored as sticky state.
- `_pending_goal_objective` / `_pending_goal_rubric` — a proposed goal awaiting
user acceptance of its criteria.
- `_pending_goal_objective` / `_pending_goal_rubric` / `_pending_goal_kind` — a
proposed goal or amendment awaiting user acceptance of its criteria.

All of these are facts the CLI reads back from `state_values` on thread resume
so it can rehydrate the session without replaying or re-tokenizing history.
Expand Down Expand Up @@ -66,14 +66,34 @@
if TYPE_CHECKING:
from langgraph.runtime import Runtime

GoalStatus = Literal["active", "blocked", "complete"]
GoalStatus = Literal["active", "paused", "blocked", "complete"]
"""Lifecycle status of a TUI-owned goal.

`active` and `blocked` are unfinished states; `complete` is terminal. A blocked
goal is still considered active (unfinished) by `get_goal`.
`active` and `blocked` are unfinished working states, `paused` preserves the goal
without driving work, and `complete` is terminal. A blocked goal is still
considered actionable (`active=True`) by `get_goal`, whereas a paused goal is
unfinished but reports `active=False`.
"""

GoalProposalKind = Literal["create", "amend"]
"""Whether a pending review creates a goal or amends the current one."""

_GOAL_STATUS_VALUES: frozenset[str] = frozenset(get_args(GoalStatus))
_GOAL_PROPOSAL_KIND_VALUES: frozenset[str] = frozenset(get_args(GoalProposalKind))


def coerce_goal_proposal_kind(value: object) -> GoalProposalKind | None:
"""Narrow a persisted proposal kind to a known value.

Args:
value: Raw value read from checkpoint state.

Returns:
The recognized proposal kind, otherwise `None`.
"""
if isinstance(value, str) and value in _GOAL_PROPOSAL_KIND_VALUES:
return cast("GoalProposalKind", value)
return None


def coerce_goal_status(value: object) -> GoalStatus | None:
Expand Down Expand Up @@ -115,7 +135,7 @@ class GoalRubricChannels(AgentState):
"""Accepted goal objective restored by the TUI on resume."""

_goal_status: Annotated[NotRequired[GoalStatus | None], PrivateStateAttr]
"""Goal lifecycle status (`active`, `blocked`, `complete`, or `None`)."""
"""Goal lifecycle status (`active`, `paused`, `blocked`, `complete`, or `None`)."""

_goal_rubric: Annotated[NotRequired[str | None], PrivateStateAttr]
"""Accepted rubric associated with `_goal_objective`."""
Expand Down Expand Up @@ -153,6 +173,11 @@ class ResumeState(GoalRubricChannels):
_pending_goal_rubric: Annotated[NotRequired[str | None], PrivateStateAttr]
"""Proposed criteria awaiting user acceptance."""

_pending_goal_kind: Annotated[
NotRequired[GoalProposalKind | None], PrivateStateAttr
]
"""Whether the pending review creates or amends a goal."""


def _extract_context_tokens(message: AIMessage) -> int | None:
"""Return the context-token count from an AI message, or `None` if absent.
Expand Down
19 changes: 14 additions & 5 deletions libs/code/deepagents_code/tui/widgets/goal_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ def __init__(
self,
objective: str,
criteria: str,
*,
amendment: bool = False,
id: str | None = None, # noqa: A002
) -> None:
"""Initialize the goal review menu."""
Expand All @@ -142,6 +144,9 @@ def __init__(
self._criteria = criteria
"""Generated acceptance criteria proposed for the goal."""

self._amendment = amendment
"""Whether this review updates an existing goal."""

self._selected = 0
"""Index of the currently highlighted action option."""

Expand Down Expand Up @@ -174,18 +179,22 @@ def compose(self) -> ComposeResult:
Widgets for the title, criteria preview, actions, editor, and help text.
"""
glyphs = get_glyphs()
title = "Review goal amendment" if self._amendment else "Review goal criteria"
yield Static(
Content.from_markup("$cursor Review goal criteria", cursor=glyphs.cursor),
Content.from_markup("$cursor $title", cursor=glyphs.cursor, title=title),
classes="goal-review-title",
)
with (
VerticalScroll(classes="goal-review-content"),
Vertical(classes="goal-review-body"),
):
yield Markdown(
f"**Proposed criteria**\n\n{self._criteria}",
classes="goal-review-markdown",
)
source = f"**Proposed criteria**\n\n{self._criteria}"
if self._amendment:
source = (
f"**Proposed objective**\n\n{self._objective}\n\n"
f"**Proposed criteria**\n\n{self._criteria}"
)
yield Markdown(source, classes="goal-review-markdown")
with Container(classes="goal-review-options-container"):
for _ in _OPTIONS:
widget = Static("", classes="goal-review-option")
Expand Down
Loading