diff --git a/src/bub/agent/core.py b/src/bub/agent/core.py index fc5bb55f..b1c648ae 100644 --- a/src/bub/agent/core.py +++ b/src/bub/agent/core.py @@ -10,6 +10,7 @@ from republic import LLM, Tool from ..errors import ModelNotConfiguredError +from ..skills import build_skills_prompt_section from .context import Context MODEL_NOT_CONFIGURED_ERROR = "Model not configured. Set BUB_MODEL (e.g., 'openai:gpt-4o-mini')." @@ -105,11 +106,20 @@ def respond( on_event: Callable[[ToolEvent], None] | None = None, ) -> str: """Run a tool-aware response and return assistant text.""" - if self._system_prompt: - messages = [{"role": "system", "content": self._system_prompt}, *messages] - + inserted_system = False observation_fingerprints: dict[str, str] = {} while True: + system_prompt = self._build_system_prompt() + if system_prompt: + if inserted_system: + messages[0] = {"role": "system", "content": system_prompt} + else: + messages = [{"role": "system", "content": system_prompt}, *messages] + inserted_system = True + elif inserted_system: + messages = messages[1:] + inserted_system = False + response = self._llm.chat.raw( messages=messages, tools=self._tools, @@ -134,6 +144,13 @@ def respond( return text or "" + def _build_system_prompt(self) -> str: + base_prompt = self._system_prompt.strip() + skills_section = build_skills_prompt_section(self._context.workspace_path) + if not skills_section: + return base_prompt + return f"{base_prompt}\n\n{skills_section}" + def _execute_tools( self, tool_calls: list[dict[str, Any]], diff --git a/src/bub/config.py b/src/bub/config.py index c465ff5d..fb427e97 100644 --- a/src/bub/config.py +++ b/src/bub/config.py @@ -5,8 +5,6 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict -from .skills import build_skills_prompt_section - class Settings(BaseSettings): """Bub application settings.""" @@ -87,16 +85,4 @@ def get_settings(workspace_path: Path | None = None) -> Settings: if agents_md_content: settings.system_prompt = agents_md_content.strip() - skills_section = build_skills_prompt_section(workspace_path) - if skills_section: - settings.system_prompt = _append_prompt_section(settings.system_prompt, skills_section) - return settings - - -def _append_prompt_section(base: str | None, section: str) -> str: - base_text = (base or "").strip() - section_text = section.strip() - if not base_text: - return section_text - return f"{base_text}\n\n{section_text}" diff --git a/src/bub/skills/skill-creator/scripts/init_skill.py b/src/bub/skills/skill-creator/scripts/init_skill.py index f90703ec..f722b98d 100644 --- a/src/bub/skills/skill-creator/scripts/init_skill.py +++ b/src/bub/skills/skill-creator/scripts/init_skill.py @@ -18,7 +18,6 @@ import sys from pathlib import Path -from generate_openai_yaml import write_openai_yaml MAX_SKILL_NAME_LENGTH = 64 ALLOWED_RESOURCES = {"scripts", "references", "assets"} @@ -296,15 +295,6 @@ def init_skill(skill_name, path, resources, include_examples, interface_override print(f"[ERROR] Error creating SKILL.md: {e}") return None - # Create agents/openai.yaml - try: - result = write_openai_yaml(skill_dir, skill_name, interface_overrides) - if not result: - return None - except Exception as e: - print(f"[ERROR] Error creating agents/openai.yaml: {e}") - return None - # Create resource directories if requested if resources: try: diff --git a/tests/test_agent_contract.py b/tests/test_agent_contract.py index 376d821d..850ded9e 100644 --- a/tests/test_agent_contract.py +++ b/tests/test_agent_contract.py @@ -4,6 +4,7 @@ import json from dataclasses import dataclass +from pathlib import Path from types import SimpleNamespace from typing import Any, ClassVar @@ -77,6 +78,15 @@ def _handler(event: ToolEvent) -> None: return _handler +def _write_skill(root: Path, folder: str, *, name: str, description: str) -> None: + skill_dir = root / folder + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + (f"---\nname: {name}\ndescription: {description}\n---\n\nSkill instructions.\n"), + encoding="utf-8", + ) + + def test_tool_result_payload_is_structured_json(tmp_path, monkeypatch) -> None: monkeypatch.setenv("BUB_MODEL", "openai:gpt-4o-mini") monkeypatch.setattr("bub.agent.core.LLM", _FakeLLM) @@ -163,3 +173,96 @@ def test_agent_allows_completion_with_verification_evidence(tmp_path, monkeypatc result = agent.respond([{"role": "user", "content": "please verify completion and then conclude"}]) assert result == "verified completion" + + +def test_agent_refreshes_skills_per_respond(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("BUB_MODEL", "openai:gpt-4o-mini") + monkeypatch.setenv("HOME", str(tmp_path / "home")) + + captured_system_prompts: list[str] = [] + + class _CaptureChat: + def raw(self, *, messages: list[dict[str, Any]], tools: list[Any], max_tokens: int | None = None) -> object: + _ = (tools, max_tokens) + content = messages[0].get("content", "") + captured_system_prompts.append(str(content)) + return _text_response("done") + + class _CaptureLLM: + def __init__(self, model: str, api_key: str | None = None, api_base: str | None = None) -> None: + _ = (api_key, api_base) + provider, model_name = model.split(":", 1) + self.provider = provider + self.model = model_name + self.chat = _CaptureChat() + self.tools = _FakeTools(outputs=["ok"]) + + monkeypatch.setattr("bub.agent.core.LLM", _CaptureLLM) + + project_skills = tmp_path / ".agent" / "skills" + _write_skill(project_skills, "alpha", name="alpha-skill", description="alpha") + + agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs_read")]) + first = agent.respond([{"role": "user", "content": "first"}]) + assert first == "done" + + _write_skill(project_skills, "beta", name="beta-skill", description="beta") + second = agent.respond([{"role": "user", "content": "second"}]) + assert second == "done" + + assert len(captured_system_prompts) == 2 + assert "alpha-skill" in captured_system_prompts[0] + assert "beta-skill" not in captured_system_prompts[0] + assert "beta-skill" in captured_system_prompts[1] + + +def test_agent_refreshes_skills_within_same_respond_loop(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("BUB_MODEL", "openai:gpt-4o-mini") + monkeypatch.setenv("HOME", str(tmp_path / "home")) + project_skills = tmp_path / ".agent" / "skills" + _write_skill(project_skills, "alpha", name="alpha-skill", description="alpha") + + captured_system_prompts: list[str] = [] + + class _LoopChat: + def __init__(self) -> None: + self.calls = 0 + + def raw(self, *, messages: list[dict[str, Any]], tools: list[Any], max_tokens: int | None = None) -> object: + _ = max_tokens + content = messages[0].get("content", "") + captured_system_prompts.append(str(content)) + self.calls += 1 + if tools and self.calls == 1: + return _tool_call_response(name="fs_read", arguments='{"path":"foo.txt"}') + return _text_response("done") + + class _LoopTools: + def __init__(self) -> None: + self.calls = 0 + + def execute(self, call: dict[str, Any], *, tools: list[Any] | None = None) -> str: + _ = (call, tools) + if self.calls == 0: + _write_skill(project_skills, "beta", name="beta-skill", description="beta") + self.calls += 1 + return "ok" + + class _LoopLLM: + def __init__(self, model: str, api_key: str | None = None, api_base: str | None = None) -> None: + _ = (api_key, api_base) + provider, model_name = model.split(":", 1) + self.provider = provider + self.model = model_name + self.chat = _LoopChat() + self.tools = _LoopTools() + + monkeypatch.setattr("bub.agent.core.LLM", _LoopLLM) + + agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs_read")]) + result = agent.respond([{"role": "user", "content": "check"}]) + + assert result == "done" + assert len(captured_system_prompts) >= 2 + assert "beta-skill" not in captured_system_prompts[0] + assert "beta-skill" in captured_system_prompts[1] diff --git a/tests/test_bub.py b/tests/test_bub.py index 95ee8dee..4814feab 100644 --- a/tests/test_bub.py +++ b/tests/test_bub.py @@ -22,12 +22,10 @@ def test_settings_with_agents_md(self, tmp_path, monkeypatch): agents_md = tmp_path / "AGENTS.md" agents_md.write_text("System prompt from AGENTS.md") settings = get_settings(tmp_path) - assert settings.system_prompt is not None - assert settings.system_prompt.startswith("System prompt from AGENTS.md") - assert "" in settings.system_prompt + assert settings.system_prompt == "System prompt from AGENTS.md" def test_settings_with_skills_section(self, tmp_path, monkeypatch): - """Test settings include available skills metadata.""" + """Test settings do not inline available skills metadata.""" monkeypatch.setenv("HOME", str(tmp_path / "home")) skill_dir = tmp_path / ".agent" / "skills" / "code_review" skill_dir.mkdir(parents=True) @@ -38,9 +36,7 @@ def test_settings_with_skills_section(self, tmp_path, monkeypatch): settings = get_settings(tmp_path) assert settings.system_prompt is not None - assert "" in settings.system_prompt - assert "code-review" in settings.system_prompt - assert "code_review/SKILL.md" in settings.system_prompt + assert "" not in settings.system_prompt class TestTools: