Files
deepagents/libs/code/deepagents_code/project_utils.py
Mason Daugherty 04de43e05a feat(code,talon): require Python 3.12 or greater (#5603)
`dcode` now requires Python 3.12 or newer (previously 3.11). Users on
3.11 will need to upgrade their interpreter to install or update to this
release.

---

The floor bump lets the codebase use 3.12-only syntax and drop 3.11
compatibility shims:

- PEP 695 `type` statements replace `TypeAlias` annotations, and generic
functions/classes use type-parameter syntax instead of
`TypeVar`/`Generic[...]`
- `Protocol` / `override` import from `typing` instead of
`typing_extensions`
- The pre-PEP-706 `tarfile` extraction fallback (needed only for
3.11.0–3.11.3) is gone; extraction always uses `filter="data"`
- `uv.lock` re-locks without 3.11-only backports (`tomli`,
`backports-tarfile`)
- CI matrices, `ty`, and ruff `target-version` all move to 3.12;
integration test defaults and release builds are updated to match

`deepagents-talon` depends on `deepagents-code`, so its floor moves in
the same commit — it cannot bump independently without a broken window
where talon allows an interpreter its dependency rejects. This is the
deliberate coordinated multi-package bump called out in RELEASING.md, so
the `allow-lockfile-release` label is applied to acknowledge the talon
release-please fan-out.

<details>
<summary>Test plan</summary>

- `ruff check` / `ruff format --check` clean
- `ty check` clean
- Full `libs/code` unit suite passes (two pre-existing failures in
`test_app.py` / `test_server_graph.py` reproduce on the base commit and
are unrelated)
- COMMANDS.md catalog check and lockfile freshness hooks pass

</details>
2026-08-18 14:53:06 -04:00

232 lines
8.1 KiB
Python

"""Utilities for project root detection and project-specific configuration."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from deepagents_code._env_vars import SERVER_ENV_PREFIX
from deepagents_code._git import find_git_root
if TYPE_CHECKING:
from collections.abc import Mapping
import logging
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ProjectContext:
"""Explicit user/project path context for project-sensitive behavior.
Attributes:
user_cwd: Authoritative working directory from the app invocation.
project_root: Resolved project root for `user_cwd`, if one exists.
"""
user_cwd: Path
project_root: Path | None = None
def __post_init__(self) -> None:
"""Validate that path fields are absolute.
Raises:
ValueError: If `user_cwd` or `project_root` is not absolute.
"""
if not self.user_cwd.is_absolute():
msg = f"user_cwd must be absolute, got {self.user_cwd!r}"
raise ValueError(msg)
if self.project_root is not None and not self.project_root.is_absolute():
msg = f"project_root must be absolute, got {self.project_root!r}"
raise ValueError(msg)
@classmethod
def from_user_cwd(cls, user_cwd: str | Path) -> ProjectContext:
"""Build a project context from an explicit user working directory.
Args:
user_cwd: User invocation directory.
Returns:
Resolved project context.
"""
resolved_cwd = Path(user_cwd).expanduser().resolve()
return cls(
user_cwd=resolved_cwd,
project_root=find_project_root(resolved_cwd),
)
def resolve_user_path(self, path: str | Path) -> Path:
"""Resolve a path relative to the explicit user working directory.
Args:
path: Absolute or relative user-facing path.
Returns:
Absolute resolved path.
"""
candidate = Path(path).expanduser()
if candidate.is_absolute():
return candidate.resolve()
return (self.user_cwd / candidate).resolve()
def project_agent_md_paths(self) -> list[Path]:
"""Return project-level `AGENTS.md` files for this context."""
if self.project_root is None:
return []
return find_project_agent_md(self.project_root)
def project_skills_dir(self) -> Path | None:
"""Return the project `.deepagents/skills` directory, if any."""
if self.project_root is None:
return None
return self.project_root / ".deepagents" / "skills"
def project_agents_dir(self) -> Path | None:
"""Return the project `.deepagents/agents` directory, if any."""
if self.project_root is None:
return None
return self.project_root / ".deepagents" / "agents"
def project_agent_skills_dir(self) -> Path | None:
"""Return the project `.agents/skills` directory, if any."""
if self.project_root is None:
return None
return self.project_root / ".agents" / "skills"
def get_server_project_context(
env: Mapping[str, str] | None = None,
) -> ProjectContext | None:
"""Read the server project context from environment transport data.
Args:
env: Environment mapping to read from.
Returns:
Reconstructed project context, or `None` if no server context exists.
"""
environment = os.environ if env is None else env
raw_cwd = environment.get(f"{SERVER_ENV_PREFIX}CWD")
if not raw_cwd:
return None
try:
user_cwd = Path(raw_cwd).expanduser().resolve()
raw_project_root = environment.get(f"{SERVER_ENV_PREFIX}PROJECT_ROOT")
project_root = (
Path(raw_project_root).expanduser().resolve()
if raw_project_root
else find_project_root(user_cwd)
)
except OSError:
logger.warning(
"Could not resolve server project context from CWD=%s",
raw_cwd,
exc_info=True,
)
return None
return ProjectContext(user_cwd=user_cwd, project_root=project_root)
def find_project_root(start_path: str | Path | None = None) -> Path | None:
"""Find the project root by looking for git metadata.
Args:
start_path: Directory to start searching from.
Defaults to current working directory.
Returns:
Path to the project root if found, None otherwise.
"""
current = Path(start_path or Path.cwd()).expanduser().resolve()
return find_git_root(current)
def find_project_agent_md(project_root: Path) -> list[Path]:
"""Find project-specific AGENTS.md file(s).
Checks two locations and returns ALL that exist:
1. project_root/.deepagents/AGENTS.md
2. project_root/AGENTS.md
Both files will be loaded and combined if both exist.
Candidates with symlinked path components are followed only when the
resolved target stays inside `project_root`. The returned `Path` is the
resolved target when any symlink component was traversed, so
`FilesystemBackend.download_files` opens a regular file rather than
tripping `O_NOFOLLOW` on the link itself. Symlinks pointing outside the
project root, symlink loops, and unreadable parents are skipped with a
warning. Broken symlinks are treated as missing files (no warning),
matching the pre-existing behavior for absent candidates.
Why: project AGENTS.md is auto-discovered and loaded into the system
prompt before the first model call. Without the in-tree check, a
malicious clone could ship `AGENTS.md -> ~/.ssh/config` (or any other
locally-readable file) and have its contents injected as agent
instructions on first run.
Args:
project_root: Path to the project root directory.
Returns:
Existing AGENTS.md paths, with in-tree symlinked path components
pre-resolved to their targets. Empty if neither file exists, one
entry if only one is present, or two entries if both locations
have the file.
"""
# Resolve the root once so the candidate-equality check below works even
# when the caller passes a non-canonical `project_root` (e.g., macOS
# `/var` -> `/private/var`, or a path with symlinked ancestors).
project_root_resolved = project_root.resolve()
candidates = [
project_root_resolved / ".deepagents" / "AGENTS.md",
project_root_resolved / "AGENTS.md",
]
paths: list[Path] = []
for candidate in candidates:
try:
# Single syscall handles existence, broken symlinks, loops, and
# canonicalization. `strict=True` raises rather than returning
# the unresolved path.
resolved = candidate.resolve(strict=True)
except FileNotFoundError:
# Absent file or broken symlink — matches the pre-existing
# silent-skip behavior for missing candidates. `Path.exists()`
# also returns False for symlink loops on every supported version,
# so loops do NOT come through here; see the OSError branch.
continue
except (OSError, RuntimeError) as exc:
# `OSError(ELOOP)` on Python 3.13+, `RuntimeError("Symlink loop
# ...")` on 3.12 only; bare `OSError` for permission/unreadable
# parent. Security-relevant — warn and skip.
logger.warning(
"Skipping AGENTS.md candidate %s: %s",
candidate,
exc,
)
continue
try:
resolved.relative_to(project_root_resolved)
except ValueError:
logger.warning(
"Skipping AGENTS.md symlink %s: target %s is outside "
"the project root %s",
candidate,
resolved,
project_root_resolved,
)
continue
if candidate.absolute() == resolved:
paths.append(candidate)
else:
paths.append(resolved)
return paths