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
12 changes: 6 additions & 6 deletions src/bub/agent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
HUMAN_PREVIEW_MAX_LEN = 240
HUMAN_PREVIEW_TRUNCATE_LEN = 237
VERIFICATION_TOOL_NAMES = {
"fs.read",
"fs.grep",
"fs.glob",
"tape.search",
"tape.info",
"tape.anchors",
"fs_read",
"fs_grep",
"fs_glob",
"tape_search",
"tape_info",
"tape_anchors",
"status",
}
BASH_VERIFICATION_TOKENS = (
Expand Down
27 changes: 22 additions & 5 deletions src/bub/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,28 @@ def run(
model: str | None = None,
max_tokens: int | None = None,
) -> None:
"""Run a single command with Bub."""
# Keep signature for CLI compatibility while run mode is intentionally disabled.
_ = (command, workspace, model, max_tokens)
renderer.error("bub run is not supported in async mode. Use bub chat.")
raise typer.Exit(1)
"""Run a single request-response turn with Bub."""
try:
workspace_path = workspace or Path.cwd()
runtime = _build_runtime(workspace_path, model, max_tokens)
_run_once(runtime, command)
except Exception as exc:
renderer.error(f"Failed to run command: {exc!s}")
raise typer.Exit(1) from exc


def _run_once(runtime: Runtime, command: str) -> None:
route = runtime.session.handle_input(command, origin="human")
if route.exit_requested or route.done_requested or not route.enter_agent:
return

response = runtime.session.agent_respond(
on_event=lambda event: runtime.tape.record_tool_event(event.kind, event.payload)
)
assistant_result = runtime.session.interpret_assistant(response)
if assistant_result.visible_text:
runtime.tape.record_assistant_message(assistant_result.visible_text)
renderer.info(assistant_result.visible_text)


if __name__ == "__main__":
Expand Down
6 changes: 3 additions & 3 deletions src/bub/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ class Settings(BaseSettings):
default=(
"You are Bub, a concise coding assistant.\n"
"Use tools when they help you answer or modify the project.\n"
"Available tools: fs.read, fs.write, fs.edit, fs.glob, fs.grep, bash, tape.search, tape.anchors, "
"tape.info, tape.reset, handoff, status, help, tools.\n"
"Use exact tool names as listed above; do not invent aliases.\n"
"Available tools: fs_read, fs_write, fs_edit, fs_glob, fs_grep, bash, tape_search, tape_anchors, "
"tape_info, tape_reset, handoff, status, help, tools.\n"
"Use exact tool names as listed above for tool calling.\n"
"Tool observations are returned as JSON with keys: tool, signature, category, status, repeat, "
"machine_readable, human_preview.\n"
"If a tool observation status is stagnant, stop repeating that call and provide a final answer.\n"
Expand Down
5 changes: 4 additions & 1 deletion src/bub/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tools package for Bub."""

from dataclasses import replace

from republic import Tool

from ..agent.context import Context
Expand All @@ -9,7 +11,8 @@
def build_agent_tools(context: Context, catalog: ToolCatalog | None = None) -> list[Tool]:
"""Build the tool set for the agent runtime."""
catalog = catalog or build_tool_catalog()
return catalog.build_tools(context, audience="agent")
tools = catalog.build_tools(context, audience="agent")
return [replace(tool, name=tool.name.replace(".", "_")) for tool in tools]


def build_cli_tools(context: Context, catalog: ToolCatalog | None = None) -> list[Tool]:
Expand Down
5 changes: 3 additions & 2 deletions src/bub/tools/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,11 @@ def render_help(self) -> str:
return "\n".join(lines).strip()

def render_tools(self) -> str:
specs = sorted(self.agent_specs(), key=lambda spec: spec.name)
specs = self.agent_specs()
if not specs:
return "(no tools)"
return "\n".join(spec.name for spec in specs)
tool_names = sorted(spec.name.replace(".", "_") for spec in specs)
return "\n".join(tool_names)

def render_bub_notice(self, args: list[str]) -> str:
if args and args[0] == "chat":
Expand Down
20 changes: 10 additions & 10 deletions tests/test_agent_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,13 @@ 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)
_FakeLLM.queued_responses = [
_tool_call_response(name="fs.read", arguments='{"path":"calc.py"}'),
_tool_call_response(name="fs_read", arguments='{"path":"calc.py"}'),
_text_response("done"),
]
_FakeLLM.queued_outputs = ["file-content"]
_FakeLLM.forced_text_when_no_tools = "forced final answer"

agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs.read")])
agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs_read")])
events: list[ToolEvent] = []

result = agent.respond([{"role": "user", "content": "check file"}], on_event=_capture_events(events))
Expand All @@ -98,28 +98,28 @@ def test_tool_result_payload_is_structured_json(tmp_path, monkeypatch) -> None:

payload_text = tool_result_events[0].payload["result"][0]["content"]
payload = json.loads(payload_text)
assert payload["tool"] == "fs.read"
assert payload["tool"] == "fs_read"
assert payload["category"] == "verification"
assert payload["status"] == "ok"
assert payload["repeat"] is False
machine = payload["machine_readable"]
assert machine["format"] == "text"
assert machine["value"] == "file-content"
assert payload["human_preview"] == "file-content"
assert payload["signature"] == 'fs.read:{"path":"calc.py"}'
assert payload["signature"] == 'fs_read:{"path":"calc.py"}'


def test_agent_recovers_when_observations_are_stagnant(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("BUB_MODEL", "openai:gpt-4o-mini")
monkeypatch.setattr("bub.agent.core.LLM", _FakeLLM)
_FakeLLM.queued_responses = [
_tool_call_response(name="fs.read", arguments='{"path":"calc.py"}'),
_tool_call_response(name="fs.read", arguments='{"path":"calc.py"}'),
_tool_call_response(name="fs_read", arguments='{"path":"calc.py"}'),
_tool_call_response(name="fs_read", arguments='{"path":"calc.py"}'),
]
_FakeLLM.queued_outputs = ["same-output", "same-output"]
_FakeLLM.forced_text_when_no_tools = "final answer without more tools"

agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs.read")])
agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs_read")])
events: list[ToolEvent] = []

result = agent.respond([{"role": "user", "content": "fix and verify"}], on_event=_capture_events(events))
Expand All @@ -141,7 +141,7 @@ def test_agent_does_not_hard_block_completion_without_verification_evidence(tmp_
_FakeLLM.queued_outputs = []
_FakeLLM.forced_text_when_no_tools = "forced final answer"

agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs.read")])
agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs_read")])

result = agent.respond([{"role": "user", "content": "请验证完成结果后再回复完成"}])

Expand All @@ -152,13 +152,13 @@ def test_agent_allows_completion_with_verification_evidence(tmp_path, monkeypatc
monkeypatch.setenv("BUB_MODEL", "openai:gpt-4o-mini")
monkeypatch.setattr("bub.agent.core.LLM", _FakeLLM)
_FakeLLM.queued_responses = [
_tool_call_response(name="fs.read", arguments='{"path":"out.txt"}'),
_tool_call_response(name="fs_read", arguments='{"path":"out.txt"}'),
_text_response("verified completion"),
]
_FakeLLM.queued_outputs = ["ok-content"]
_FakeLLM.forced_text_when_no_tools = "forced final answer"

agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs.read")])
agent = Agent(context=Context(tmp_path), tools=[SimpleNamespace(name="fs_read")])

result = agent.respond([{"role": "user", "content": "please verify completion and then conclude"}])

Expand Down
34 changes: 17 additions & 17 deletions tests/test_bub.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,45 +38,45 @@ def test_default_tool_names(self, tmp_path, monkeypatch):
tool_map = self._tool_map(tmp_path, monkeypatch)
assert set(tool_map.keys()) == {
"bash",
"fs.edit",
"fs.glob",
"fs.grep",
"fs.read",
"fs.write",
"fs_edit",
"fs_glob",
"fs_grep",
"fs_read",
"fs_write",
"handoff",
"help",
"status",
"tape.anchors",
"tape.info",
"tape.reset",
"tape.search",
"tape_anchors",
"tape_info",
"tape_reset",
"tape_search",
"tools",
}

def test_write_and_read(self, tmp_path, monkeypatch):
"""Test write then read tool."""
tool_map = self._tool_map(tmp_path, monkeypatch)
result = tool_map["fs.write"].run(path="test.txt", content="line1\nline2\nline3\n")
result = tool_map["fs_write"].run(path="test.txt", content="line1\nline2\nline3\n")
assert result == "ok"

read_result = tool_map["fs.read"].run(path="test.txt", offset=1, limit=1)
read_result = tool_map["fs_read"].run(path="test.txt", offset=1, limit=1)
assert "2| line2" in read_result

def test_edit_tool(self, tmp_path, monkeypatch):
"""Test edit tool replacement."""
tool_map = self._tool_map(tmp_path, monkeypatch)
tool_map["fs.write"].run(path="edit.txt", content="hello world")
tool_map["fs_write"].run(path="edit.txt", content="hello world")

result = tool_map["fs.edit"].run(path="edit.txt", old="world", new="bub")
result = tool_map["fs_edit"].run(path="edit.txt", old="world", new="bub")
assert result == "ok"
assert (tmp_path / "edit.txt").read_text() == "hello bub"

def test_edit_requires_unique(self, tmp_path, monkeypatch):
"""Test edit tool requires unique match unless all=true."""
tool_map = self._tool_map(tmp_path, monkeypatch)
tool_map["fs.write"].run(path="dup.txt", content="a a a")
tool_map["fs_write"].run(path="dup.txt", content="a a a")

result = tool_map["fs.edit"].run(path="dup.txt", old="a", new="b")
result = tool_map["fs_edit"].run(path="dup.txt", old="a", new="b")
assert result.startswith("error: old_string appears")

def test_glob_tool(self, tmp_path, monkeypatch):
Expand All @@ -85,15 +85,15 @@ def test_glob_tool(self, tmp_path, monkeypatch):
(tmp_path / "a.txt").write_text("one")
(tmp_path / "b.md").write_text("two")

result = tool_map["fs.glob"].run(path=".", pattern="*.txt")
result = tool_map["fs_glob"].run(path=".", pattern="*.txt")
assert "a.txt" in result

def test_grep_tool(self, tmp_path, monkeypatch):
"""Test grep tool."""
tool_map = self._tool_map(tmp_path, monkeypatch)
(tmp_path / "hello.txt").write_text("hello\nworld\n")

result = tool_map["fs.grep"].run(pattern="hello", path=".")
result = tool_map["fs_grep"].run(pattern="hello", path=".")
assert "hello.txt:1:hello" in result

def test_bash_tool(self, tmp_path, monkeypatch):
Expand Down
Loading
Loading