diff --git a/README.md b/README.md index 378d3e5..6d919e4 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ See also: [agentic-slackbot](https://github.com/John-Lin/agentic-slackbot) — a - Supports OpenAI, Azure OpenAI endpoints - Per-conversation history with automatic truncation - Group reply chain — after `@mention`, anyone can continue by replying -- Local shell skills — let the agent run shell scripts from `skills/` (opt-in via `SHELL_SKILLS_ENABLED`) +- Optional local shell via `ShellTool`, controlled by `SHELL_ENABLED` and `SHELL_SKILLS_DIR` ## Install Dependencies @@ -43,8 +43,12 @@ export TELEGRAM_BOT_TOKEN="" export OPENAI_API_KEY="" export OPENAI_MODEL="gpt-5.4" -# Shell skills (disabled by default) -# export SHELL_SKILLS_ENABLED=1 +# Local shell (disabled by default) +# export SHELL_ENABLED=1 +# export SHELL_SKILLS_DIR="./skills" # optional; mount skills alongside the shell + +# Optional verbose OpenAI Agents SDK logging +# export AGENT_VERBOSE_LOG=1 ``` ## Agent Instructions @@ -173,6 +177,38 @@ uv run bot access group remove Group members do not need to pair individually — access is controlled at the group level. +## Local Shell (Optional) + +The bot can expose a local `ShellTool`. This is **disabled by default**. Enable it with: + +``` +export SHELL_ENABLED=1 +``` + +With just `SHELL_ENABLED=1`, the agent gets bare local shell access with no pre-defined skills. + +### Shell Skills (Optional) + +You can optionally mount a skills directory alongside the shell. Each immediate subdirectory containing a `SKILL.md` file is registered as a skill and exposed to the agent as a hint (skills are advisory metadata — they do **not** sandbox command execution). + +``` +export SHELL_ENABLED=1 +export SHELL_SKILLS_DIR="./skills" +``` + +`SHELL_SKILLS_DIR` is ignored unless `SHELL_ENABLED` is set. If the directory is missing or contains no valid skills, the bot falls back to a bare shell and logs a warning. + +The `SKILL.md` file should have YAML frontmatter with `name` and `description` fields: + +```markdown +--- +name: my-skill +description: A brief description of what this skill does +--- + +Detailed instructions for the agent... +``` + ## Docker ```bash diff --git a/app.py b/app.py index 537e9f4..05dac4d 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,8 @@ import logging import sys +from agents import enable_verbose_stdout_logging + from bot.agents import OpenAIAgent from bot.auth import add_group from bot.auth import allow_user @@ -15,15 +17,22 @@ from bot.auth import remove_user from bot.auth import set_dm_policy from bot.config import Configuration +from bot.config import env_flag from bot.telegram import TelegramMCPBot -async def start_bot() -> None: - """Initialize and run the Telegram bot.""" +def _configure_logging() -> None: logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO, ) + if env_flag("AGENT_VERBOSE_LOG"): + enable_verbose_stdout_logging() + + +async def start_bot() -> None: + """Initialize and run the Telegram bot.""" + _configure_logging() config = Configuration() server_config = config.load_config("servers_config.json") diff --git a/bot/agents.py b/bot/agents.py index 2894267..af0d691 100644 --- a/bot/agents.py +++ b/bot/agents.py @@ -20,12 +20,13 @@ from agents.tracing import set_tracing_disabled from openai import AsyncOpenAI +from .config import env_flag + INSTRUCTIONS_FILE = Path("instructions.md") MAX_TURNS = 10 MCP_SESSION_TIMEOUT_SECONDS = 30.0 SHELL_TIMEOUT = 30.0 -SKILLS_DIR = Path(__file__).resolve().parent.parent / "skills" set_tracing_disabled(True) @@ -80,17 +81,17 @@ def _parse_skill_description(content: str) -> str: return "" -def _load_shell_skills() -> list[ShellToolLocalSkill]: - """Discover local shell skills under SKILLS_DIR. +def _load_shell_skills(skills_dir: Path) -> list[ShellToolLocalSkill]: + """Discover local shell skills under ``skills_dir``. - Each immediate subdirectory of SKILLS_DIR containing a SKILL.md is mounted - as a ShellToolLocalSkill. The skill name is the directory name; the - description is read from the SKILL.md YAML frontmatter. + Each immediate subdirectory containing a ``SKILL.md`` file is mounted as a + ``ShellToolLocalSkill``. The skill name is the directory name; the + description is read from the ``SKILL.md`` YAML frontmatter. """ - if not SKILLS_DIR.is_dir(): + if not skills_dir.is_dir(): return [] skills: list[ShellToolLocalSkill] = [] - for skill_dir in sorted(SKILLS_DIR.iterdir()): + for skill_dir in sorted(skills_dir.iterdir()): skill_md = skill_dir / "SKILL.md" if not skill_dir.is_dir() or not skill_md.is_file(): continue @@ -109,6 +110,37 @@ def _load_shell_skills() -> list[ShellToolLocalSkill]: return skills +def _get_shell_environment() -> ShellToolLocalEnvironment | None: + """Return the configured local shell environment, if enabled. + + Controlled by two independent environment variables: + + * ``SHELL_ENABLED`` — when truthy, the ``ShellTool`` is attached to the agent. + * ``SHELL_SKILLS_DIR`` — optional path; when set, skills discovered under it + are mounted alongside the shell. Ignored if ``SHELL_ENABLED`` is not set. + """ + skills_dir_env = os.getenv("SHELL_SKILLS_DIR") + if not env_flag("SHELL_ENABLED"): + if skills_dir_env: + logging.warning( + "SHELL_SKILLS_DIR=%r is set but SHELL_ENABLED is not; ignoring skills dir.", + skills_dir_env, + ) + return None + + environment: ShellToolLocalEnvironment = {"type": "local"} + if skills_dir_env: + skills = _load_shell_skills(Path(skills_dir_env)) + if skills: + environment["skills"] = skills + else: + logging.warning( + "SHELL_SKILLS_DIR=%r yielded no skills; attaching bare local shell.", + skills_dir_env, + ) + return environment + + async def _shell_executor(request: ShellCommandRequest) -> str: """Run each shell command from the request and return combined output. @@ -215,11 +247,9 @@ def from_dict(cls, name: str, config: dict[str, Any]) -> OpenAIAgent: ) ) tools: list[Any] = [] - if os.getenv("SHELL_SKILLS_ENABLED"): - skills = _load_shell_skills() - if skills: - environment = ShellToolLocalEnvironment(type="local", skills=skills) - tools.append(ShellTool(executor=_shell_executor, environment=environment)) + environment = _get_shell_environment() + if environment is not None: + tools.append(ShellTool(executor=_shell_executor, environment=environment)) instructions = _load_instructions() return cls(name, instructions=instructions, mcp_servers=mcp_servers, tools=tools) diff --git a/bot/config.py b/bot/config.py index da3c55c..2f74ee6 100644 --- a/bot/config.py +++ b/bot/config.py @@ -8,6 +8,21 @@ logger = logging.getLogger(__name__) +_FALSY_ENV_VALUES = frozenset({"", "0", "false", "no", "off"}) + + +def env_flag(name: str) -> bool: + """Return True if env var ``name`` is set to a truthy value. + + Common falsy spellings (empty, "0", "false", "no", "off") are treated as + disabled so that ``FOO=0`` behaves as users intuitively expect rather than + as Python's default "non-empty string is truthy" rule. + """ + raw = os.getenv(name) + if raw is None: + return False + return raw.strip().lower() not in _FALSY_ENV_VALUES + class Configuration: """Manages configuration and environment variables for the MCP Telegram bot.""" diff --git a/tests/test_agents.py b/tests/test_agents.py index 1bf6636..64355b1 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -27,12 +27,11 @@ def _stub_instructions(monkeypatch): @pytest.fixture(autouse=True) -def _mock_model(monkeypatch, tmp_path_factory): - """Prevent tests from constructing a real OpenAI client and isolate skills.""" +def _mock_model(monkeypatch): + """Prevent tests from constructing a real OpenAI client and isolate shell env vars.""" monkeypatch.setattr("bot.agents._get_model", lambda: create_autospec(Model)) - # Point SKILLS_DIR at an empty directory so tests do not auto-load the - # real ./skills/ on disk. Tests that need skills can override this. - monkeypatch.setattr("bot.agents.SKILLS_DIR", tmp_path_factory.mktemp("empty_skills")) + monkeypatch.delenv("SHELL_ENABLED", raising=False) + monkeypatch.delenv("SHELL_SKILLS_DIR", raising=False) class TestGetModel: @@ -241,118 +240,131 @@ def test_no_truncation_when_under_limit(self): @pytest.mark.usefixtures("_stub_instructions") -class TestLoadShellSkills: - def _make_skill_dir(self, tmp_path): - """Create a valid skill directory and return its parent.""" - skill_dir = tmp_path / "my-skill" +class TestShellToolConfiguration: + def _get_shell_tools(self, agent: OpenAIAgent) -> list[ShellTool]: + return [t for t in agent.agent.tools if isinstance(t, ShellTool)] + + def _configure_env(self, monkeypatch, *, enabled: bool, skills_dir: Path | None = None) -> None: + if enabled: + monkeypatch.setenv("SHELL_ENABLED", "1") + else: + monkeypatch.delenv("SHELL_ENABLED", raising=False) + if skills_dir is not None: + monkeypatch.setenv("SHELL_SKILLS_DIR", str(skills_dir)) + else: + monkeypatch.delenv("SHELL_SKILLS_DIR", raising=False) + + def _make_skill(self, parent: Path, name: str, description: str = "desc") -> Path: + skill_dir = parent / name skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\n") - return tmp_path + (skill_dir / "SKILL.md").write_text(f"---\nname: {name}\ndescription: {description}\n---\n") + return skill_dir def test_disabled_by_default(self, tmp_path, monkeypatch): - """Skills exist but SHELL_SKILLS_ENABLED is not set — no ShellTool.""" - monkeypatch.delenv("SHELL_SKILLS_ENABLED", raising=False) - monkeypatch.setattr("bot.agents.SKILLS_DIR", self._make_skill_dir(tmp_path)) - agent = OpenAIAgent.from_dict("test", {"mcpServers": {}}) - shell_tools = [t for t in agent.agent.tools if isinstance(t, ShellTool)] - assert len(shell_tools) == 0 + """SHELL_ENABLED unset means no ShellTool, even if SHELL_SKILLS_DIR is set.""" + self._make_skill(tmp_path, "my-skill") + self._configure_env(monkeypatch, enabled=False, skills_dir=tmp_path) - def test_enabled_with_env_var(self, tmp_path, monkeypatch): - """SHELL_SKILLS_ENABLED=1 and skills exist — ShellTool is added.""" - monkeypatch.setenv("SHELL_SKILLS_ENABLED", "1") - monkeypatch.setattr("bot.agents.SKILLS_DIR", self._make_skill_dir(tmp_path)) agent = OpenAIAgent.from_dict("test", {"mcpServers": {}}) - shell_tools = [t for t in agent.agent.tools if isinstance(t, ShellTool)] - assert len(shell_tools) == 1 - def test_no_shell_tool_when_skills_dir_missing(self, tmp_path, monkeypatch): - monkeypatch.setenv("SHELL_SKILLS_ENABLED", "1") - monkeypatch.setattr("bot.agents.SKILLS_DIR", tmp_path / "nonexistent") + assert self._get_shell_tools(agent) == [] + + def test_orphaned_skills_dir_without_shell_enabled_warns(self, tmp_path, monkeypatch, caplog): + self._configure_env(monkeypatch, enabled=False, skills_dir=tmp_path) + + with caplog.at_level("WARNING", logger="root"): + agent = OpenAIAgent.from_dict("test", {"mcpServers": {}}) + + assert self._get_shell_tools(agent) == [] + assert any( + "SHELL_SKILLS_DIR" in record.message and "SHELL_ENABLED" in record.message for record in caplog.records + ) + + def test_shell_enabled_without_skills_dir_adds_bare_shell(self, tmp_path, monkeypatch): + self._configure_env(monkeypatch, enabled=True, skills_dir=None) + 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_skill_found(self, tmp_path, monkeypatch): - monkeypatch.setenv("SHELL_SKILLS_ENABLED", "1") - skill_dir = tmp_path / "my-skill" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("---\nname: my-skill\ndescription: A test skill\n---\n") - monkeypatch.setattr("bot.agents.SKILLS_DIR", tmp_path) + shell_tools = self._get_shell_tools(agent) + assert len(shell_tools) == 1 + assert shell_tools[0].environment == {"type": "local"} + + def test_shell_enabled_with_skills_dir_mounts_skills(self, tmp_path, monkeypatch): + skill_dir = self._make_skill(tmp_path, "my-skill", description="A test skill") + self._configure_env(monkeypatch, enabled=True, skills_dir=tmp_path) agent = OpenAIAgent.from_dict("test", {"mcpServers": {}}) - shell_tool = next(t for t in agent.agent.tools if isinstance(t, ShellTool)) + + shell_tool = self._get_shell_tools(agent)[0] skill = shell_tool.environment["skills"][0] assert skill["name"] == "my-skill" assert skill["description"] == "A test skill" assert skill["path"] == str(skill_dir) + def test_skills_dir_missing_falls_back_to_bare_shell_with_warning(self, tmp_path, monkeypatch, caplog): + self._configure_env(monkeypatch, enabled=True, skills_dir=tmp_path / "nonexistent") + + with caplog.at_level("WARNING", logger="root"): + agent = OpenAIAgent.from_dict("test", {"mcpServers": {}}) + + shell_tools = self._get_shell_tools(agent) + assert len(shell_tools) == 1 + assert shell_tools[0].environment == {"type": "local"} + assert any("yielded no skills" in record.message for record in caplog.records) + def test_multiple_skills_all_mounted(self, tmp_path, monkeypatch): - monkeypatch.setenv("SHELL_SKILLS_ENABLED", "1") - for name in ["skill-a", "skill-b"]: - d = tmp_path / name - d.mkdir() - (d / "SKILL.md").write_text(f"---\nname: {name}\ndescription: desc {name}\n---\n") - monkeypatch.setattr("bot.agents.SKILLS_DIR", tmp_path) + self._make_skill(tmp_path, "skill-a") + self._make_skill(tmp_path, "skill-b") + self._configure_env(monkeypatch, enabled=True, skills_dir=tmp_path) agent = OpenAIAgent.from_dict("test", {"mcpServers": {}}) - shell_tool = next(t for t in agent.agent.tools if isinstance(t, ShellTool)) + + shell_tool = self._get_shell_tools(agent)[0] assert len(shell_tool.environment["skills"]) == 2 def test_directory_without_skill_md_is_skipped(self, tmp_path, monkeypatch): - monkeypatch.setenv("SHELL_SKILLS_ENABLED", "1") (tmp_path / "not-a-skill").mkdir() - good = tmp_path / "real-skill" - good.mkdir() - (good / "SKILL.md").write_text("---\nname: real-skill\ndescription: d\n---\n") - monkeypatch.setattr("bot.agents.SKILLS_DIR", tmp_path) + self._make_skill(tmp_path, "real-skill") + self._configure_env(monkeypatch, enabled=True, skills_dir=tmp_path) agent = OpenAIAgent.from_dict("test", {"mcpServers": {}}) - shell_tool = next(t for t in agent.agent.tools if isinstance(t, ShellTool)) + + shell_tool = self._get_shell_tools(agent)[0] skills = shell_tool.environment["skills"] assert len(skills) == 1 assert skills[0]["name"] == "real-skill" def test_mcp_servers_and_shell_skills_coexist(self, tmp_path, monkeypatch): - monkeypatch.setenv("SHELL_SKILLS_ENABLED", "1") - skill_dir = tmp_path / "s" - skill_dir.mkdir() - (skill_dir / "SKILL.md").write_text("---\nname: s\ndescription: d\n---\n") - monkeypatch.setattr("bot.agents.SKILLS_DIR", tmp_path) + self._make_skill(tmp_path, "s") + self._configure_env(monkeypatch, enabled=True, skills_dir=tmp_path) config = {"mcpServers": {"my-mcp": {"command": "uvx", "args": ["something"]}}} 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 + assert len(self._get_shell_tools(agent)) == 1 def test_unreadable_utf8_skill_file_is_skipped(self, tmp_path, monkeypatch): - monkeypatch.setenv("SHELL_SKILLS_ENABLED", "1") bad = tmp_path / "bad-skill" bad.mkdir() (bad / "SKILL.md").write_bytes(b"\xff\xfe\x00\x00") - - good = tmp_path / "good-skill" - good.mkdir() - (good / "SKILL.md").write_text("---\nname: good-skill\ndescription: good\n---\n") - - monkeypatch.setattr("bot.agents.SKILLS_DIR", tmp_path) + self._make_skill(tmp_path, "good-skill", description="good") + self._configure_env(monkeypatch, enabled=True, skills_dir=tmp_path) agent = OpenAIAgent.from_dict("test", {"mcpServers": {}}) - shell_tool = next(t for t in agent.agent.tools if isinstance(t, ShellTool)) + + shell_tool = self._get_shell_tools(agent)[0] skills = shell_tool.environment["skills"] assert len(skills) == 1 assert skills[0]["name"] == "good-skill" def test_oserror_reading_skill_file_is_skipped(self, tmp_path, monkeypatch): - monkeypatch.setenv("SHELL_SKILLS_ENABLED", "1") bad = tmp_path / "bad-skill" bad.mkdir() bad_file = bad / "SKILL.md" bad_file.write_text("---\nname: bad\ndescription: bad\n---\n") - - good = tmp_path / "good-skill" - good.mkdir() - (good / "SKILL.md").write_text("---\nname: good-skill\ndescription: good\n---\n") + self._make_skill(tmp_path, "good-skill", description="good") + self._configure_env(monkeypatch, enabled=True, skills_dir=tmp_path) original_read_text = Path.read_text @@ -361,11 +373,11 @@ def _read_text(self: Path, *args, **kwargs): raise OSError("permission denied") return original_read_text(self, *args, **kwargs) - monkeypatch.setattr("bot.agents.SKILLS_DIR", tmp_path) monkeypatch.setattr(Path, "read_text", _read_text) agent = OpenAIAgent.from_dict("test", {"mcpServers": {}}) - shell_tool = next(t for t in agent.agent.tools if isinstance(t, ShellTool)) + + shell_tool = self._get_shell_tools(agent)[0] skills = shell_tool.environment["skills"] assert len(skills) == 1 assert skills[0]["name"] == "good-skill" diff --git a/tests/test_config.py b/tests/test_config.py index 08ec4ba..49b9281 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,6 +5,7 @@ import pytest from bot.config import Configuration +from bot.config import env_flag @pytest.fixture() @@ -58,3 +59,24 @@ def test_raises_on_invalid_json(self, tmp_path): config_file.write_text("not json") with pytest.raises(json.JSONDecodeError): Configuration.load_config(str(config_file)) + + +class TestEnvFlag: + def test_unset_is_false(self, monkeypatch): + monkeypatch.delenv("MY_FLAG", raising=False) + assert env_flag("MY_FLAG") is False + + @pytest.mark.parametrize("value", ["", "0", "false", "no", "off"]) + def test_common_falsy_values(self, monkeypatch, value): + monkeypatch.setenv("MY_FLAG", value) + assert env_flag("MY_FLAG") is False + + @pytest.mark.parametrize("value", ["FALSE", "No", "Off", " 0 "]) + def test_case_insensitive_and_whitespace(self, monkeypatch, value): + monkeypatch.setenv("MY_FLAG", value) + assert env_flag("MY_FLAG") is False + + @pytest.mark.parametrize("value", ["1", "true", "yes", "on", "enabled", "anything"]) + def test_truthy_values(self, monkeypatch, value): + monkeypatch.setenv("MY_FLAG", value) + assert env_flag("MY_FLAG") is True