Skip to content
Merged
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
23 changes: 20 additions & 3 deletions src/bub/agent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')."
Expand Down Expand Up @@ -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,
Expand All @@ -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]],
Expand Down
14 changes: 0 additions & 14 deletions src/bub/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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}"
10 changes: 0 additions & 10 deletions src/bub/skills/skill-creator/scripts/init_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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:
Expand Down
103 changes: 103 additions & 0 deletions tests/test_agent_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Any, ClassVar

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 "<name>alpha-skill</name>" in captured_system_prompts[0]
assert "<name>beta-skill</name>" not in captured_system_prompts[0]
assert "<name>beta-skill</name>" 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 "<name>beta-skill</name>" not in captured_system_prompts[0]
assert "<name>beta-skill</name>" in captured_system_prompts[1]
10 changes: 3 additions & 7 deletions tests/test_bub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<available_skills>" 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)
Expand All @@ -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 "<available_skills>" in settings.system_prompt
assert "<name>code-review</name>" in settings.system_prompt
assert "code_review/SKILL.md" in settings.system_prompt
assert "<available_skills>" not in settings.system_prompt


class TestTools:
Expand Down
Loading