Skip to content

Commit 7a27354

Browse files
authored
Merge pull request #56 from nforro/tools
Tools implementation #2
2 parents 780d8d7 + 20b3509 commit 7a27354

21 files changed

+588
-109
lines changed

beeai/Containerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ RUN dnf -y install \
88
python3 \
99
python3-pip \
1010
python3-redis \
11+
python3-specfile \
1112
rpmbuild \
1213
rpmdevtools \
1314
rpmlint \

beeai/Containerfile.mcp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ RUN dnf -y install \
55
python3 \
66
python3-pip \
77
python3-ogr \
8+
python3-requests \
9+
krb5-libs \
10+
krb5-workstation \
11+
centpkg \
812
git \
913
&& dnf clean all
1014

@@ -15,9 +19,9 @@ RUN pip3 install --no-cache-dir fastmcp
1519
RUN useradd -m -G wheel mcp
1620

1721
# Create directories
18-
# `/home/mcp/mcp_servers/` should be directly copied for Openshift deployment
22+
# `/home/mcp/mcp_server/` should be directly copied for Openshift deployment
1923
# although it is locally mounted through a volume
20-
COPY mcp_servers/ /home/mcp/mcp_servers/
24+
COPY mcp_server/ /home/mcp/mcp_server/
2125
RUN chgrp -R root /home/mcp && chmod -R g+rwX /home/mcp
2226

2327
USER mcp

beeai/Containerfile.tests

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ RUN dnf -y install \
55
make \
66
python3 \
77
python3-ogr \
8+
python3-specfile \
89
python3-pip \
910
python3-pytest \
1011
python3-pytest-asyncio \

beeai/Makefile.tests

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ build-test-image:
1111
check:
1212
cd ./agents && \
1313
PYTHONPATH=$(CURDIR) PYTHONDONTWRITEBYTECODE=1 python3 -m pytest --verbose --showlocals $(TEST_TARGET)
14-
cd ./mcp_servers && \
14+
cd ./mcp_server && \
1515
PYTHONPATH=$(CURDIR) PYTHONDONTWRITEBYTECODE=1 python3 -m pytest --verbose --showlocals $(TEST_TARGET)
1616

1717
.PHONY: check-in-container

beeai/agents/backport_agent.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from base_agent import BaseAgent, TInputSchema, TOutputSchema
2323
from constants import COMMIT_PREFIX, BRANCH_PREFIX
2424
from observability import setup_observability
25-
from tools.shell_command import ShellCommandTool
25+
from tools.commands import RunShellCommandTool
2626
from triage_agent import BackportData, ErrorData
2727
from utils import mcp_tools, redis_client, get_git_finalization_steps
2828

@@ -63,7 +63,7 @@ class BackportAgent(BaseAgent):
6363
def __init__(self) -> None:
6464
super().__init__(
6565
llm=ChatModel.from_name(os.getenv("CHAT_MODEL")),
66-
tools=[ThinkTool(), ShellCommandTool(), DuckDuckGoSearchTool()],
66+
tools=[ThinkTool(), RunShellCommandTool(), DuckDuckGoSearchTool()],
6767
memory=UnconstrainedMemory(),
6868
requirements=[
6969
ConditionalRequirement(ThinkTool, force_after=Tool, consecutive_allowed=False),
@@ -145,16 +145,20 @@ def prompt(self) -> str:
145145
"""
146146

147147
async def run_with_schema(self, input: TInputSchema) -> TOutputSchema:
148-
async with mcp_tools(os.getenv("MCP_GITLAB_URL")) as gitlab_tools:
148+
async with mcp_tools(
149+
os.getenv("MCP_GATEWAY_URL"),
150+
filter=lambda t: t
151+
in ("fork_repository", "open_merge_request", "push_to_remote_repository"),
152+
) as gateway_tools:
149153
tools = self._tools.copy()
150154
try:
151-
self._tools.extend(gitlab_tools)
155+
self._tools.extend(gateway_tools)
152156
return await self._run_with_schema(input)
153157
finally:
154158
self._tools = tools
155159
# disassociate removed tools from requirements
156160
for requirement in self._requirements:
157-
if requirement._source_tool in gitlab_tools:
161+
if requirement._source_tool in gateway_tools:
158162
requirement._source_tool = None
159163

160164

beeai/agents/rebase_agent.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from base_agent import BaseAgent, TInputSchema, TOutputSchema
2323
from constants import COMMIT_PREFIX, BRANCH_PREFIX
2424
from observability import setup_observability
25-
from tools.shell_command import ShellCommandTool
25+
from tools.commands import RunShellCommandTool
2626
from triage_agent import RebaseData, ErrorData
2727
from utils import mcp_tools, redis_client, get_git_finalization_steps
2828

@@ -62,7 +62,7 @@ class RebaseAgent(BaseAgent):
6262
def __init__(self) -> None:
6363
super().__init__(
6464
llm=ChatModel.from_name(os.getenv("CHAT_MODEL")),
65-
tools=[ThinkTool(), ShellCommandTool(), DuckDuckGoSearchTool()],
65+
tools=[ThinkTool(), RunShellCommandTool(), DuckDuckGoSearchTool()],
6666
memory=UnconstrainedMemory(),
6767
requirements=[
6868
ConditionalRequirement(ThinkTool, force_after=Tool, consecutive_allowed=False),
@@ -122,9 +122,9 @@ def prompt(self) -> str:
122122
* Do not run the `centpkg new-sources` command for now (testing purposes), just write down the commands you would run.
123123
124124
IMPORTANT GUIDELINES:
125-
- **Tool Usage**: You have ShellCommand tool available - use it directly!
125+
- **Tool Usage**: You have run_shell_command tool available - use it directly!
126126
- **Command Execution Rules**:
127-
- Use ShellCommand tool for ALL command execution
127+
- Use run_shell_command tool for ALL command execution
128128
- If a command shows "no output" or empty STDOUT, that is a VALID result - do not retry
129129
- Commands that succeed with no output are normal - report success
130130
- **Git Configuration**: Always configure git user name and email before any git operations
@@ -174,16 +174,20 @@ def prompt(self) -> str:
174174
"""
175175

176176
async def run_with_schema(self, input: TInputSchema) -> TOutputSchema:
177-
async with mcp_tools(os.getenv("MCP_GITLAB_URL")) as gitlab_tools:
177+
async with mcp_tools(
178+
os.getenv("MCP_GATEWAY_URL"),
179+
filter=lambda t: t
180+
in ("fork_repository", "open_merge_request", "push_to_remote_repository"),
181+
) as gateway_tools:
178182
tools = self._tools.copy()
179183
try:
180-
self._tools.extend(gitlab_tools)
184+
self._tools.extend(gateway_tools)
181185
return await self._run_with_schema(input)
182186
finally:
183187
self._tools = tools
184188
# disassociate removed tools from requirements
185189
for requirement in self._requirements:
186-
if requirement._source_tool in gitlab_tools:
190+
if requirement._source_tool in gateway_tools:
187191
requirement._source_tool = None
188192

189193

beeai/agents/tests/unit/test_tools.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1+
import datetime
2+
from textwrap import dedent
3+
14
import pytest
5+
from flexmock import flexmock
6+
from specfile import specfile
27

38
from beeai_framework.middleware.trajectory import GlobalTrajectoryMiddleware
49

5-
from tools.shell_command import ShellCommandTool, ShellCommandToolInput
10+
from tools.commands import RunShellCommandTool, RunShellCommandToolInput
11+
from tools.specfile import (
12+
AddChangelogEntryTool,
13+
AddChangelogEntryToolInput,
14+
BumpReleaseTool,
15+
BumpReleaseToolInput,
16+
)
617

718

819
@pytest.mark.parametrize(
@@ -29,12 +40,74 @@
2940
],
3041
)
3142
@pytest.mark.asyncio
32-
async def test_shell_command(command, exit_code, stdout, stderr):
33-
tool = ShellCommandTool()
34-
output = await tool.run(input=ShellCommandToolInput(command=command)).middleware(
43+
async def test_run_shell_command(command, exit_code, stdout, stderr):
44+
tool = RunShellCommandTool()
45+
output = await tool.run(input=RunShellCommandToolInput(command=command)).middleware(
3546
GlobalTrajectoryMiddleware(pretty=True)
3647
)
3748
result = output.to_json_safe()
3849
assert result.exit_code == exit_code
3950
assert result.stdout == stdout
4051
assert result.stderr == stderr
52+
53+
54+
@pytest.fixture
55+
def minimal_spec(tmp_path):
56+
spec = tmp_path / "test.spec"
57+
spec.write_text(
58+
dedent(
59+
"""
60+
Name: test
61+
Version: 0.1
62+
Release: 2%{?dist}
63+
Summary: Test package
64+
65+
License: MIT
66+
67+
%description
68+
Test package
69+
70+
%changelog
71+
* Thu Jun 07 2018 Nikola Forró <[email protected]> - 0.1-1
72+
- first version
73+
"""
74+
)
75+
)
76+
return spec
77+
78+
79+
@pytest.mark.asyncio
80+
async def test_add_changelog_entry(minimal_spec):
81+
content = ["- some change", " second line"]
82+
author = "rhel-packaging-agent"
83+
84+
flexmock(specfile).should_receive("datetime").and_return(
85+
flexmock(
86+
datetime=flexmock(now=lambda _: flexmock(date=lambda: datetime.date(2025, 8, 5))),
87+
timezone=flexmock(utc=None),
88+
)
89+
)
90+
tool = AddChangelogEntryTool()
91+
output = await tool.run(
92+
input=AddChangelogEntryToolInput(spec=minimal_spec, content=content, author=author, email=email)
93+
).middleware(GlobalTrajectoryMiddleware(pretty=True))
94+
result = output.result
95+
assert not result
96+
assert minimal_spec.read_text().splitlines()[-7:-2] == [
97+
"%changelog",
98+
"* Tue Aug 05 2025 rhel-packaging-agent <[email protected]> - 0.1-2",
99+
"- some change",
100+
" second line",
101+
"",
102+
]
103+
104+
105+
@pytest.mark.asyncio
106+
async def test_bump_release(minimal_spec):
107+
tool = BumpReleaseTool()
108+
output = await tool.run(input=BumpReleaseToolInput(spec=minimal_spec)).middleware(
109+
GlobalTrajectoryMiddleware(pretty=True)
110+
)
111+
result = output.result
112+
assert not result
113+
assert minimal_spec.read_text().splitlines()[3] == "Release: 3%{?dist}"

beeai/agents/tools/shell_command.py renamed to beeai/agents/tools/commands.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,37 @@
88
from beeai_framework.tools import JSONToolOutput, Tool, ToolRunOptions
99

1010

11-
class ShellCommandToolInput(BaseModel):
12-
command: str = Field(description="Command to run.")
11+
class RunShellCommandToolInput(BaseModel):
12+
command: str = Field(description="Command to run")
1313

1414

15-
class ShellCommandToolResult(BaseModel):
15+
class RunShellCommandToolResult(BaseModel):
1616
exit_code: int
1717
stdout: str | None
1818
stderr: str | None
1919

2020

21-
class ShellCommandToolOutput(JSONToolOutput[ShellCommandToolResult]):
21+
class RunShellCommandToolOutput(JSONToolOutput[RunShellCommandToolResult]):
2222
pass
2323

2424

25-
class ShellCommandTool(Tool[ShellCommandToolInput, ToolRunOptions, ShellCommandToolOutput]):
26-
name = "ShellCommand"
27-
description = """Runs commands in a shell."""
28-
input_schema = ShellCommandToolInput
29-
30-
def __init__(self, options: dict[str, Any] | None = None) -> None:
31-
super().__init__(options)
25+
class RunShellCommandTool(Tool[RunShellCommandToolInput, ToolRunOptions, RunShellCommandToolOutput]):
26+
name = "run_shell_command"
27+
description = """
28+
Runs the specified command in a shell. Returns a dictionary with exit code
29+
and captured stdout and stderr.
30+
"""
31+
input_schema = RunShellCommandToolInput
3232

3333
def _create_emitter(self) -> Emitter:
3434
return Emitter.root().child(
35-
namespace=["tool", "shell", "command"],
35+
namespace=["tool", "commands", self.name],
3636
creator=self,
3737
)
3838

3939
async def _run(
40-
self, tool_input: ShellCommandToolInput, options: ToolRunOptions | None, context: RunContext
41-
) -> ShellCommandToolOutput:
40+
self, tool_input: RunShellCommandToolInput, options: ToolRunOptions | None, context: RunContext
41+
) -> RunShellCommandToolOutput:
4242
proc = await asyncio.create_subprocess_shell(
4343
tool_input.command,
4444
stdout=asyncio.subprocess.PIPE,
@@ -53,4 +53,4 @@ async def _run(
5353
"stderr": stderr.decode() if stderr else None,
5454
}
5555

56-
return ShellCommandToolOutput(ShellCommandToolResult.model_validate(result))
56+
return RunShellCommandToolOutput(RunShellCommandToolResult.model_validate(result))

beeai/agents/tools/specfile.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import asyncio
2+
from pathlib import Path
3+
from typing import Any
4+
5+
from pydantic import BaseModel, Field
6+
from specfile import Specfile
7+
8+
from beeai_framework.context import RunContext
9+
from beeai_framework.emitter import Emitter
10+
from beeai_framework.tools import StringToolOutput, Tool, ToolRunOptions
11+
12+
13+
# version update & release reset ?
14+
# patch addition ?
15+
16+
17+
class AddChangelogEntryToolInput(BaseModel):
18+
spec: Path = Field(description="Absolute path to a spec file")
19+
content: list[str] = Field(
20+
description="""
21+
Content of the entry as a list of lines, maximum line length should be 80 characters,
22+
every paragraph should start with "- "
23+
"""
24+
)
25+
author: str = Field(description="Author of the entry (change)")
26+
email: str = Field(description="E-mail address of the author")
27+
28+
29+
class AddChangelogEntryTool(Tool[AddChangelogEntryToolInput, ToolRunOptions, StringToolOutput]):
30+
name = "add_changelog_entry"
31+
description = """
32+
Adds a new changelog entry to the specified spec file. Returns error message on failure.
33+
"""
34+
input_schema = AddChangelogEntryToolInput
35+
36+
def _create_emitter(self) -> Emitter:
37+
return Emitter.root().child(
38+
namespace=["tool", "specfile", self.name],
39+
creator=self,
40+
)
41+
42+
async def _run(
43+
self, tool_input: AddChangelogEntryToolInput, options: ToolRunOptions | None, context: RunContext
44+
) -> StringToolOutput:
45+
try:
46+
with Specfile(tool_input.spec) as spec:
47+
spec.add_changelog_entry(tool_input.content, author=tool_input.author, email=tool_input.email)
48+
except Exception as e:
49+
return StringToolOutput(result=f"Failed to add changelog entry: {e}")
50+
return StringToolOutput()
51+
52+
53+
class BumpReleaseToolInput(BaseModel):
54+
spec: Path = Field(description="Absolute path to a spec file")
55+
56+
57+
class BumpReleaseTool(Tool[BumpReleaseToolInput, ToolRunOptions, StringToolOutput]):
58+
name = "bump_release"
59+
description = """
60+
Bumps (increments) the value of `Release` in the specified spec file.
61+
Returns error message on failure.
62+
"""
63+
input_schema = BumpReleaseToolInput
64+
65+
def _create_emitter(self) -> Emitter:
66+
return Emitter.root().child(
67+
namespace=["tool", "specfile", self.name],
68+
creator=self,
69+
)
70+
71+
async def _run(
72+
self, tool_input: BumpReleaseToolInput, options: ToolRunOptions | None, context: RunContext
73+
) -> StringToolOutput:
74+
try:
75+
with Specfile(tool_input.spec) as spec:
76+
spec.bump_release()
77+
except Exception as e:
78+
return StringToolOutput(result=f"Failed to bump release: {e}")
79+
return StringToolOutput()

0 commit comments

Comments
 (0)