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
24 changes: 23 additions & 1 deletion libs/code/deepagents_code/tui/textual_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ def __call__(self, cost_usd: float, /) -> None: ...

_ASK_USER_UNSUPPORTED_ERROR = "ask_user not supported by this UI"

_REJECT_REASON_PREFIX = "User rejected the tool call with reason: "
"""Synthetic framing prepended to a user-typed HITL rejection reason."""


def _permission_tool_calls(
interrupt_id: str,
Expand Down Expand Up @@ -439,6 +442,24 @@ def _reject_tracked_rows(
return _dispatch_terminal_tool_result_hooks(rejected, "Tool approval rejected")


def _frame_reject_reason(reason: str) -> str:
"""Frame a user-typed rejection reason for the model.

Stock HITL uses the supplied message as the *entire* synthetic
`ToolMessage`, replacing its canned "user rejected the tool call" wording.
A bare reason ("no", "wrong file") therefore reaches the model with no
indication of who produced it or why the tool never ran, so the framing is
reattached here while the raw text is what the tool row renders.

Args:
reason: Non-empty reason typed into the rejection reason field.

Returns:
The reason prefixed with the synthetic rejection framing.
"""
return f"{_REJECT_REASON_PREFIX}{reason}"


def _get_hitl_request_adapter(hitl_request_type: type) -> TypeAdapter:
"""Return a cached `TypeAdapter(HITLRequest)`.

Expand Down Expand Up @@ -2804,7 +2825,8 @@ async def _after_automatic_compact() -> None:
)
reject_decision: RejectDecision = (
RejectDecision(
type="reject", message=reject_message
type="reject",
message=_frame_reject_reason(reject_message),
)
if reject_message
else RejectDecision(type="reject")
Expand Down
4 changes: 3 additions & 1 deletion libs/code/deepagents_code/tui/widgets/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,9 @@ def action_reject_with_reason(self) -> None:
"""Enter free-text reject mode if Reject is currently selected.

No-op unless the cursor is on the Reject option. Mounts an inline
`Input` whose value is sent as `RejectDecision.message` on submit.
`Input` whose value is sent verbatim on submit; the adapter frames it
before it becomes `RejectDecision.message` so this widget keeps the raw
text for display.
"""
if self._reason_input_active:
return
Expand Down
132 changes: 131 additions & 1 deletion libs/code/tests/unit_tests/tui/test_textual_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
_dispatch_tool_result_hook,
_format_rubric_details,
_format_rubric_event,
_frame_reject_reason,
_handle_interrupt_cleanup,
_interrupt_owned_tool_rows,
_is_auto_mode_classifier_chunk,
Expand Down Expand Up @@ -5510,7 +5511,12 @@ async def request_approval(
assert isinstance(resume_cmd, Command)
resume_payload = cast("dict[str, dict[str, Any]]", resume_cmd.resume)
decisions = resume_payload["interrupt-1"]["decisions"]
assert decisions == [{"type": "reject", "message": "use a safer command"}]
assert decisions == [
{
"type": "reject",
"message": _frame_reject_reason("use a safer command"),
}
]
app_messages = [widget for widget in mounted if isinstance(widget, AppMessage)]
assert not any("Command rejected" in str(msg._content) for msg in app_messages)

Expand Down Expand Up @@ -7502,6 +7508,130 @@ async def request_approval(
# Stayed rejected despite the resumed error ToolMessage driving set_error.
assert execute_widgets[0]._status == "rejected"

async def test_hitl_reasoned_reject_frames_reason_for_model(self) -> None:
"""The model gets framed rejection text; the row keeps the raw reason."""
mounted: list[ToolCallMessage] = []

async def capture_mount(widget: object) -> None:
await asyncio.sleep(0)
if isinstance(widget, ToolCallMessage):
mounted.append(widget)

action_requests = [{"name": "execute", "args": {"command": "echo hi"}}]
agent = _SequencedAgent(
streams_by_call=[
[
(
(),
"messages",
(
_tool_call_message(
"execute", {"command": "echo hi"}, "tool-1"
),
{},
),
),
_hitl_interrupt_chunk(
{
"action_requests": action_requests,
"review_configs": [
{
"action_name": "execute",
"allowed_decisions": ["approve", "reject"],
}
],
}
),
],
[],
]
)

async def request_approval(
_action_requests: list[dict[str, Any]],
_assistant_id: str | None,
) -> asyncio.Future[object]:
await asyncio.sleep(0)
future: asyncio.Future[object] = asyncio.Future()
future.set_result({"type": "reject", "message": "use another command"})
return future

adapter = TextualUIAdapter(
mount_message=capture_mount,
update_status=_noop_status,
request_approval=request_approval,
)

await execute_task_textual(
user_input="hello",
agent=agent,
assistant_id="assistant",
session_state=_session_state(auto_approve=False),
adapter=adapter,
)

resume_cmd = agent.stream_inputs[1]
assert isinstance(resume_cmd, Command)
resume_payload = cast("dict[str, dict[str, Any]]", resume_cmd.resume)
expected_message = (
"User rejected the tool call with reason: use another command"
)
assert resume_payload["interrupt-1"]["decisions"] == [
{"type": "reject", "message": expected_message}
]
execute_widgets = [w for w in mounted if w.tool_name == "execute"]
assert len(execute_widgets) == 1
assert execute_widgets[0]._reject_reason == "use another command"

async def test_hitl_blank_reject_reason_stays_bare(self) -> None:
"""A whitespace-only reason must not synthesize an empty framed reason."""
action_requests = [{"name": "execute", "args": {"command": "echo hi"}}]
agent = _SequencedAgent(
streams_by_call=[
[
_hitl_interrupt_chunk(
{
"action_requests": action_requests,
"review_configs": [
{
"action_name": "execute",
"allowed_decisions": ["approve", "reject"],
}
],
}
)
],
[],
]
)

async def request_approval(
_action_requests: list[dict[str, Any]],
_assistant_id: str | None,
) -> asyncio.Future[object]:
await asyncio.sleep(0)
future: asyncio.Future[object] = asyncio.Future()
future.set_result({"type": "reject", "message": " "})
return future

adapter = TextualUIAdapter(
mount_message=_mock_mount,
update_status=_noop_status,
request_approval=request_approval,
)

await execute_task_textual(
user_input="hello",
agent=agent,
assistant_id="assistant",
session_state=_session_state(auto_approve=False),
adapter=adapter,
)

# A blank reason is a bare reject: the turn aborts instead of resuming,
# so the upstream canned rejection wording is what the model would see.
assert len(agent.stream_inputs) == 1

async def test_tool_use_dispatched_after_streaming_fragments(self) -> None:
"""tool.use reassembles streamed arg fragments and fires exactly once."""
chunks = [
Expand Down