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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,44 @@ Options:
Your choice:
```

### Skills

Deep Code Agent can load DeepAgents-native skills from a local skills directory when using the filesystem backend.
By default, it looks for project skills at:

```plaintext
<codebase_dir>/.agents/skills
```

Each skill is a directory with a `SKILL.md` file:

```plaintext
.agents/skills/
└── my-skill/
└── SKILL.md
```

`SKILL.md` must include YAML frontmatter with a skill name and description:

```markdown
---
name: my-skill
description: Specialized workflow for a project task
---

# My Skill

Use these instructions when the task matches this workflow.
```

To load explicit skill directories, pass `--skills-dir`. This option can be provided multiple times, and explicit directories replace the default auto-discovery path:

```bash
uv run deep-code-agent --backend-type filesystem --skills-dir .agents/skills
```

Skills currently require `--backend-type filesystem`; the state backend does not load local skill directories.

### Working with Subagents

The Deep Code Agent includes specialized subagents that can be used independently or as part of the main workflow:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.13,<4.0"
license-files = ["LICENSE"]
dependencies = [
"deepagents>=0.2.8",
"deepagents>=0.6.7",
"langchain>=1.1.0",
"langchain-openai>=1.1.0",
"langgraph>=1.0.4",
Expand Down
22 changes: 22 additions & 0 deletions src/deep_code_agent/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Command-line interface for Deep Code Agent."""

import argparse
from pathlib import Path
from typing import TYPE_CHECKING, Any

from deep_code_agent import __version__
Expand All @@ -20,6 +21,18 @@ def _format_args(args: dict[str, Any], max_length: int = 200) -> str:
return "\n".join(formatted)


def _resolve_skills(args, codebase_dir: str) -> list[str] | None:
"""Resolve skill directories from CLI args or the project default."""
if args.skills_dir:
return [Path(skill_dir).expanduser().absolute().as_posix() for skill_dir in args.skills_dir]

default_skills = Path(codebase_dir) / ".agents" / "skills"
if default_skills.exists():
return [default_skills.absolute().as_posix()]

return None


def _initialize_agent(args, codebase_dir: str) -> Any:
"""Initialize model and agent for both CLI and TUI modes.

Expand All @@ -36,6 +49,7 @@ def _initialize_agent(args, codebase_dir: str) -> Any:
from deep_code_agent.models.llms.langchain_chat import create_chat_model

load_dotenv()
skills = _resolve_skills(args, codebase_dir)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate auto-discovered skills on filesystem backend

When the default backend remains state and the selected codebase already contains .agents/skills, this resolves a non-empty skills list and passes it into create_code_agent, which immediately raises ValueError("Skills require filesystem backend"). That makes otherwise normal state-backend CLI/TUI startup fail merely because the project has a skills directory; only explicit --skills-dir on the state backend should be rejected, or auto-discovery should be skipped unless args.backend_type == "filesystem".

Useful? React with 👍 / 👎.


model = None
if any([args.model_name, args.api_key, args.base_url]) or args.model_provider != "openai":
Expand All @@ -51,6 +65,7 @@ def _initialize_agent(args, codebase_dir: str) -> Any:
model=model,
checkpointer=InMemorySaver(),
backend_type=args.backend_type,
skills=skills,
)


Expand Down Expand Up @@ -194,6 +209,12 @@ def main() -> None:
parser.add_argument("--base-url", default=None, help="Base URL for model service")
parser.add_argument("--thread-id", default="1", help="Thread ID for session")
parser.add_argument("--tui", action="store_true", help="Use TUI mode (experimental)")
parser.add_argument(
"--skills-dir",
action="append",
default=None,
help="Skill directory source. Can be provided multiple times. Defaults to <codebase_dir>/.agents/skills if it exists.",
)

args = parser.parse_args()

Expand Down Expand Up @@ -283,6 +304,7 @@ def agent_factory():
"model": args.model_name or "default",
"session_id": args.thread_id,
"codebase_dir": codebase_dir,
"skills": _resolve_skills(args, codebase_dir) or [],
}

app = DeepCodeAgentApp(
Expand Down
9 changes: 8 additions & 1 deletion src/deep_code_agent/code_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def create_code_agent(
checkpointer: Any = None,
backend_type: str = "state",
interrupt_on: dict[str, bool | Any] | None = DEFAULT_INTERRUPT_ON,
skills: list[str] | None = None,
):
"""
Create a DeepAgents-based Code Agent for software development tasks.
Expand All @@ -44,6 +45,8 @@ def create_code_agent(
backend_type: Backend type to use. Must be either "state" or "filesystem".
interrupt_on: Configuration for human-in-the-loop approval. Maps tool names
to approval settings. Set to None to disable all approvals.
skills: Optional list of local skill directory sources. Skills are only
supported with the filesystem backend in this project.

Returns:
A fully configured Code Agent instance ready to handle software development tasks.
Expand All @@ -53,6 +56,9 @@ def create_code_agent(
ValueError: If an unsupported backend type is provided.
"""
path = Path(codebase_dir)
if backend_type == "state" and skills:
raise ValueError("Skills require filesystem backend; use backend_type='filesystem'.")

if backend_type == "filesystem" and not path.exists():
path.mkdir(parents=True, exist_ok=True)

Expand All @@ -64,7 +70,7 @@ def create_code_agent(
if backend_type == "filesystem":
from deepagents.backends.filesystem import FilesystemBackend

backend = FilesystemBackend(root_dir=codebase_dir)
backend = FilesystemBackend(root_dir=codebase_dir, virtual_mode=False)
tools = [make_terminal_tool(codebase_dir)]
elif backend_type == "state":
from deepagents.backends.state import StateBackend
Expand All @@ -85,6 +91,7 @@ def backend(rt):
checkpointer=checkpointer,
backend=backend,
interrupt_on=interrupt_on,
skills=skills,
)
except Exception as exc:
raise RuntimeError(f"Error creating DeepAgent: {exc}") from exc
8 changes: 8 additions & 0 deletions src/deep_code_agent/tui/bridge/agent_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ def _reset_streaming_state(self) -> None:
self._pending_tool_widgets.clear()
self._tool_widgets_by_id.clear()

def _finish_streaming_message_segment(self) -> None:
"""Detach the current assistant bubble so later chunks start a new one."""
self._streaming_chunks.clear()
self._streaming_bubble = None
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve split bubbles after message completion

After a tool boundary this clears only the bridge's current segment, but StreamHandler still accumulates all message chunks for the turn and later emits MESSAGE_COMPLETE with the full combined text. In a response like Before → tool → After, the final complete event updates the post-tool bubble to BeforeAfter, duplicating the pre-tool text and undoing the intended split at tool boundaries.

Useful? React with 👍 / 👎.


def _extract_tool_name_from_interrupt(self, interrupt_data: dict) -> str:
"""Extract tool name from interrupt data.

Expand Down Expand Up @@ -346,6 +351,7 @@ def handle_event() -> None:
input_box.focus_input()

elif event.type == EventType.TOOL_CALL:
self._finish_streaming_message_segment()
tool_data = event.data or {}
tool_name = (
tool_data.get("name", "unknown")
Expand Down Expand Up @@ -414,6 +420,7 @@ def handle_event() -> None:
self._active_tool_widget.update_status("running")

elif event.type == EventType.TOOL_SUCCESS:
self._finish_streaming_message_segment()
result = event.data or ""
meta = event.metadata or {}
tool_call_id = meta.get("tool_call_id") if isinstance(meta, dict) else None
Expand Down Expand Up @@ -449,6 +456,7 @@ def handle_event() -> None:
self._active_tool_widget = None

elif event.type == EventType.TOOL_ERROR:
self._finish_streaming_message_segment()
error = event.data or "Unknown error"
meta = event.metadata or {}
tool_call_id = meta.get("tool_call_id") if isinstance(meta, dict) else None
Expand Down
67 changes: 66 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import pytest

from deep_code_agent import __version__
from deep_code_agent.cli import _initialize_agent, _run_tui_mode, main
from deep_code_agent.cli import _initialize_agent, _resolve_skills, _run_tui_mode, main


def test_main_prints_version_and_exits(capsys):
Expand Down Expand Up @@ -37,6 +37,7 @@ def test_initialize_agent_skips_model_creation_for_default_provider(
api_key=None,
base_url=None,
backend_type="state",
skills_dir=None,
)

_initialize_agent(args, "/tmp/project")
Expand All @@ -46,6 +47,7 @@ def test_initialize_agent_skips_model_creation_for_default_provider(
assert mock_create_code_agent.call_args.kwargs["model"] is None
assert mock_create_code_agent.call_args.kwargs["backend_type"] == "state"
assert mock_create_code_agent.call_args.kwargs["checkpointer"] is mock_checkpointer.return_value
assert mock_create_code_agent.call_args.kwargs["skills"] is None


@patch("dotenv.load_dotenv")
Expand All @@ -65,6 +67,7 @@ def test_initialize_agent_builds_model_for_explicit_provider(
api_key=None,
base_url=None,
backend_type="filesystem",
skills_dir=None,
)
mock_create_chat_model.return_value = object()

Expand All @@ -81,12 +84,72 @@ def test_initialize_agent_builds_model_for_explicit_provider(
assert mock_create_code_agent.call_args.kwargs["checkpointer"] is mock_checkpointer.return_value


def test_resolve_skills_uses_explicit_dirs_in_order(tmp_path):
"""Explicit skill dirs should be absolutized and keep user order."""
first = tmp_path / "first"
second = tmp_path / "second"

result = _resolve_skills(SimpleNamespace(skills_dir=[str(first), str(second)]), str(tmp_path))

assert result == [first.absolute().as_posix(), second.absolute().as_posix()]


def test_resolve_skills_finds_default_agents_skills(tmp_path):
"""Default skills path is <codebase_dir>/.agents/skills."""
default_skills = tmp_path / ".agents" / "skills"
default_skills.mkdir(parents=True)

result = _resolve_skills(SimpleNamespace(skills_dir=None), str(tmp_path))

assert result == [default_skills.absolute().as_posix()]


def test_resolve_skills_returns_none_when_default_missing(tmp_path):
"""No explicit dirs and no default directory should disable skills."""
result = _resolve_skills(SimpleNamespace(skills_dir=None), str(tmp_path))

assert result is None


@patch("dotenv.load_dotenv")
@patch("deep_code_agent.code_agent.create_code_agent")
@patch("deep_code_agent.models.llms.langchain_chat.create_chat_model")
@patch("langgraph.checkpoint.memory.InMemorySaver")
def test_initialize_agent_passes_resolved_skills(
mock_checkpointer,
mock_create_chat_model,
mock_create_code_agent,
_mock_load_dotenv,
tmp_path,
):
"""Agent initialization should pass resolved skill directories."""
skills = tmp_path / ".agents" / "skills"
skills.mkdir(parents=True)
args = SimpleNamespace(
model_name=None,
model_provider="openai",
api_key=None,
base_url=None,
backend_type="filesystem",
skills_dir=None,
)

_initialize_agent(args, str(tmp_path))

assert mock_create_code_agent.call_args.kwargs["skills"] == [skills.absolute().as_posix()]


@patch("deep_code_agent.tui.DeepCodeAgentApp")
@patch("deep_code_agent.cli._initialize_agent")
def test_tui_mode_defers_agent_initialization_until_after_app_starts(mock_initialize_agent, mock_app_class):
"""TUI mode should render before doing slow agent initialization."""
args = SimpleNamespace(
model_name=None,
model_provider="openai",
api_key=None,
base_url=None,
backend_type="filesystem",
skills_dir=["/tmp/custom-skills"],
thread_id="test-thread",
)
app_instance = mock_app_class.return_value
Expand All @@ -96,4 +159,6 @@ def test_tui_mode_defers_agent_initialization_until_after_app_starts(mock_initia
mock_initialize_agent.assert_not_called()
mock_app_class.assert_called_once()
assert "agent_factory" in mock_app_class.call_args.kwargs
mock_app_class.call_args.kwargs["agent_factory"]()
assert mock_initialize_agent.call_args.args == (args, mock_app_class.call_args.kwargs["session_info"]["codebase_dir"])
app_instance.run.assert_called_once_with()
37 changes: 36 additions & 1 deletion tests/test_code_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,46 @@ def test_filesystem_backend_creates_directory_and_mounts_terminal(

assert target_dir.exists()
call_kwargs = mock_create_agent.call_args.kwargs
mock_filesystem_backend.assert_called_once_with(root_dir=target_dir.absolute().as_posix())
mock_filesystem_backend.assert_called_once_with(root_dir=target_dir.absolute().as_posix(), virtual_mode=False)
mock_make_terminal_tool.assert_called_once_with(target_dir.absolute().as_posix())
assert call_kwargs["backend"] is mock_filesystem_backend.return_value
assert call_kwargs["tools"] == [mock_terminal_tool]
assert call_kwargs["tools"][0] is not terminal
assert call_kwargs["skills"] is None

@patch("deepagents.backends.filesystem.FilesystemBackend")
@patch("deep_code_agent.code_agent.make_terminal_tool")
@patch("deep_code_agent.code_agent.create_deep_agent")
@patch("deep_code_agent.code_agent.create_chat_model")
@patch("deep_code_agent.code_agent.get_system_prompt")
@patch("deep_code_agent.code_agent.create_subagent_configurations")
def test_filesystem_backend_passes_skills(
self,
mock_subagents,
mock_prompt,
mock_create_chat_model,
mock_create_agent,
mock_make_terminal_tool,
mock_filesystem_backend,
tmp_path,
):
"""Test filesystem backend passes skill directories to DeepAgents."""
mock_prompt.return_value = "Root: {codebase_dir}"
mock_subagents.return_value = []
mock_create_chat_model.return_value = MagicMock()
mock_create_agent.return_value = MagicMock()
mock_make_terminal_tool.return_value = MagicMock()
skills = [tmp_path.joinpath(".agents", "skills").as_posix()]

create_code_agent(str(tmp_path), backend_type="filesystem", skills=skills)

call_kwargs = mock_create_agent.call_args.kwargs
assert call_kwargs["skills"] == skills

def test_state_backend_rejects_skills(self):
"""State backend should not accept local skill directories."""
with pytest.raises(ValueError, match="Skills require filesystem backend"):
create_code_agent("/tmp/test", backend_type="state", skills=["/tmp/test/.agents/skills"])

@patch("deep_code_agent.code_agent.create_deep_agent")
@patch("deep_code_agent.code_agent.create_chat_model")
Expand Down
Loading
Loading