Skip to content
Draft
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
8 changes: 8 additions & 0 deletions src/forge/config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Configuration management using Pydantic settings."""

import logging
import tempfile
from functools import cached_property, lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Literal

from pydantic import Field, SecretStr
Expand Down Expand Up @@ -255,6 +257,12 @@ def detect_model_provider(model_name: str) -> str:
default="skills/",
description="Base directory for skill resolution. The resolver finds skills/default/ and skills/{project}/ under this path.",
)

@property
def skills_install_dir(self) -> Path:
"""Directory for runtime-fetched skill packages."""
return Path(tempfile.gettempdir()) / "forge" / "skills"

container_langchain_verbose: bool = Field(
default=False,
description="Enable LangChain verbose/debug logging in container",
Expand Down
4 changes: 3 additions & 1 deletion src/forge/integrations/agents/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,9 @@ def _get_skill_paths(self, ticket_key: str | None = None) -> list[str]:
fallback to skills/default/ for any skill not overridden by the project.
"""
skills_dir = PROJECT_ROOT / self.settings.skills_dir.rstrip("/")
paths = resolve_skill_paths(ticket_key or "", skills_dir)
paths = resolve_skill_paths(
ticket_key or "", skills_dir, skills_install_dir=self.settings.skills_install_dir
)
logger.debug(f"Using skill paths: {paths}")
return paths

Expand Down
7 changes: 6 additions & 1 deletion src/forge/orchestrator/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,12 @@ async def _process_workflow(self, message: QueueMessage) -> None:
project_key = extract_project_key(ticket_key)
jira_client = JiraClient()
skills_dir = Path(self.settings.skills_dir)
await ensure_skills(project_key, jira_client, skills_dir)
await ensure_skills(
project_key,
jira_client,
skills_dir,
skills_install_dir=self.settings.skills_install_dir,
)
except Exception:
logger.warning(
"Skill synchronisation failed for %s; continuing with workflow.",
Expand Down
7 changes: 6 additions & 1 deletion src/forge/sandbox/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,13 @@ def _get_skill_mounts(
- container_paths: Comma-separated paths for AGENT_SKILL_PATHS env var
"""
skills_dir = Path.cwd() / self.settings.skills_dir.rstrip("/")
# Assumes worker and runner share the same host filesystem — skills_install_dir
# is populated by the worker and mounted into the container from the host.
host_paths = [
Path(p.rstrip("/")) for p in resolve_skill_paths(ticket_key or "", skills_dir)
Path(p.rstrip("/"))
for p in resolve_skill_paths(
ticket_key or "", skills_dir, skills_install_dir=self.settings.skills_install_dir
)
]

mounts = []
Expand Down
22 changes: 14 additions & 8 deletions src/forge/skills/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ async def ensure_skills(
project_key: str,
jira_client: JiraClient,
skills_dir: Path,
*,
skills_install_dir: Path | None = None,
) -> None:
"""Ensure all skills configured for *project_key* are installed.

Expand All @@ -39,15 +41,16 @@ async def ensure_skills(
and installs any entries whose SHA has changed or that are not yet
installed.

The target directory for skills is ``skills_dir/<project_key_lower>/``
where ``<project_key_lower>`` is the lowercase form of *project_key*.

Args:
project_key: Jira project key (e.g., ``"MYPROJ"``).
jira_client: Authenticated Jira client used to read the project
property.
skills_dir: Root directory where skills are installed. The lock file
is read from ``skills_dir/skills.lock``.
skills_dir: Root directory for source-tracked skills (e.g.
``skills/default/``). Used as the install target only when
*skills_install_dir* is not provided.
skills_install_dir: When provided, fetched skills and the lock file are
written here instead of *skills_dir*. Keeps runtime artifacts
out of the source tree.
"""
# ------------------------------------------------------------------
# 1. Fetch the forge.skills configuration from Jira.
Expand All @@ -69,15 +72,18 @@ async def ensure_skills(
return

# ------------------------------------------------------------------
# 2. Read the current lock file.
# 2. Determine install root (skills_install_dir if provided, else skills_dir).
# ------------------------------------------------------------------
lock_path = skills_dir / "skills.lock"
install_root = skills_install_dir if skills_install_dir is not None else skills_dir
install_root.mkdir(parents=True, exist_ok=True)

lock_path = install_root / "skills.lock"
lock = read_lock_file(lock_path)

# ------------------------------------------------------------------
# 3. Determine the target directory (lowercase project key).
# ------------------------------------------------------------------
target_dir = skills_dir / project_key.lower()
target_dir = install_root / project_key.lower()

# ------------------------------------------------------------------
# 4. Process each SkillEntry.
Expand Down
32 changes: 25 additions & 7 deletions src/forge/skills/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,21 @@
logger = logging.getLogger(__name__)


def resolve_skill_paths(ticket_key: str, skills_dir: Path) -> list[str]:
def resolve_skill_paths(
ticket_key: str,
skills_dir: Path,
*,
skills_install_dir: Path | None = None,
) -> list[str]:
"""Return ordered skill source paths for Deep Agents.

Deep Agents loads sources in order and deduplicates by skill name,
with later sources overriding earlier ones (last wins). Default comes
first; project override comes last so it wins on name collision.
with later sources overriding earlier ones (last wins).

Resolution order (lowest to highest priority):
1. ``skills_dir/default/`` — committed default skills
2. ``skills_dir/{project}/`` — committed project overrides
3. ``skills_install_dir/{project}/`` — runtime-fetched project skills
"""
default_dir = skills_dir / "default"

Expand All @@ -18,11 +27,20 @@ def resolve_skill_paths(ticket_key: str, skills_dir: Path) -> list[str]:
return [str(default_dir) + "/"]

project = ticket_key.split("-")[0].lower()
paths: list[str] = [str(default_dir) + "/"]

override_dir = skills_dir / project
if override_dir.is_dir():
paths.append(str(override_dir) + "/")
logger.info(f"Skills: committed override active for '{project}' ({override_dir})")

if not override_dir.is_dir():
if skills_install_dir is not None:
cached_dir = skills_install_dir / project
if cached_dir.is_dir():
paths.append(str(cached_dir) + "/")
logger.info(f"Skills: fetched skills active for '{project}' ({cached_dir})")

if len(paths) == 1:
logger.info(f"Skills: default only (no override for project '{project}')")
return [str(default_dir) + "/"]

logger.info(f"Skills: project override active for '{project}' ({override_dir})")
return [str(default_dir) + "/", str(override_dir) + "/"]
return paths
2 changes: 1 addition & 1 deletion tests/unit/integrations/agents/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,5 @@ def test_get_skill_paths_returns_default_without_ticket_key():
mock_resolver.return_value = ["skills/default/"]
result = agent._get_skill_paths(None)

mock_resolver.assert_called_once_with("", ANY)
mock_resolver.assert_called_once_with("", ANY, skills_install_dir=ANY)
assert result == ["skills/default/"]
14 changes: 9 additions & 5 deletions tests/unit/orchestrator/test_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ async def test_ensure_skills_receives_correct_project_key(
"""Project key extracted from ticket key is passed to ensure_skills."""
received: dict = {}

async def fake_ensure_skills(project_key, _jira_client, _skills_dir) -> None:
async def fake_ensure_skills(project_key, _jira_client, _skills_dir, **_kw) -> None:
received["project_key"] = project_key

with (
Expand All @@ -570,11 +570,14 @@ async def fake_ensure_skills(project_key, _jira_client, _skills_dir) -> None:
async def test_ensure_skills_receives_skills_dir_from_settings(
self, worker: OrchestratorWorker, jira_message: QueueMessage
):
"""skills_dir passed to ensure_skills comes from settings.skills_dir."""
"""skills_dir and skills_install_dir passed to ensure_skills come from settings."""
received: dict = {}

async def fake_ensure_skills(_project_key, _jira_client, skills_dir) -> None:
async def fake_ensure_skills(
_project_key, _jira_client, skills_dir, *, skills_install_dir=None
) -> None:
received["skills_dir"] = skills_dir
received["skills_install_dir"] = skills_install_dir

worker.settings.skills_dir = "custom/skills"

Expand All @@ -587,6 +590,7 @@ async def fake_ensure_skills(_project_key, _jira_client, skills_dir) -> None:
await worker._process_workflow(jira_message)

assert received["skills_dir"] == Path("custom/skills")
assert received["skills_install_dir"] == worker.settings.skills_install_dir

@pytest.mark.asyncio
async def test_workflow_continues_when_ensure_skills_raises(
Expand Down Expand Up @@ -653,7 +657,7 @@ async def test_jira_client_instantiated_for_ensure_skills(
received: dict = {}
fake_client_instance = MagicMock()

async def fake_ensure_skills(_project_key, jira_client, _skills_dir) -> None:
async def fake_ensure_skills(_project_key, jira_client, _skills_dir, **_kw) -> None:
received["jira_client"] = jira_client

with (
Expand All @@ -677,7 +681,7 @@ async def test_ensure_skills_skipped_gracefully_when_forge_skills_not_set(
"""
ensure_skills_called = False

async def fake_ensure_skills_no_property(project_key, jira_client, _skills_dir) -> None:
async def fake_ensure_skills_no_property(project_key, jira_client, _skills_dir, **_kw) -> None:
"""Simulate ensure_skills when forge.skills property is absent (returns None)."""
nonlocal ensure_skills_called
ensure_skills_called = True
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/skills/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,115 @@ async def test_handles_empty_list_from_invalid_json(self, tmp_path: Path, caplog
mock_clone.assert_not_called()
mock_update_lock.assert_not_called()
assert "is empty" in caplog.text


# ---------------------------------------------------------------------------
# skills_install_dir support
# ---------------------------------------------------------------------------


class TestEnsureSkillsInstallDir:
@pytest.mark.asyncio
async def test_lock_file_written_to_skills_install_dir(self, tmp_path: Path) -> None:
"""When skills_install_dir is provided, lock file is written there instead of skills_dir."""
skills = tmp_path / "skills"
skills.mkdir()
cache = tmp_path / "cache"

entry = _make_skill_entry()
jira_client = _make_jira_client([entry])

fake_clone = tmp_path / "clone"
(fake_clone / "skills").mkdir(parents=True)

mock_cm = MagicMock()
mock_cm.__aenter__ = AsyncMock(return_value=fake_clone)
mock_cm.__aexit__ = AsyncMock(return_value=False)

with (
patch(
"forge.skills.orchestrator.resolve_ref_sha",
new=AsyncMock(return_value=RESOLVED_SHA),
),
patch("forge.skills.orchestrator.should_fetch_entry", return_value=True),
patch("forge.skills.orchestrator.clone_context", return_value=mock_cm),
patch(
"forge.skills.orchestrator.install_path_mode",
return_value=["skill-a"],
),
patch("forge.skills.orchestrator.update_lock_file") as mock_update_lock,
patch(
"forge.skills.orchestrator.read_lock_file",
return_value=LockFile(),
),
):
await ensure_skills(PROJECT_KEY, jira_client, skills, skills_install_dir=cache)

lock_path_used = mock_update_lock.call_args[0][0]
assert lock_path_used == cache / "skills.lock"
assert "skills.lock" not in [p.name for p in skills.iterdir()]

@pytest.mark.asyncio
async def test_skills_installed_to_skills_install_dir(self, tmp_path: Path) -> None:
"""When skills_install_dir is provided, skills install to skills_install_dir/{project}."""
skills = tmp_path / "skills"
skills.mkdir()
cache = tmp_path / "cache"

entry = _make_skill_entry()
jira_client = _make_jira_client([entry])

fake_clone = tmp_path / "clone"
(fake_clone / "skills").mkdir(parents=True)

mock_cm = MagicMock()
mock_cm.__aenter__ = AsyncMock(return_value=fake_clone)
mock_cm.__aexit__ = AsyncMock(return_value=False)

captured_target_dir: list[Path] = []

def fake_install(_source_dir: Path, target_dir: Path) -> list[str]:
captured_target_dir.append(target_dir)
return ["skill-a"]

with (
patch(
"forge.skills.orchestrator.resolve_ref_sha",
new=AsyncMock(return_value=RESOLVED_SHA),
),
patch("forge.skills.orchestrator.should_fetch_entry", return_value=True),
patch("forge.skills.orchestrator.clone_context", return_value=mock_cm),
patch(
"forge.skills.orchestrator.install_path_mode",
side_effect=fake_install,
),
patch("forge.skills.orchestrator.update_lock_file"),
patch(
"forge.skills.orchestrator.read_lock_file",
return_value=LockFile(),
),
):
await ensure_skills(PROJECT_KEY, jira_client, skills, skills_install_dir=cache)

assert captured_target_dir[0] == cache / "myproj"

@pytest.mark.asyncio
async def test_skills_install_dir_created_if_missing(self, tmp_path: Path) -> None:
"""skills_install_dir is created automatically when it does not exist."""
cache = tmp_path / "nonexistent" / "cache"
assert not cache.exists()

entry = _make_skill_entry()
jira_client = _make_jira_client([entry])

with (
patch(
"forge.skills.orchestrator.resolve_ref_sha",
new=AsyncMock(return_value=RESOLVED_SHA),
),
patch("forge.skills.orchestrator.should_fetch_entry", return_value=False),
patch("forge.skills.orchestrator.clone_context"),
):
await ensure_skills(PROJECT_KEY, jira_client, tmp_path, skills_install_dir=cache)

assert cache.is_dir()
54 changes: 54 additions & 0 deletions tests/unit/skills/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,57 @@ def test_override_path_is_file_returns_default(skills_dir: Path) -> None:
(skills_dir / "proj").touch() # file, not a directory
result = resolve_skill_paths("PROJ-123", skills_dir)
assert result == [str(skills_dir / "default") + "/"]


# ---------------------------------------------------------------------------
# skills_install_dir support
# ---------------------------------------------------------------------------


def test_skills_install_dir_appended_after_committed_override(
skills_dir: Path, tmp_path: Path
) -> None:
"""When both committed override and cached skills exist, all three paths returned."""
(skills_dir / "proj").mkdir()
cache = tmp_path / "cache"
(cache / "proj").mkdir(parents=True)

result = resolve_skill_paths("PROJ-123", skills_dir, skills_install_dir=cache)
assert result == [
str(skills_dir / "default") + "/",
str(skills_dir / "proj") + "/",
str(cache / "proj") + "/",
]


def test_skills_install_dir_only_fetched(skills_dir: Path, tmp_path: Path) -> None:
"""When only skills_install_dir has a project dir, returns default + cached."""
cache = tmp_path / "cache"
(cache / "proj").mkdir(parents=True)

result = resolve_skill_paths("PROJ-123", skills_dir, skills_install_dir=cache)
assert result == [
str(skills_dir / "default") + "/",
str(cache / "proj") + "/",
]


def test_skills_install_dir_none_preserves_old_behavior(skills_dir: Path) -> None:
"""When skills_install_dir is None, behavior matches original (no extra paths)."""
(skills_dir / "proj").mkdir()
result = resolve_skill_paths("PROJ-123", skills_dir, skills_install_dir=None)
assert result == [
str(skills_dir / "default") + "/",
str(skills_dir / "proj") + "/",
]


def test_skills_install_dir_nonexistent_project_returns_default(
skills_dir: Path, tmp_path: Path
) -> None:
"""When skills_install_dir exists but has no project subdir, returns default only."""
cache = tmp_path / "cache"
cache.mkdir()

result = resolve_skill_paths("PROJ-123", skills_dir, skills_install_dir=cache)
assert result == [str(skills_dir / "default") + "/"]
Loading