mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): record shell output as user context (#4353)
Local `!` commands are user-initiated environment context, not assistant-authored output. Before this change, dcode represented a non-incognito shell command as a synthetic `HumanMessage`/`AIMessage` pair: ```text Human: !git pull AI: ```text Already up to date. ``` ``` That made shell output look like something the model said, which is misleading in traces and can confuse downstream history consumers. This switches the model-visible record to a single structured user-context message, matching the pattern used by tools like Codex for user-entered shell commands: ```text Human: <user_shell_command> <command> git pull </command> <result> Exit code: 0 Output: Already up to date. </result> </user_shell_command> ``` Agent-invoked shell tools can still use normal tool-call/tool-result semantics. This change is only for user-entered `!` commands that run outside the agent graph and are later flushed into the thread state before the next user turn. ## Changes - Change `DeepAgentsApp._buffer_shell_for_model_context` to append one structured `HumanMessage` per `!` command instead of a `HumanMessage` command plus synthetic `AIMessage` output. - Include the command, exit status, and combined stdout/stderr in explicit XML-like sections so the model can distinguish user intent from environment output. - Record `Exit code: unknown` for interrupted/timeout paths where the subprocess has no return code. - Preserve the existing privacy and timing behavior: `!!` incognito commands and `--startup-cmd` output stay local-only, and non-incognito `!` output is still deferred until the next user send. - Update shell-context tests for successful output, nonzero exits, empty output, interrupted commands, timeout/OSError paths, flush ordering, and retained buffers. ## Testing - `uv run --directory "/Users/mdrxy/oss/deepagents/libs/code" --group test pytest tests/unit_tests/test_app.py -k "shell"` - `uv run --directory "/Users/mdrxy/oss/deepagents/libs/code" ruff check deepagents_code/app.py tests/unit_tests/test_app.py`
This commit is contained in:
@@ -1986,10 +1986,11 @@ class DeepAgentsApp(App):
|
||||
"""True while a `!` shell command is executing."""
|
||||
|
||||
self._pending_shell_messages: list[BaseMessage] = []
|
||||
"""Non-incognito `!` command/output messages awaiting flush.
|
||||
"""Non-incognito `!` runs awaiting flush, one per command.
|
||||
|
||||
`!` runs outside the agent graph, so its command/output are buffered
|
||||
here and written into thread state on the next user send (see
|
||||
`!` runs outside the agent graph, so each run is buffered here as a
|
||||
single structured `HumanMessage` (command + output) and written into
|
||||
thread state on the next user send (see
|
||||
`_flush_pending_shell_messages`) — never proactively. `!!` (incognito)
|
||||
never appends here."""
|
||||
|
||||
@@ -6915,8 +6916,8 @@ class DeepAgentsApp(App):
|
||||
their command/output never reach the checkpoint the model reads. Rather
|
||||
than write to thread state immediately (which would spend a model turn
|
||||
on output the user may never reference), the command/output are queued
|
||||
here as a `HumanMessage`/`AIMessage` pair and flushed when the user
|
||||
sends their next message (see `_flush_pending_shell_messages`). `!!`
|
||||
here as a structured `HumanMessage` and flushed when the user sends
|
||||
their next message (see `_flush_pending_shell_messages`). `!!`
|
||||
(incognito) callers skip this and stay local-only.
|
||||
|
||||
Args:
|
||||
@@ -6924,16 +6925,23 @@ class DeepAgentsApp(App):
|
||||
output: Combined stdout/stderr captured from the command.
|
||||
returncode: Process exit code, or `None` if unavailable.
|
||||
"""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
status = f"\n[Command exited with code {returncode}]" if returncode else ""
|
||||
code = returncode if returncode is not None else "unknown"
|
||||
body = output or "(no output)"
|
||||
self._pending_shell_messages.extend(
|
||||
[
|
||||
HumanMessage(content=f"!{command}"),
|
||||
AIMessage(content=f"```text\n{body}\n```{status}"),
|
||||
]
|
||||
content = (
|
||||
"<user_shell_command>\n"
|
||||
"<command>\n"
|
||||
f"{command}\n"
|
||||
"</command>\n"
|
||||
"<result>\n"
|
||||
f"Exit code: {code}\n"
|
||||
"Output:\n"
|
||||
f"{body}\n"
|
||||
"</result>\n"
|
||||
"</user_shell_command>"
|
||||
)
|
||||
self._pending_shell_messages.append(HumanMessage(content=content))
|
||||
|
||||
async def _flush_pending_shell_messages(self) -> None:
|
||||
"""Write buffered `!` command/output into thread state, then clear it.
|
||||
|
||||
@@ -19,6 +19,8 @@ from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable, Iterator
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from deepagents_code.notifications import PendingNotification
|
||||
from deepagents_code.sessions import ThreadInfo
|
||||
|
||||
@@ -5561,6 +5563,27 @@ class TestPasteRouting:
|
||||
class TestShellCommandInterrupt:
|
||||
"""Tests for interruptible shell commands (! prefix) using worker pattern."""
|
||||
|
||||
@staticmethod
|
||||
def _shell_context_message(
|
||||
command: str, output: str, returncode: int = 0
|
||||
) -> HumanMessage:
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
return HumanMessage(
|
||||
content=(
|
||||
"<user_shell_command>\n"
|
||||
"<command>\n"
|
||||
f"{command}\n"
|
||||
"</command>\n"
|
||||
"<result>\n"
|
||||
f"Exit code: {returncode}\n"
|
||||
"Output:\n"
|
||||
f"{output}\n"
|
||||
"</result>\n"
|
||||
"</user_shell_command>"
|
||||
)
|
||||
)
|
||||
|
||||
async def test_escape_cancels_shell_worker(self) -> None:
|
||||
"""Esc while shell command is running should cancel the worker."""
|
||||
app = DeepAgentsApp()
|
||||
@@ -5620,8 +5643,9 @@ class TestShellCommandInterrupt:
|
||||
|
||||
mock_killpg.assert_called()
|
||||
buffered = app._pending_shell_messages
|
||||
assert buffered[0].content == "!sleep 999"
|
||||
assert "Command interrupted" in buffered[1].content
|
||||
assert len(buffered) == 1
|
||||
assert "sleep 999" in buffered[0].content
|
||||
assert "Command interrupted" in buffered[0].content
|
||||
|
||||
async def test_cleanup_clears_state(self) -> None:
|
||||
"""_cleanup_shell_task should reset all shell state."""
|
||||
@@ -5907,8 +5931,9 @@ class TestShellCommandInterrupt:
|
||||
error_msgs = app.query(ErrorMessage)
|
||||
assert any("timed out" in w._content for w in error_msgs)
|
||||
buffered = app._pending_shell_messages
|
||||
assert buffered[0].content == "!sleep 999"
|
||||
assert "timed out" in buffered[1].content
|
||||
assert len(buffered) == 1
|
||||
assert "sleep 999" in buffered[0].content
|
||||
assert "timed out" in buffered[0].content
|
||||
|
||||
async def test_incognito_timeout_feedback_is_not_model_visible(self) -> None:
|
||||
"""Incognito timeout feedback should stay out of user/assistant records."""
|
||||
@@ -6021,8 +6046,9 @@ class TestShellCommandInterrupt:
|
||||
error_msgs = app.query(ErrorMessage)
|
||||
assert any("Permission denied" in w._content for w in error_msgs)
|
||||
buffered = app._pending_shell_messages
|
||||
assert buffered[0].content == "!forbidden"
|
||||
assert "Permission denied" in buffered[1].content
|
||||
assert len(buffered) == 1
|
||||
assert "forbidden" in buffered[0].content
|
||||
assert "Permission denied" in buffered[0].content
|
||||
|
||||
async def test_handle_shell_command_sets_running_state(self) -> None:
|
||||
"""_handle_shell_command should set _shell_running and spawn worker."""
|
||||
@@ -6146,7 +6172,7 @@ class TestShellCommandInterrupt:
|
||||
|
||||
async def test_non_incognito_shell_buffers_for_model_context(self) -> None:
|
||||
"""A `!` command/output is buffered, not written immediately."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
app._agent = MagicMock()
|
||||
@@ -6175,10 +6201,20 @@ class TestShellCommandInterrupt:
|
||||
# Deferred: nothing written to graph state until the next user send.
|
||||
app._agent.aupdate_state.assert_not_awaited()
|
||||
buffered = app._pending_shell_messages
|
||||
assert len(buffered) == 1
|
||||
assert isinstance(buffered[0], HumanMessage)
|
||||
assert buffered[0].content == "!echo hello world"
|
||||
assert isinstance(buffered[1], AIMessage)
|
||||
assert buffered[1].content == "```text\nhello world\n```"
|
||||
assert buffered[0].content == (
|
||||
"<user_shell_command>\n"
|
||||
"<command>\n"
|
||||
"echo hello world\n"
|
||||
"</command>\n"
|
||||
"<result>\n"
|
||||
"Exit code: 0\n"
|
||||
"Output:\n"
|
||||
"hello world\n"
|
||||
"</result>\n"
|
||||
"</user_shell_command>"
|
||||
)
|
||||
|
||||
async def test_non_incognito_shell_output_uses_text_fence(self) -> None:
|
||||
"""Non-incognito shell output renders in a ```text fenced block."""
|
||||
@@ -6217,18 +6253,13 @@ class TestShellCommandInterrupt:
|
||||
|
||||
async def test_pending_shell_flushed_on_next_user_send(self) -> None:
|
||||
"""Buffered `!` output is written to graph state on the next send."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
app._agent = MagicMock()
|
||||
app._agent.aupdate_state = AsyncMock()
|
||||
app._lc_thread_id = "thread-123"
|
||||
app._ui_adapter = MagicMock()
|
||||
app._session_state = MagicMock()
|
||||
app._pending_shell_messages = [
|
||||
HumanMessage(content="!echo hi"),
|
||||
AIMessage(content="```text\nhi\n```"),
|
||||
]
|
||||
app._pending_shell_messages = [self._shell_context_message("echo hi", "hi")]
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
@@ -6244,21 +6275,18 @@ class TestShellCommandInterrupt:
|
||||
assert call is not None
|
||||
assert call.args[0]["configurable"]["thread_id"] == "thread-123"
|
||||
sent = call.args[1]["messages"]
|
||||
assert sent[0].content == "!echo hi"
|
||||
assert sent == [self._shell_context_message("echo hi", "hi")]
|
||||
# Buffer is drained so it is not replayed onto a later turn.
|
||||
assert app._pending_shell_messages == []
|
||||
|
||||
async def test_pending_shell_first_message_uses_session_thread(self) -> None:
|
||||
"""A first-message `!` command should flush to the new session thread."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
app._agent = MagicMock()
|
||||
app._agent.aupdate_state = AsyncMock()
|
||||
app._ui_adapter = MagicMock()
|
||||
app._pending_shell_messages = [
|
||||
HumanMessage(content="!echo before-chat"),
|
||||
AIMessage(content="```text\nbefore-chat\n```"),
|
||||
self._shell_context_message("echo before-chat", "before-chat")
|
||||
]
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
@@ -6282,8 +6310,6 @@ class TestShellCommandInterrupt:
|
||||
|
||||
async def test_pending_shell_flush_ensures_remote_thread_first(self) -> None:
|
||||
"""Server mode must register a fresh thread before flushing shell output."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deepagents_code.remote_client import RemoteAgent
|
||||
|
||||
calls: list[str] = []
|
||||
@@ -6298,8 +6324,7 @@ class TestShellCommandInterrupt:
|
||||
|
||||
app = DeepAgentsApp(agent=agent, thread_id="thread-remote")
|
||||
app._pending_shell_messages = [
|
||||
HumanMessage(content="!pwd"),
|
||||
AIMessage(content="```text\n/tmp/project\n```"),
|
||||
self._shell_context_message("pwd", "/tmp/project")
|
||||
]
|
||||
|
||||
with patch.object(app, "_remote_agent", return_value=remote):
|
||||
@@ -6403,17 +6428,15 @@ class TestShellCommandInterrupt:
|
||||
await pilot.pause()
|
||||
|
||||
buffered = app._pending_shell_messages
|
||||
assert "boom" in buffered[1].content
|
||||
assert "exited with code 2" in buffered[1].content
|
||||
assert len(buffered) == 1
|
||||
assert "boom" in buffered[0].content
|
||||
assert "Exit code: 2" in buffered[0].content
|
||||
|
||||
async def test_clear_messages_drops_pending_shell_buffer(self) -> None:
|
||||
"""`_clear_messages` must drop buffered `!` output (no cross-thread leak)."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
app._pending_shell_messages = [
|
||||
HumanMessage(content="!echo secret"),
|
||||
AIMessage(content="```text\nsecret\n```"),
|
||||
self._shell_context_message("echo secret", "secret")
|
||||
]
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
@@ -6445,18 +6468,13 @@ class TestShellCommandInterrupt:
|
||||
|
||||
async def test_pending_shell_flush_failure_drops_buffer(self) -> None:
|
||||
"""A checkpoint-write failure must not raise and must clear the buffer."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
app._agent = MagicMock()
|
||||
app._agent.aupdate_state = AsyncMock(
|
||||
side_effect=RuntimeError("checkpoint down")
|
||||
)
|
||||
app._lc_thread_id = "thread-123"
|
||||
app._pending_shell_messages = [
|
||||
HumanMessage(content="!echo hi"),
|
||||
AIMessage(content="```text\nhi\n```"),
|
||||
]
|
||||
app._pending_shell_messages = [self._shell_context_message("echo hi", "hi")]
|
||||
|
||||
# Must not propagate; buffer is dropped so stale output is not replayed.
|
||||
await app._flush_pending_shell_messages()
|
||||
@@ -6466,16 +6484,11 @@ class TestShellCommandInterrupt:
|
||||
|
||||
async def test_pending_shell_flush_without_agent_retains_buffer(self) -> None:
|
||||
"""With no agent/thread, the buffer is kept for a later send, not dropped."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
app._agent = None
|
||||
app._lc_thread_id = None
|
||||
app._session_state = None
|
||||
buffered = [
|
||||
HumanMessage(content="!echo hi"),
|
||||
AIMessage(content="```text\nhi\n```"),
|
||||
]
|
||||
buffered = [self._shell_context_message("echo hi", "hi")]
|
||||
app._pending_shell_messages = list(buffered)
|
||||
|
||||
await app._flush_pending_shell_messages()
|
||||
@@ -6485,18 +6498,13 @@ class TestShellCommandInterrupt:
|
||||
|
||||
async def test_pending_shell_flush_precedes_agent_worker(self) -> None:
|
||||
"""The `!` flush must land before the user-message turn is spawned."""
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
app = DeepAgentsApp()
|
||||
app._agent = MagicMock()
|
||||
app._agent.aupdate_state = AsyncMock()
|
||||
app._lc_thread_id = "thread-123"
|
||||
app._ui_adapter = MagicMock()
|
||||
app._session_state = MagicMock()
|
||||
app._pending_shell_messages = [
|
||||
HumanMessage(content="!echo hi"),
|
||||
AIMessage(content="```text\nhi\n```"),
|
||||
]
|
||||
app._pending_shell_messages = [self._shell_context_message("echo hi", "hi")]
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
@@ -6516,30 +6524,38 @@ class TestShellCommandInterrupt:
|
||||
assert names == ["flush", "spawn"]
|
||||
|
||||
async def test_buffer_shell_appends_in_command_order(self) -> None:
|
||||
"""Multiple `!` commands buffer as ordered Human/AI pairs."""
|
||||
"""Multiple `!` commands buffer as ordered user shell records."""
|
||||
app = DeepAgentsApp()
|
||||
app._buffer_shell_for_model_context("cmd-a", "out-a", 0)
|
||||
app._buffer_shell_for_model_context("cmd-b", "out-b", 0)
|
||||
|
||||
contents = [m.content for m in app._pending_shell_messages]
|
||||
assert contents[0] == "!cmd-a"
|
||||
assert "out-a" in contents[1]
|
||||
assert contents[2] == "!cmd-b"
|
||||
assert "out-b" in contents[3]
|
||||
assert len(contents) == 2
|
||||
assert "cmd-a" in contents[0]
|
||||
assert "out-a" in contents[0]
|
||||
assert "cmd-b" in contents[1]
|
||||
assert "out-b" in contents[1]
|
||||
|
||||
async def test_buffer_shell_empty_output_uses_placeholder(self) -> None:
|
||||
"""Empty output is buffered as `(no output)` so the pair is never blank."""
|
||||
"""Empty output is buffered as `(no output)` so the record is never blank."""
|
||||
app = DeepAgentsApp()
|
||||
app._buffer_shell_for_model_context("true", "", 0)
|
||||
|
||||
assert "(no output)" in app._pending_shell_messages[1].content
|
||||
assert "(no output)" in app._pending_shell_messages[0].content
|
||||
|
||||
async def test_buffer_shell_zero_exit_omits_status_suffix(self) -> None:
|
||||
"""A successful `!` command must not annotate an exit code."""
|
||||
async def test_buffer_shell_zero_exit_records_status(self) -> None:
|
||||
"""A successful `!` command records the exit code in the structured result."""
|
||||
app = DeepAgentsApp()
|
||||
app._buffer_shell_for_model_context("echo ok", "ok", 0)
|
||||
|
||||
assert "exited with code" not in app._pending_shell_messages[1].content
|
||||
assert "Exit code: 0" in app._pending_shell_messages[0].content
|
||||
|
||||
async def test_buffer_shell_unknown_returncode_records_unknown(self) -> None:
|
||||
"""A `None` return code (interrupt/timeout) records `Exit code: unknown`."""
|
||||
app = DeepAgentsApp()
|
||||
app._buffer_shell_for_model_context("sleep 999", "Command interrupted", None)
|
||||
|
||||
assert "Exit code: unknown" in app._pending_shell_messages[0].content
|
||||
|
||||
async def test_unknown_input_mode_does_not_dispatch_to_agent(self) -> None:
|
||||
"""An unrecognized mode must surface an error rather than reach the LLM.
|
||||
|
||||
Reference in New Issue
Block a user