Skip to content
Closed
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
84 changes: 82 additions & 2 deletions bot/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
import asyncio
import logging
import os
from pathlib import Path
from typing import Any

from agents import Agent
from agents import Runner
from agents import ShellTool
from agents import ShellToolLocalSkill
from agents import TResponseInputItem
from agents.mcp import MCPServerStdio
from agents.mcp import MCPServerStreamableHttp
Expand All @@ -24,6 +27,7 @@

MAX_TURNS = 10
MCP_SESSION_TIMEOUT_SECONDS = 30.0
SHELL_TIMEOUT = 30.0

set_tracing_disabled(True)

Expand All @@ -47,20 +51,79 @@ def _get_model() -> OpenAIResponsesModel | OpenAIChatCompletionsModel:
return OpenAIResponsesModel(model=model_name, openai_client=client)


def _read_skill_description(skill_dir: Path) -> str:
"""Read the description field from a skill's SKILL.md frontmatter.

Returns an empty string if the file is missing or has no description.
"""
skill_md = skill_dir / "SKILL.md"
try:
content = skill_md.read_text(encoding="utf-8")
except FileNotFoundError:
logging.warning("SKILL.md not found in %s", skill_dir)
return ""

# Parse YAML frontmatter between --- delimiters
if not content.startswith("---"):
return ""
end = content.find("\n---", 3)
if end == -1:
return ""
frontmatter = content[3:end]
for line in frontmatter.splitlines():
if line.startswith("description:"):
return line[len("description:") :].strip()
return ""


async def _shell_executor(request: Any) -> str:
"""Execute a shell command from a LocalShellCommandRequest.

Uses timeout_ms from the request if provided, otherwise falls back
to SHELL_TIMEOUT seconds.
"""
action = request.data.action
command: list[str] = action.command
env: dict[str, str] = action.env or {}
cwd: str | None = action.working_directory
timeout_ms: int | None = action.timeout_ms

timeout = (timeout_ms / 1000.0) if timeout_ms is not None else SHELL_TIMEOUT

merged_env = {**os.environ, **env}

proc = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
env=merged_env,
cwd=cwd,
)
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return stdout.decode("utf-8", errors="replace")
except TimeoutError:
proc.kill()
await proc.communicate()
return f"Command timed out after {timeout}s"


class OpenAIAgent:
"""A wrapper for OpenAI Agent with MCP server support."""
"""A wrapper for OpenAI Agent with MCP server and local shell skill support."""

def __init__(
self,
name: str,
mcp_servers: list | None = None,
tools: list | None = None,
instructions: str = DEFAULT_INSTRUCTIONS,
) -> None:
self.agent = Agent(
name=name,
instructions=instructions,
model=_get_model(),
mcp_servers=(mcp_servers if mcp_servers is not None else []),
tools=(tools if tools is not None else []),
)
self.name = name
self._conversations: dict[int, list[TResponseInputItem]] = {}
Expand Down Expand Up @@ -116,8 +179,25 @@ def from_dict(cls, name: str, config: dict[str, Any]) -> OpenAIAgent:
},
)
)

tools: list[Any] = []
skill_configs = config.get("shellSkills", [])
if skill_configs:
skills: list[ShellToolLocalSkill] = []
for skill_cfg in skill_configs:
skill_path = Path(os.path.expanduser(skill_cfg["path"]))
description = _read_skill_description(skill_path)
skills.append(
ShellToolLocalSkill(
name=skill_cfg["name"],
description=description,
path=str(skill_path),
)
)
tools.append(ShellTool(executor=_shell_executor, environment={"type": "local", "skills": skills}))

instructions = config.get("instructions", DEFAULT_INSTRUCTIONS)
return cls(name, mcp_servers, instructions=instructions)
return cls(name, mcp_servers, tools=tools, instructions=instructions)

async def connect(self) -> None:
for mcp_server in self.agent.mcp_servers:
Expand Down
16 changes: 15 additions & 1 deletion servers_config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,19 @@
"command": "uvx",
"args": ["yfmcp"]
}
}
},
"shellSkills": [
{
"name": "obsidian-cli",
"path": "~/.claude/skills/obsidian-cli"
},
{
"name": "obsidian-markdown",
"path": "~/.claude/skills/obsidian-markdown"
},
{
"name": "obsidian-bases",
"path": "~/.claude/skills/obsidian-bases"
}
]
}
131 changes: 131 additions & 0 deletions tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from unittest.mock import patch

import pytest
from agents import ShellTool
from agents.mcp import MCPServerStdio
from agents.mcp import MCPServerStreamableHttp
from agents.models.interface import Model
Expand All @@ -13,8 +14,10 @@

from bot.agents import DEFAULT_INSTRUCTIONS
from bot.agents import MAX_TURNS
from bot.agents import SHELL_TIMEOUT
from bot.agents import OpenAIAgent
from bot.agents import _get_model
from bot.agents import _shell_executor


@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -233,3 +236,131 @@ def test_no_truncation_when_under_limit(self):
msgs = agent.get_messages(chat_id=100)
user_msgs = [m for m in msgs if m["role"] == "user"]
assert len(user_msgs) == 3


def _make_shell_request(
command: list[str],
env: dict | None = None,
cwd: str | None = None,
timeout_ms: int | None = None,
):
"""Build a mock LocalShellCommandRequest."""
action = MagicMock()
action.command = command
action.env = env or {}
action.working_directory = cwd
action.timeout_ms = timeout_ms
request = MagicMock()
request.data.action = action
return request


class TestShellExecutor:
def test_constant_is_30_seconds(self):
assert SHELL_TIMEOUT == 30.0

@pytest.mark.anyio
async def test_returns_stdout(self):
request = _make_shell_request(["echo", "hello"])
result = await _shell_executor(request)
assert "hello" in result

@pytest.mark.anyio
async def test_stderr_merged_into_output(self):
request = _make_shell_request(["bash", "-c", "echo err >&2"])
result = await _shell_executor(request)
assert "err" in result

@pytest.mark.anyio
async def test_uses_working_directory(self):
request = _make_shell_request(["pwd"], cwd="/tmp")
result = await _shell_executor(request)
assert "/tmp" in result

@pytest.mark.anyio
async def test_times_out_and_returns_message(self, monkeypatch):
monkeypatch.setattr("bot.agents.SHELL_TIMEOUT", 0.05)
request = _make_shell_request(["sleep", "10"])
result = await _shell_executor(request)
assert "timed out" in result.lower()

@pytest.mark.anyio
async def test_timeout_ms_from_request_overrides_default(self, monkeypatch):
monkeypatch.setattr("bot.agents.SHELL_TIMEOUT", 30.0)
# 50ms timeout from request — sleep 10s should time out
request = _make_shell_request(["sleep", "10"], timeout_ms=50)
result = await _shell_executor(request)
assert "timed out" in result.lower()


class TestFromDictShellSkills:
def test_no_shell_skills_when_config_missing(self):
agent = OpenAIAgent.from_dict("test", {"mcpServers": {}})
shell_tools = [t for t in agent.agent.tools if isinstance(t, ShellTool)]
assert len(shell_tools) == 0

def test_shell_tool_added_when_skills_configured(self, tmp_path):
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\n")

config = {
"mcpServers": {},
"shellSkills": [{"name": "my-skill", "path": str(skill_dir)}],
}
agent = OpenAIAgent.from_dict("test", config)
shell_tools = [t for t in agent.agent.tools if isinstance(t, ShellTool)]
assert len(shell_tools) == 1

def test_skill_description_read_from_skill_md(self, tmp_path):
skill_dir = tmp_path / "obs"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("---\nname: obs\ndescription: Interact with Obsidian\n---\n# Content\n")
config = {
"mcpServers": {},
"shellSkills": [{"name": "obs", "path": str(skill_dir)}],
}
agent = OpenAIAgent.from_dict("test", config)
shell_tool = next(t for t in agent.agent.tools if isinstance(t, ShellTool))
skill = shell_tool.environment["skills"][0]
assert skill["description"] == "Interact with Obsidian"

def test_tilde_expanded_in_path(self, tmp_path, monkeypatch):
skill_dir = tmp_path / "skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("---\nname: s\ndescription: d\n---\n")
monkeypatch.setenv("HOME", str(tmp_path))

config = {
"mcpServers": {},
"shellSkills": [{"name": "s", "path": "~/skill"}],
}
agent = OpenAIAgent.from_dict("test", config)
shell_tool = next(t for t in agent.agent.tools if isinstance(t, ShellTool))
assert shell_tool.environment["skills"][0]["path"] == str(skill_dir)

def test_multiple_skills_all_mounted(self, tmp_path):
skills = []
for name in ["skill-a", "skill-b"]:
d = tmp_path / name
d.mkdir()
(d / "SKILL.md").write_text(f"---\nname: {name}\ndescription: desc\n---\n")
skills.append({"name": name, "path": str(d)})

agent = OpenAIAgent.from_dict("test", {"mcpServers": {}, "shellSkills": skills})
shell_tool = next(t for t in agent.agent.tools if isinstance(t, ShellTool))
assert len(shell_tool.environment["skills"]) == 2

def test_mcp_servers_and_shell_skills_coexist(self, tmp_path):
skill_dir = tmp_path / "s"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text("---\nname: s\ndescription: d\n---\n")

config = {
"mcpServers": {"my-mcp": {"command": "uvx", "args": ["something"]}},
"shellSkills": [{"name": "s", "path": str(skill_dir)}],
}
agent = OpenAIAgent.from_dict("test", config)
assert len(agent.agent.mcp_servers) == 1
shell_tools = [t for t in agent.agent.tools if isinstance(t, ShellTool)]
assert len(shell_tools) == 1
Loading