plain-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain_agent-0.1.0.dist-info/METADATA +64 -0
- plain_agent-0.1.0.dist-info/RECORD +26 -0
- plain_agent-0.1.0.dist-info/WHEEL +5 -0
- plain_agent-0.1.0.dist-info/entry_points.txt +2 -0
- plain_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- plain_agent-0.1.0.dist-info/top_level.txt +1 -0
- simple_agent/agent_loop.py +112 -0
- simple_agent/cli.py +43 -0
- simple_agent/conversation_history.py +59 -0
- simple_agent/llm_client.py +49 -0
- simple_agent/message_types.py +39 -0
- simple_agent/prompt.py +5 -0
- simple_agent/streaming.py +96 -0
- simple_agent/terminal_loop.py +49 -0
- simple_agent/tools/base_tool.py +29 -0
- simple_agent/tools/command_policy.py +20 -0
- simple_agent/tools/command_runtime.py +155 -0
- simple_agent/tools/edit_file.py +75 -0
- simple_agent/tools/list_files.py +45 -0
- simple_agent/tools/permissions/file_permission.py +94 -0
- simple_agent/tools/read_file.py +60 -0
- simple_agent/tools/run_command.py +54 -0
- simple_agent/tools/search_text.py +66 -0
- simple_agent/tools/tools.py +46 -0
- simple_agent/tools/utils.py +11 -0
- simple_agent/tools/write_file.py +55 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: plain-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small agent that calls OpenAI-compatible APIs.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: openai==2.43.0
|
|
10
|
+
Requires-Dist: python-dotenv>=1.2.2
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Plain Agent
|
|
14
|
+
|
|
15
|
+
A small agent that calls OpenAI compatible LLM APIs.
|
|
16
|
+
|
|
17
|
+
This project starts with a streaming agent loop:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
Interactive terminal
|
|
21
|
+
-> reads your prompt
|
|
22
|
+
-> streams assistant text as it arrives
|
|
23
|
+
-> detects tool calls from the model
|
|
24
|
+
-> runs workspace tools when requested
|
|
25
|
+
-> sends tool results back to the model
|
|
26
|
+
-> repeats until the assistant gives a final answer
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
This project uses `uv` to track the Python environment.
|
|
32
|
+
If `uv` is not installed, follow the [official installation guide](https://docs.astral.sh/uv/getting-started/installation/).
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv sync
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
Create a local `.env` file or export environment variables in your shell. See `.env.example` for more examples.
|
|
41
|
+
|
|
42
|
+
For DeepSeek:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export DEEPSEEK_API_KEY="your-api-key"
|
|
46
|
+
export LLM_PROVIDER="deepseek"
|
|
47
|
+
export LLM_MODEL="deepseek-v4-flash"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For OpenAI:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
export OPENAI_API_KEY="your-api-key"
|
|
54
|
+
export LLM_PROVIDER="openai"
|
|
55
|
+
export LLM_MODEL="gpt-5.4-mini"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
You can still set `LLM_BASE_URL` when you want to override the provider default, such as pointing at a local OpenAI compatible server like Ollama.
|
|
59
|
+
|
|
60
|
+
## Run
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv run plain-agent
|
|
64
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
plain_agent-0.1.0.dist-info/licenses/LICENSE,sha256=6zgW5PChFyhFoChqZ6HZlkuGJExiIlcmbwzm-cXaBY8,1064
|
|
2
|
+
simple_agent/agent_loop.py,sha256=wD18vnHGrDxL7l0ydIpjI2K-Rb4TP14wyohd2XTZu9U,4201
|
|
3
|
+
simple_agent/cli.py,sha256=1OVKa4SXwMv2DCLy4Wj2bmIj9E28JhOepXrp-qg08AU,1324
|
|
4
|
+
simple_agent/conversation_history.py,sha256=CoaXkWk8ckF8BexNjsC-HaYW3bBY9m4Tp3BEplZ6osM,1805
|
|
5
|
+
simple_agent/llm_client.py,sha256=cIs6Q502ezKXgGcpgE9ymszRW6SIONTOLLDa0xcmLsQ,1162
|
|
6
|
+
simple_agent/message_types.py,sha256=G-jqN_UKhaMkDpcdYykbvFGf-dclLf84Qjs4u9kW5-E,764
|
|
7
|
+
simple_agent/prompt.py,sha256=NePuYJtdrjXUT5n3KgfaMUMf8NpX0FxEkqFaViNhE4o,207
|
|
8
|
+
simple_agent/streaming.py,sha256=w_PMCdoQyCi2dL2uQBMArdo94d9PQ_dvWR9BLkafrdc,2783
|
|
9
|
+
simple_agent/terminal_loop.py,sha256=3fe6ux5fhp54rj9SMjWBdz584bJmMDu7JwcH-Vmj-Fo,1583
|
|
10
|
+
simple_agent/tools/base_tool.py,sha256=Rz3sqVTQ8ajks-m4qYG-fCpgO8_yovmhyW_neJXbx_4,697
|
|
11
|
+
simple_agent/tools/command_policy.py,sha256=QhWyRZc7YclbbJN8_QAUL5KaJN5wuzmcoTvrBp9pLMk,543
|
|
12
|
+
simple_agent/tools/command_runtime.py,sha256=Wk34ZNfSF5Vb7RxIT35vz_Q9lP5SaysB-BOPAsLDebY,5432
|
|
13
|
+
simple_agent/tools/edit_file.py,sha256=Mw4Ov9xOEHXOPa7Hh0YRXKiEE8gfKNR266IMCeWHEcQ,2672
|
|
14
|
+
simple_agent/tools/list_files.py,sha256=83ksH9RvtpvgTJMVGlC-NNO5gt9X2QC0N12TheKDzk0,1563
|
|
15
|
+
simple_agent/tools/read_file.py,sha256=5X6JGlCfBk8h82seNQoR_CilRHxyeIr4EN8UoWZ1xBU,1858
|
|
16
|
+
simple_agent/tools/run_command.py,sha256=3RDkNe2298DKqdV0gsbNWOvQbf2FBYl94TNCziXvbqk,1855
|
|
17
|
+
simple_agent/tools/search_text.py,sha256=9woE27IA023dAx3B6P0Wl87iJRhvJhhi9D6nQRGC9aw,2331
|
|
18
|
+
simple_agent/tools/tools.py,sha256=jTGK31zh2Ty9Vp_ajgDCEt-GY42vUZjv7qeDu3gpZ1k,1598
|
|
19
|
+
simple_agent/tools/utils.py,sha256=q1yGn7RPMBh8MUoT0_aAoHew0GYZFw9QeM2nxrEujEE,230
|
|
20
|
+
simple_agent/tools/write_file.py,sha256=rQ1bgXlK5qzMTz_TNfFciGiyZnbErzIPrqfLd8QDBog,1815
|
|
21
|
+
simple_agent/tools/permissions/file_permission.py,sha256=UesMDoXBP-iciFYCBG2F0a6ytL_eAD2M9HSXjS12l-w,3755
|
|
22
|
+
plain_agent-0.1.0.dist-info/METADATA,sha256=MXgRpMCHlFXnJ5j3UsEjYrW4sSmcW6XXeRu6cLhv1Eo,1498
|
|
23
|
+
plain_agent-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
24
|
+
plain_agent-0.1.0.dist-info/entry_points.txt,sha256=dglvgV5z6UFGB4xSldi-f2yp0SNAjGuK8ikpSkT4tcI,54
|
|
25
|
+
plain_agent-0.1.0.dist-info/top_level.txt,sha256=GLfnn8uQL8CtYaH1Jj3ROG50x8ecOgIkirf_HVd0VFw,13
|
|
26
|
+
plain_agent-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ribo Mo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simple_agent
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Simple tool loop for the educational agent."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Callable, Generator
|
|
6
|
+
|
|
7
|
+
from simple_agent.conversation_history import ConversationHistory
|
|
8
|
+
from simple_agent.message_types import ToolCallDict
|
|
9
|
+
from simple_agent.prompt import INITIAL_PROMPT
|
|
10
|
+
from simple_agent.streaming import (
|
|
11
|
+
ChatCompletionStreamAccumulator,
|
|
12
|
+
TextDelta,
|
|
13
|
+
ToolResult,
|
|
14
|
+
)
|
|
15
|
+
from simple_agent.tools.tools import Tools
|
|
16
|
+
from simple_agent.tools.utils import error
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SimpleAgent:
|
|
20
|
+
"""A tiny Chat Completions agent loop with workspace tools."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
llm_client: Any,
|
|
25
|
+
model: str,
|
|
26
|
+
workspace: str = ".",
|
|
27
|
+
max_turns: int = 5,
|
|
28
|
+
command_approver: Callable[[str], bool] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self.llm_client = llm_client
|
|
31
|
+
self.model = model
|
|
32
|
+
self.max_turns = max_turns
|
|
33
|
+
self.command_approver = command_approver
|
|
34
|
+
self.tools = Tools(workspace)
|
|
35
|
+
self.conversation_history = ConversationHistory(INITIAL_PROMPT)
|
|
36
|
+
|
|
37
|
+
def respond_stream(self, user_input: str) -> Generator[TextDelta | ToolResult, None, None]:
|
|
38
|
+
self.conversation_history.append_user(user_input)
|
|
39
|
+
|
|
40
|
+
for _ in range(self.max_turns):
|
|
41
|
+
accumulator = ChatCompletionStreamAccumulator()
|
|
42
|
+
for chunk in self._create_llm_stream():
|
|
43
|
+
for event in accumulator.add_chunk(chunk):
|
|
44
|
+
yield event
|
|
45
|
+
|
|
46
|
+
message_dict = accumulator.assistant_message()
|
|
47
|
+
self.conversation_history.append(message_dict)
|
|
48
|
+
|
|
49
|
+
tool_calls = message_dict.get("tool_calls")
|
|
50
|
+
if not tool_calls:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
for tool_call_dict in tool_calls:
|
|
54
|
+
result = self._handle_tool_call(tool_call_dict)
|
|
55
|
+
self.conversation_history.append_tool(tool_call_dict["id"], result)
|
|
56
|
+
yield ToolResult(
|
|
57
|
+
call_id=tool_call_dict["id"],
|
|
58
|
+
name=tool_call_dict["function"]["name"],
|
|
59
|
+
result=result,
|
|
60
|
+
ok=self._tool_result_ok(result),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
final_text = "I stopped because the tool loop reached the max turn limit."
|
|
64
|
+
self.conversation_history.append_assistant(final_text)
|
|
65
|
+
yield TextDelta(final_text)
|
|
66
|
+
|
|
67
|
+
def _create_llm_stream(self) -> Iterable[Any]:
|
|
68
|
+
return self.llm_client.chat.completions.create(
|
|
69
|
+
model=self.model,
|
|
70
|
+
messages=self.conversation_history.to_messages(),
|
|
71
|
+
tools=self.tools.definitions(),
|
|
72
|
+
tool_choice="auto",
|
|
73
|
+
stream=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _handle_tool_call(self, tool_call: ToolCallDict) -> str:
|
|
77
|
+
name = tool_call["function"]["name"]
|
|
78
|
+
try:
|
|
79
|
+
arguments = self._parse_tool_arguments(tool_call["function"]["arguments"])
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
return error(str(exc))
|
|
82
|
+
user_approval = self._approve_run_command(arguments)
|
|
83
|
+
if name == "run_command" and not user_approval:
|
|
84
|
+
return error("run_command was not approved")
|
|
85
|
+
return self.tools.run(name, arguments)
|
|
86
|
+
|
|
87
|
+
def _approve_run_command(self, arguments: dict[str, object]) -> bool:
|
|
88
|
+
command = arguments.get("command")
|
|
89
|
+
if not isinstance(command, str) or not command.strip():
|
|
90
|
+
# Empty or invalid commands are blocked by the tool implementation.
|
|
91
|
+
return True
|
|
92
|
+
if self.command_approver is None:
|
|
93
|
+
return False
|
|
94
|
+
return self.command_approver(command)
|
|
95
|
+
|
|
96
|
+
def _tool_result_ok(self, result: str) -> bool:
|
|
97
|
+
try:
|
|
98
|
+
parsed = json.loads(result)
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
return False
|
|
101
|
+
if not isinstance(parsed, dict):
|
|
102
|
+
return False
|
|
103
|
+
return parsed.get("ok") is True
|
|
104
|
+
|
|
105
|
+
def _parse_tool_arguments(self, raw_arguments: str) -> dict[str, object]:
|
|
106
|
+
try:
|
|
107
|
+
arguments = json.loads(raw_arguments or "{}")
|
|
108
|
+
except json.JSONDecodeError as exc:
|
|
109
|
+
raise ValueError(f"invalid JSON arguments: {exc}")
|
|
110
|
+
if not isinstance(arguments, dict):
|
|
111
|
+
raise ValueError("tool arguments must be a JSON object")
|
|
112
|
+
return arguments
|
simple_agent/cli.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
from simple_agent.agent_loop import SimpleAgent
|
|
6
|
+
from simple_agent.llm_client import (
|
|
7
|
+
DEEPSEEK_BASE_URL,
|
|
8
|
+
OPENAI_BASE_URL,
|
|
9
|
+
OpenAICompatibleClient,
|
|
10
|
+
)
|
|
11
|
+
from simple_agent.terminal_loop import approve_run_command, run_interactive_terminal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
load_dotenv()
|
|
16
|
+
|
|
17
|
+
provider = os.environ.get("LLM_PROVIDER", "deepseek").lower()
|
|
18
|
+
timeout = float(os.environ.get("LLM_TIMEOUT", "60"))
|
|
19
|
+
|
|
20
|
+
if provider == "openai":
|
|
21
|
+
api_key = os.environ.get("OPENAI_API_KEY") or os.environ.get("LLM_API_KEY")
|
|
22
|
+
default_model = "gpt-5.4-mini"
|
|
23
|
+
default_base_url = OPENAI_BASE_URL
|
|
24
|
+
elif provider == "deepseek":
|
|
25
|
+
api_key = os.environ.get("DEEPSEEK_API_KEY") or os.environ.get("LLM_API_KEY")
|
|
26
|
+
default_model = "deepseek-v4-flash"
|
|
27
|
+
default_base_url = DEEPSEEK_BASE_URL
|
|
28
|
+
else:
|
|
29
|
+
raise ValueError("LLM_PROVIDER must be 'openai' or 'deepseek'")
|
|
30
|
+
|
|
31
|
+
llm_client = OpenAICompatibleClient(
|
|
32
|
+
api_key=api_key,
|
|
33
|
+
base_url=os.environ.get("LLM_BASE_URL", default_base_url),
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
)
|
|
36
|
+
model = os.environ.get("LLM_MODEL", default_model)
|
|
37
|
+
agent = SimpleAgent(
|
|
38
|
+
llm_client=llm_client,
|
|
39
|
+
model=model,
|
|
40
|
+
command_approver=approve_run_command,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
run_interactive_terminal(agent)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Typed chat messages and conversation history storage."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import overload
|
|
6
|
+
|
|
7
|
+
from simple_agent.message_types import AssistantMessageDict, ChatMessage, ToolMessage, UserMessage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConversationHistory:
|
|
11
|
+
"""Stores chat messages while exposing list-like read access."""
|
|
12
|
+
_messages: list[ChatMessage]
|
|
13
|
+
|
|
14
|
+
def __init__(self, system_prompt: str) -> None:
|
|
15
|
+
self._messages = [
|
|
16
|
+
{"role": "system", "content": system_prompt},
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
def append(self, message: ChatMessage) -> None:
|
|
20
|
+
self._messages.append(message)
|
|
21
|
+
|
|
22
|
+
def append_user(self, content: str) -> None:
|
|
23
|
+
message: UserMessage = {"role": "user", "content": content}
|
|
24
|
+
self.append(message)
|
|
25
|
+
|
|
26
|
+
def append_assistant(self, content: str) -> None:
|
|
27
|
+
message: AssistantMessageDict = {"role": "assistant", "content": content}
|
|
28
|
+
self.append(message)
|
|
29
|
+
|
|
30
|
+
def append_tool(self, tool_call_id: str, content: str) -> None:
|
|
31
|
+
message: ToolMessage = {
|
|
32
|
+
"role": "tool",
|
|
33
|
+
"tool_call_id": tool_call_id,
|
|
34
|
+
"content": content,
|
|
35
|
+
}
|
|
36
|
+
self.append(message)
|
|
37
|
+
|
|
38
|
+
def replace(self, messages: list[ChatMessage]) -> None:
|
|
39
|
+
self._messages = messages
|
|
40
|
+
|
|
41
|
+
def to_messages(self) -> list[ChatMessage]:
|
|
42
|
+
return deepcopy(self._messages)
|
|
43
|
+
|
|
44
|
+
def __iter__(self) -> Iterator[ChatMessage]:
|
|
45
|
+
return iter(self._messages)
|
|
46
|
+
|
|
47
|
+
def __len__(self) -> int:
|
|
48
|
+
return len(self._messages)
|
|
49
|
+
|
|
50
|
+
@overload
|
|
51
|
+
def __getitem__(self, index: int) -> ChatMessage:
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
@overload
|
|
55
|
+
def __getitem__(self, index: slice) -> list[ChatMessage]:
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def __getitem__(self, index: int | slice) -> ChatMessage | list[ChatMessage]:
|
|
59
|
+
return self._messages[index]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""OpenAI-compatible LLM clients."""
|
|
2
|
+
|
|
3
|
+
from openai import OpenAI
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
OPENAI_BASE_URL = "https://api.openai.com/v1"
|
|
7
|
+
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LLMClientError(ValueError):
|
|
11
|
+
"""Raised when LLM configuration is incomplete."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OpenAICompatibleClient:
|
|
15
|
+
"""Small wrapper around the official OpenAI SDK client."""
|
|
16
|
+
|
|
17
|
+
base_url = OPENAI_BASE_URL
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: str | None,
|
|
22
|
+
base_url: str | None = None,
|
|
23
|
+
timeout: float = 60.0,
|
|
24
|
+
) -> None:
|
|
25
|
+
if not api_key:
|
|
26
|
+
raise LLMClientError("api_key is required to create an LLM client")
|
|
27
|
+
|
|
28
|
+
self.api_key = api_key
|
|
29
|
+
self.base_url = base_url or self.base_url
|
|
30
|
+
self.timeout = timeout
|
|
31
|
+
self.client = OpenAI(
|
|
32
|
+
base_url=self.base_url,
|
|
33
|
+
api_key=self.api_key,
|
|
34
|
+
timeout=self.timeout,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def chat(self):
|
|
39
|
+
return self.client.chat
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OpenAIClient(OpenAICompatibleClient):
|
|
43
|
+
"""OpenAI client."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DeepSeekClient(OpenAICompatibleClient):
|
|
47
|
+
"""DeepSeek client using its OpenAI-compatible API."""
|
|
48
|
+
|
|
49
|
+
base_url = DEEPSEEK_BASE_URL
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""TypedDict shapes for OpenAI-compatible chat messages."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal, NotRequired, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SystemMessage(TypedDict):
|
|
7
|
+
role: Literal["system"]
|
|
8
|
+
content: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UserMessage(TypedDict):
|
|
12
|
+
role: Literal["user"]
|
|
13
|
+
content: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ToolMessage(TypedDict):
|
|
17
|
+
role: Literal["tool"]
|
|
18
|
+
tool_call_id: str
|
|
19
|
+
content: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FunctionCallDict(TypedDict):
|
|
23
|
+
name: str
|
|
24
|
+
arguments: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolCallDict(TypedDict):
|
|
28
|
+
id: str
|
|
29
|
+
type: Literal["function"]
|
|
30
|
+
function: FunctionCallDict
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AssistantMessageDict(TypedDict):
|
|
34
|
+
role: Literal["assistant"]
|
|
35
|
+
content: str | None
|
|
36
|
+
tool_calls: NotRequired[list[ToolCallDict]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
ChatMessage = SystemMessage | UserMessage | AssistantMessageDict | ToolMessage
|
simple_agent/prompt.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Helpers for Chat Completions streaming responses."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from openai.types.chat.chat_completion_chunk import (
|
|
6
|
+
ChatCompletionChunk,
|
|
7
|
+
ChoiceDeltaToolCall,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from simple_agent.message_types import AssistantMessageDict, ToolCallDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TextDelta:
|
|
15
|
+
"""A streamed assistant text chunk."""
|
|
16
|
+
|
|
17
|
+
content: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ToolResult:
|
|
22
|
+
"""A completed tool call event."""
|
|
23
|
+
|
|
24
|
+
call_id: str
|
|
25
|
+
name: str
|
|
26
|
+
result: str
|
|
27
|
+
ok: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def merge_tool_call_delta_into(tool_call: ToolCallDict, tool_call_delta: ChoiceDeltaToolCall) -> None:
|
|
31
|
+
# Each streamed delta may contain only one fragment of the final tool call.
|
|
32
|
+
call_id = tool_call_delta.id
|
|
33
|
+
if call_id:
|
|
34
|
+
tool_call["id"] = call_id
|
|
35
|
+
|
|
36
|
+
call_type = tool_call_delta.type
|
|
37
|
+
if call_type:
|
|
38
|
+
tool_call["type"] = call_type
|
|
39
|
+
|
|
40
|
+
function_delta = tool_call_delta.function
|
|
41
|
+
if function_delta is None:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
name = function_delta.name
|
|
45
|
+
if name:
|
|
46
|
+
tool_call["function"]["name"] += name
|
|
47
|
+
|
|
48
|
+
arguments = function_delta.arguments
|
|
49
|
+
if arguments:
|
|
50
|
+
tool_call["function"]["arguments"] += arguments
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ChatCompletionStreamAccumulator:
|
|
54
|
+
"""Build the final assistant message from streamed Chat Completions chunks."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
self.full_text = ""
|
|
58
|
+
self.tool_calls_by_index: dict[int, ToolCallDict] = {}
|
|
59
|
+
|
|
60
|
+
def add_chunk(self, chunk: ChatCompletionChunk) -> list[TextDelta]:
|
|
61
|
+
# OpenAI can send usage/bookkeeping chunks with choices=[].
|
|
62
|
+
choices = chunk.choices
|
|
63
|
+
if not choices:
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
delta = choices[0].delta
|
|
67
|
+
events = []
|
|
68
|
+
|
|
69
|
+
content = delta.content
|
|
70
|
+
if content:
|
|
71
|
+
self.full_text += content
|
|
72
|
+
events.append(TextDelta(content))
|
|
73
|
+
|
|
74
|
+
for tool_call_delta in delta.tool_calls or []:
|
|
75
|
+
index = tool_call_delta.index
|
|
76
|
+
if index not in self.tool_calls_by_index:
|
|
77
|
+
self.tool_calls_by_index[index] = {
|
|
78
|
+
"id": "",
|
|
79
|
+
"type": "function",
|
|
80
|
+
"function": {"name": "", "arguments": ""},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
tool_call = self.tool_calls_by_index[index]
|
|
84
|
+
# Later deltas usually add more argument text for this index.
|
|
85
|
+
merge_tool_call_delta_into(tool_call, tool_call_delta)
|
|
86
|
+
|
|
87
|
+
return events
|
|
88
|
+
|
|
89
|
+
def assistant_message(self) -> AssistantMessageDict:
|
|
90
|
+
message_dict: AssistantMessageDict = {"role": "assistant", "content": self.full_text or None}
|
|
91
|
+
if self.tool_calls_by_index:
|
|
92
|
+
message_dict["tool_calls"] = [
|
|
93
|
+
self.tool_calls_by_index[index]
|
|
94
|
+
for index in sorted(self.tool_calls_by_index)
|
|
95
|
+
]
|
|
96
|
+
return message_dict
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Interactive terminal loop for the simple agent."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from simple_agent.streaming import TextDelta, ToolResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StreamingAgent(Protocol):
|
|
10
|
+
"""Agent interface used by the terminal loop."""
|
|
11
|
+
|
|
12
|
+
def respond_stream(self, user_input: str) -> Iterable[TextDelta | ToolResult]:
|
|
13
|
+
"""Return streamed text and tool-result events for one user prompt."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def approve_run_command(command: str) -> bool:
|
|
17
|
+
"""Ask the user whether a requested shell command may run."""
|
|
18
|
+
while True:
|
|
19
|
+
answer = input(f"\nApprove command `{command}`? [y/N] ").strip().lower()
|
|
20
|
+
if answer in {"y", "yes"}:
|
|
21
|
+
return True
|
|
22
|
+
if answer in {"", "n", "no"}:
|
|
23
|
+
return False
|
|
24
|
+
print("Please answer y or n.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_interactive_terminal(agent: StreamingAgent) -> None:
|
|
28
|
+
"""Read prompts, stream responses, show tool results, and repeat."""
|
|
29
|
+
print("Simple agent client. Type 'exit' to quit.")
|
|
30
|
+
|
|
31
|
+
while True:
|
|
32
|
+
try:
|
|
33
|
+
user_input = input("> ").strip()
|
|
34
|
+
except EOFError:
|
|
35
|
+
print()
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
if user_input.lower() in {"exit", "quit"}:
|
|
39
|
+
break
|
|
40
|
+
if not user_input:
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
for event in agent.respond_stream(user_input):
|
|
44
|
+
if isinstance(event, TextDelta):
|
|
45
|
+
print(event.content, end="", flush=True)
|
|
46
|
+
elif isinstance(event, ToolResult):
|
|
47
|
+
status = "ok" if event.ok else "error"
|
|
48
|
+
print(f"\n[tool {event.name}: {status}]")
|
|
49
|
+
print()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Base class for callable tools."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BaseTool:
|
|
8
|
+
"""Base class for a tool the model can call."""
|
|
9
|
+
|
|
10
|
+
name = ""
|
|
11
|
+
description = ""
|
|
12
|
+
parameters: dict[str, object] = {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {},
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def definition(self) -> dict[str, object]:
|
|
19
|
+
return {
|
|
20
|
+
"type": "function",
|
|
21
|
+
"function": {
|
|
22
|
+
"name": self.name,
|
|
23
|
+
"description": self.description,
|
|
24
|
+
"parameters": deepcopy(self.parameters),
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def run(self, root: Path, arguments: dict[str, object]) -> str:
|
|
29
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Allowlist policy for safe workspace commands."""
|
|
2
|
+
|
|
3
|
+
RUN_COMMAND_ALLOWED_COMMANDS = ("pwd", "ls", "find", "rg", "grep", "cat", "head", "tail", "wc")
|
|
4
|
+
RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS = (
|
|
5
|
+
"status",
|
|
6
|
+
"diff",
|
|
7
|
+
"log",
|
|
8
|
+
"show",
|
|
9
|
+
"branch",
|
|
10
|
+
"rev-parse",
|
|
11
|
+
"ls-files",
|
|
12
|
+
"grep",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
RUN_COMMAND_ALLOWED_COMMANDS_TEXT = (
|
|
16
|
+
"Allowed commands: "
|
|
17
|
+
f"{', '.join(RUN_COMMAND_ALLOWED_COMMANDS)}. "
|
|
18
|
+
"Allowed git subcommands: "
|
|
19
|
+
f"{', '.join(f'git {subcommand}' for subcommand in RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS)}."
|
|
20
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Runtime for safe workspace inspection commands."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
from simple_agent.tools.command_policy import (
|
|
9
|
+
RUN_COMMAND_ALLOWED_COMMANDS,
|
|
10
|
+
RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS,
|
|
11
|
+
)
|
|
12
|
+
from simple_agent.tools.permissions.file_permission import WorkspacePermission
|
|
13
|
+
|
|
14
|
+
ALLOWED_COMMANDS = set(RUN_COMMAND_ALLOWED_COMMANDS)
|
|
15
|
+
ALLOWED_GIT_SUBCOMMANDS = set(RUN_COMMAND_ALLOWED_GIT_SUBCOMMANDS)
|
|
16
|
+
SHELL_OPERATORS = ("|", ">", "<", "&&", "||", ";", "$(", "`", "&")
|
|
17
|
+
MUTATING_FLAGS = {
|
|
18
|
+
"-delete",
|
|
19
|
+
"-exec",
|
|
20
|
+
"-execdir",
|
|
21
|
+
"-ok",
|
|
22
|
+
"-okdir",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CommandRuntimeError(ValueError):
|
|
27
|
+
"""Raised when a command cannot be safely run."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CommandResult:
|
|
32
|
+
ok: bool
|
|
33
|
+
command: str
|
|
34
|
+
exit_code: int | None
|
|
35
|
+
stdout: str
|
|
36
|
+
stderr: str
|
|
37
|
+
timed_out: bool
|
|
38
|
+
truncated: bool
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, object]:
|
|
41
|
+
return asdict(self)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CommandRuntime:
|
|
45
|
+
"""Runs allowlisted commands from a workspace without shell syntax."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, timeout_seconds: float = 30, max_output_chars: int = 12_000) -> None:
|
|
48
|
+
self.timeout_seconds = timeout_seconds
|
|
49
|
+
self.max_output_chars = max_output_chars
|
|
50
|
+
|
|
51
|
+
def run(self, workspace: Path, command: str) -> CommandResult:
|
|
52
|
+
argv = self._parse_command(command)
|
|
53
|
+
self._require_command_access(workspace, argv)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
completed = subprocess.run(
|
|
57
|
+
argv,
|
|
58
|
+
cwd=workspace,
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
timeout=self.timeout_seconds,
|
|
62
|
+
shell=False,
|
|
63
|
+
check=False,
|
|
64
|
+
)
|
|
65
|
+
except subprocess.TimeoutExpired as exc:
|
|
66
|
+
stdout = self._output_text(exc.stdout)
|
|
67
|
+
stderr = self._output_text(exc.stderr)
|
|
68
|
+
stdout, stderr, truncated = self._truncate_outputs(stdout, stderr)
|
|
69
|
+
return CommandResult(
|
|
70
|
+
ok=False,
|
|
71
|
+
command=command,
|
|
72
|
+
exit_code=None,
|
|
73
|
+
stdout=stdout,
|
|
74
|
+
stderr=stderr,
|
|
75
|
+
timed_out=True,
|
|
76
|
+
truncated=truncated,
|
|
77
|
+
)
|
|
78
|
+
except OSError as exc:
|
|
79
|
+
raise CommandRuntimeError(f"could not run command: {exc}") from exc
|
|
80
|
+
|
|
81
|
+
stdout, stderr, truncated = self._truncate_outputs(completed.stdout, completed.stderr)
|
|
82
|
+
return CommandResult(
|
|
83
|
+
ok=completed.returncode == 0,
|
|
84
|
+
command=command,
|
|
85
|
+
exit_code=completed.returncode,
|
|
86
|
+
stdout=stdout,
|
|
87
|
+
stderr=stderr,
|
|
88
|
+
timed_out=False,
|
|
89
|
+
truncated=truncated,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def _parse_command(self, command: str) -> list[str]:
|
|
93
|
+
self._require_no_shell_syntax(command)
|
|
94
|
+
try:
|
|
95
|
+
argv = shlex.split(command)
|
|
96
|
+
except ValueError as exc:
|
|
97
|
+
raise CommandRuntimeError(f"invalid command: {exc}") from exc
|
|
98
|
+
if not argv:
|
|
99
|
+
raise CommandRuntimeError("command is required")
|
|
100
|
+
return argv
|
|
101
|
+
|
|
102
|
+
def _require_no_shell_syntax(self, command: str) -> None:
|
|
103
|
+
for operator in SHELL_OPERATORS:
|
|
104
|
+
if operator in command:
|
|
105
|
+
raise CommandRuntimeError(f"shell syntax is not allowed: {operator}")
|
|
106
|
+
|
|
107
|
+
def _require_command_access(self, workspace: Path, argv: list[str]) -> None:
|
|
108
|
+
self._check_supported_command(argv)
|
|
109
|
+
self._check_command_paths(workspace, argv)
|
|
110
|
+
|
|
111
|
+
def _check_supported_command(self, argv: list[str]) -> None:
|
|
112
|
+
executable = argv[0]
|
|
113
|
+
if executable in ALLOWED_COMMANDS:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if executable == "git":
|
|
117
|
+
if len(argv) < 2:
|
|
118
|
+
raise CommandRuntimeError("git subcommand is required")
|
|
119
|
+
subcommand = argv[1]
|
|
120
|
+
if subcommand in ALLOWED_GIT_SUBCOMMANDS:
|
|
121
|
+
return
|
|
122
|
+
raise CommandRuntimeError(f"git subcommand is not allowed: {subcommand}")
|
|
123
|
+
|
|
124
|
+
raise CommandRuntimeError(f"command is not allowed: {executable}")
|
|
125
|
+
|
|
126
|
+
def _check_command_paths(self, workspace: Path, argv: list[str]) -> None:
|
|
127
|
+
permissions = WorkspacePermission(workspace)
|
|
128
|
+
for argument in argv[1:]:
|
|
129
|
+
if argument in MUTATING_FLAGS:
|
|
130
|
+
raise CommandRuntimeError(f"command flag is not allowed: {argument}")
|
|
131
|
+
if argument.startswith("-"):
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
argument_path = Path(argument)
|
|
135
|
+
if argument_path.is_absolute() or ".." in argument_path.parts:
|
|
136
|
+
raise CommandRuntimeError(f"path argument is outside workspace: {argument}")
|
|
137
|
+
|
|
138
|
+
candidate = permissions.workspace / argument_path
|
|
139
|
+
if candidate.exists() and not permissions.contains_path(candidate.resolve()):
|
|
140
|
+
raise CommandRuntimeError(f"path argument is outside workspace: {argument}")
|
|
141
|
+
|
|
142
|
+
def _truncate_outputs(self, stdout: str, stderr: str) -> tuple[str, str, bool]:
|
|
143
|
+
if len(stdout) + len(stderr) <= self.max_output_chars:
|
|
144
|
+
return stdout, stderr, False
|
|
145
|
+
|
|
146
|
+
stdout = stdout[:self.max_output_chars]
|
|
147
|
+
stderr = stderr[:self.max_output_chars - len(stdout)]
|
|
148
|
+
return stdout, stderr, True
|
|
149
|
+
|
|
150
|
+
def _output_text(self, output: str | bytes | bytearray | memoryview | None) -> str:
|
|
151
|
+
if output is None:
|
|
152
|
+
return ""
|
|
153
|
+
if isinstance(output, str):
|
|
154
|
+
return output
|
|
155
|
+
return bytes(output).decode(errors="replace")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Tool for editing workspace files with exact string replacement."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from simple_agent.tools.base_tool import BaseTool
|
|
6
|
+
from simple_agent.tools.permissions.file_permission import FilePermissionError, WorkspacePermission
|
|
7
|
+
from simple_agent.tools.utils import error, ok
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EditFileTool(BaseTool):
|
|
11
|
+
"""Replace an exact string inside an existing workspace file."""
|
|
12
|
+
|
|
13
|
+
name = "edit_file"
|
|
14
|
+
description = "Replace an exact string inside an existing workspace file."
|
|
15
|
+
parameters = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"path": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Workspace-relative file path.",
|
|
21
|
+
},
|
|
22
|
+
"old_string": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Exact text to replace.",
|
|
25
|
+
},
|
|
26
|
+
"new_string": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": "Replacement text.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
"required": ["path", "old_string", "new_string"],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def run(self, root: Path, arguments: dict[str, object]) -> str:
|
|
35
|
+
path = arguments.get("path")
|
|
36
|
+
old_string = arguments.get("old_string")
|
|
37
|
+
new_string = arguments.get("new_string")
|
|
38
|
+
if not isinstance(path, str):
|
|
39
|
+
return error("path is required")
|
|
40
|
+
if not isinstance(old_string, str) or not old_string:
|
|
41
|
+
return error("old_string is required")
|
|
42
|
+
if not isinstance(new_string, str):
|
|
43
|
+
return error("new_string is required")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
permissions = WorkspacePermission(root)
|
|
47
|
+
workspace_path = permissions.require_access(path)
|
|
48
|
+
except FilePermissionError as exc:
|
|
49
|
+
return error(str(exc))
|
|
50
|
+
|
|
51
|
+
if not workspace_path.is_file():
|
|
52
|
+
return error(f"path is not a file: {path}")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
text = workspace_path.read_text(encoding="utf-8")
|
|
56
|
+
except UnicodeDecodeError:
|
|
57
|
+
return error(f"file is not valid UTF-8 text: {path}")
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
return error(f"could not read file: {exc}")
|
|
60
|
+
|
|
61
|
+
count = text.count(old_string)
|
|
62
|
+
if count == 0:
|
|
63
|
+
return error(f"old_string not found in {path}")
|
|
64
|
+
if count > 1:
|
|
65
|
+
return error(f"old_string found {count} times in {path} — provide more context")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
workspace_path.write_text(text.replace(old_string, new_string), encoding="utf-8")
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
return error(f"could not write file: {exc}")
|
|
71
|
+
|
|
72
|
+
return ok({
|
|
73
|
+
"path": permissions.relative_to_workspace(workspace_path),
|
|
74
|
+
"replaced": True,
|
|
75
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Tool for listing workspace files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from simple_agent.tools.base_tool import BaseTool
|
|
6
|
+
from simple_agent.tools.permissions.file_permission import FilePermissionError, WorkspacePermission
|
|
7
|
+
from simple_agent.tools.utils import error, ok
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ListFilesTool(BaseTool):
|
|
11
|
+
"""List files and directories inside the workspace."""
|
|
12
|
+
|
|
13
|
+
name = "list_files"
|
|
14
|
+
description = "List files and directories inside the workspace."
|
|
15
|
+
parameters = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"path": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Workspace-relative directory path.",
|
|
21
|
+
"default": ".",
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def run(self, root: Path, arguments: dict[str, object]) -> str:
|
|
27
|
+
path = arguments.get("path", ".")
|
|
28
|
+
try:
|
|
29
|
+
permissions = WorkspacePermission(root)
|
|
30
|
+
workspace_path = permissions.require_access(path)
|
|
31
|
+
except FilePermissionError as exc:
|
|
32
|
+
return error(str(exc))
|
|
33
|
+
|
|
34
|
+
if not workspace_path.is_dir():
|
|
35
|
+
return error(f"path is not a directory: {path}")
|
|
36
|
+
|
|
37
|
+
entries = []
|
|
38
|
+
for child in sorted(workspace_path.iterdir()):
|
|
39
|
+
relative_path = child.relative_to(permissions.workspace)
|
|
40
|
+
if permissions.is_sensitive_path(relative_path):
|
|
41
|
+
continue
|
|
42
|
+
suffix = "/" if child.is_dir() else ""
|
|
43
|
+
entries.append(f"{relative_path}{suffix}")
|
|
44
|
+
|
|
45
|
+
return ok({"path": permissions.relative_to_workspace(workspace_path), "entries": entries})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""File permission helpers for workspace tools."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
IGNORED_DIRS = {".agents", ".codex", ".git", ".sandbox", ".venv", "__pycache__"}
|
|
6
|
+
SENSITIVE_FILE_NAMES = {".env", "id_dsa", "id_ecdsa", "id_ed25519", "id_rsa"}
|
|
7
|
+
SENSITIVE_FILE_SUFFIXES = {".key", ".pem", ".p12", ".pfx"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FilePermissionError(PermissionError):
|
|
11
|
+
"""Raised when a tool cannot safely access a workspace path."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WorkspacePermission:
|
|
15
|
+
"""Checks whether paths are safe to access inside a workspace."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, workspace: Path) -> None:
|
|
18
|
+
self.workspace = workspace.resolve()
|
|
19
|
+
|
|
20
|
+
def _resolve_to_absolute_path(self, path: Path) -> Path:
|
|
21
|
+
try:
|
|
22
|
+
if path.is_absolute():
|
|
23
|
+
return path.resolve()
|
|
24
|
+
return (self.workspace / path).resolve()
|
|
25
|
+
except (OSError, RuntimeError) as exc:
|
|
26
|
+
raise FilePermissionError(f"path could not be resolved: {path}") from exc
|
|
27
|
+
|
|
28
|
+
def is_sensitive_path(self, relative_path: Path) -> bool:
|
|
29
|
+
for part in relative_path.parts:
|
|
30
|
+
lower_part = part.lower()
|
|
31
|
+
if lower_part in IGNORED_DIRS or lower_part in SENSITIVE_FILE_NAMES:
|
|
32
|
+
return True
|
|
33
|
+
return relative_path.suffix.lower() in SENSITIVE_FILE_SUFFIXES
|
|
34
|
+
|
|
35
|
+
def require_access(self, path: str | Path, must_exist: bool = True) -> Path:
|
|
36
|
+
try:
|
|
37
|
+
user_path = Path(path)
|
|
38
|
+
except TypeError as exc:
|
|
39
|
+
raise FilePermissionError("path must be a string") from exc
|
|
40
|
+
|
|
41
|
+
absolute_path = self._resolve_to_absolute_path(user_path)
|
|
42
|
+
self._check_inside_workspace(absolute_path, path)
|
|
43
|
+
self._check_not_sensitive(absolute_path, path)
|
|
44
|
+
self._check_exists(absolute_path, path, must_exist=must_exist)
|
|
45
|
+
return absolute_path
|
|
46
|
+
|
|
47
|
+
def contains_path(self, path: Path) -> bool:
|
|
48
|
+
return path == self.workspace or self.workspace in path.parents
|
|
49
|
+
|
|
50
|
+
def get_files_under_path(self, path: Path) -> list[Path]:
|
|
51
|
+
if path.is_file():
|
|
52
|
+
return [path]
|
|
53
|
+
return self._walk_files(path)
|
|
54
|
+
|
|
55
|
+
def _walk_files(self, path: Path) -> list[Path]:
|
|
56
|
+
"""Collect workspace files recursively, skipping symlinks and blocked paths."""
|
|
57
|
+
files: list[Path] = []
|
|
58
|
+
for child in sorted(path.iterdir()):
|
|
59
|
+
if child.is_symlink():
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
relative_path = child.relative_to(self.workspace)
|
|
63
|
+
if child.name in IGNORED_DIRS or self.is_sensitive_path(relative_path):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if child.is_dir():
|
|
67
|
+
files += self._walk_files(child)
|
|
68
|
+
elif child.is_file():
|
|
69
|
+
files.append(child)
|
|
70
|
+
return files
|
|
71
|
+
|
|
72
|
+
def _check_inside_workspace(self, absolute_path: Path, original_path: str | Path) -> None:
|
|
73
|
+
if not self.contains_path(absolute_path):
|
|
74
|
+
raise FilePermissionError(f"path is outside workspace: {original_path}")
|
|
75
|
+
|
|
76
|
+
def _check_not_sensitive(self, absolute_path: Path, original_path: str | Path) -> None:
|
|
77
|
+
if absolute_path != self.workspace and self.is_sensitive_path(absolute_path.relative_to(self.workspace)):
|
|
78
|
+
raise FilePermissionError(f"path is blocked: {original_path}")
|
|
79
|
+
|
|
80
|
+
def _check_exists(
|
|
81
|
+
self,
|
|
82
|
+
absolute_path: Path,
|
|
83
|
+
original_path: str | Path,
|
|
84
|
+
must_exist: bool = True,
|
|
85
|
+
) -> None:
|
|
86
|
+
try:
|
|
87
|
+
path_exists = absolute_path.exists()
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
raise FilePermissionError(f"path could not be checked: {original_path}") from exc
|
|
90
|
+
if must_exist and not path_exists:
|
|
91
|
+
raise FilePermissionError(f"path does not exist: {original_path}")
|
|
92
|
+
|
|
93
|
+
def relative_to_workspace(self, path: Path) -> str:
|
|
94
|
+
return str(path.relative_to(self.workspace))
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tool for reading workspace files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from simple_agent.tools.base_tool import BaseTool
|
|
6
|
+
from simple_agent.tools.permissions.file_permission import FilePermissionError, WorkspacePermission
|
|
7
|
+
from simple_agent.tools.utils import error, ok
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReadFileTool(BaseTool):
|
|
11
|
+
"""Read a UTF-8 text file inside the workspace."""
|
|
12
|
+
|
|
13
|
+
name = "read_file"
|
|
14
|
+
description = "Read a UTF-8 text file inside the workspace."
|
|
15
|
+
parameters = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"path": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Workspace-relative file path.",
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"required": ["path"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def __init__(self, max_chars: int = 12_000) -> None:
|
|
27
|
+
self.max_chars = max_chars
|
|
28
|
+
|
|
29
|
+
def run(self, root: Path, arguments: dict[str, object]) -> str:
|
|
30
|
+
path = arguments.get("path")
|
|
31
|
+
if not isinstance(path, str):
|
|
32
|
+
return error("path is required")
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
permissions = WorkspacePermission(root)
|
|
36
|
+
workspace_path = permissions.require_access(path)
|
|
37
|
+
except FilePermissionError as exc:
|
|
38
|
+
return error(str(exc))
|
|
39
|
+
|
|
40
|
+
if not workspace_path.is_file():
|
|
41
|
+
return error(f"path is not a file: {path}")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
text = workspace_path.read_text(encoding="utf-8")
|
|
45
|
+
except UnicodeDecodeError:
|
|
46
|
+
return error(f"file is not valid UTF-8 text: {path}")
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
return error(f"could not read file: {exc}")
|
|
49
|
+
|
|
50
|
+
truncated = len(text) > self.max_chars
|
|
51
|
+
if truncated:
|
|
52
|
+
text = text[: self.max_chars]
|
|
53
|
+
|
|
54
|
+
return ok(
|
|
55
|
+
{
|
|
56
|
+
"path": permissions.relative_to_workspace(workspace_path),
|
|
57
|
+
"content": text,
|
|
58
|
+
"truncated": truncated,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Tool for running safe inspection commands in the workspace."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from simple_agent.tools.base_tool import BaseTool
|
|
7
|
+
from simple_agent.tools.command_policy import RUN_COMMAND_ALLOWED_COMMANDS_TEXT
|
|
8
|
+
from simple_agent.tools.command_runtime import CommandRuntime, CommandRuntimeError
|
|
9
|
+
from simple_agent.tools.utils import error
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RunCommandTool(BaseTool):
|
|
13
|
+
"""Run a small allowlisted command without shell syntax."""
|
|
14
|
+
|
|
15
|
+
# This tool is for trusted workspace inspection, not a full sandbox. It
|
|
16
|
+
# runs outside the WorkspacePermission file filters, so keep the command
|
|
17
|
+
# allowlist and argument checks conservative.
|
|
18
|
+
|
|
19
|
+
name = "run_command"
|
|
20
|
+
description = (
|
|
21
|
+
"Run a safe inspection command inside the workspace. "
|
|
22
|
+
f"{RUN_COMMAND_ALLOWED_COMMANDS_TEXT}"
|
|
23
|
+
)
|
|
24
|
+
parameters = {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"command": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": (
|
|
30
|
+
"Simple command to run. Shell syntax is not supported. "
|
|
31
|
+
f"{RUN_COMMAND_ALLOWED_COMMANDS_TEXT}"
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
"required": ["command"],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def __init__(self, timeout_seconds: float = 30, max_output_chars: int = 12_000) -> None:
|
|
39
|
+
self.runtime = CommandRuntime(
|
|
40
|
+
timeout_seconds=timeout_seconds,
|
|
41
|
+
max_output_chars=max_output_chars,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def run(self, root: Path, arguments: dict[str, object]) -> str:
|
|
45
|
+
command = arguments.get("command")
|
|
46
|
+
if not isinstance(command, str) or not command.strip():
|
|
47
|
+
return error("command is required")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
result = self.runtime.run(root, command)
|
|
51
|
+
except CommandRuntimeError as exc:
|
|
52
|
+
return error(str(exc))
|
|
53
|
+
|
|
54
|
+
return json.dumps(result.to_dict())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tool for searching workspace text."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from simple_agent.tools.base_tool import BaseTool
|
|
6
|
+
from simple_agent.tools.permissions.file_permission import FilePermissionError, WorkspacePermission
|
|
7
|
+
from simple_agent.tools.utils import error, ok
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SearchTextTool(BaseTool):
|
|
11
|
+
"""Search for exact text inside workspace files."""
|
|
12
|
+
|
|
13
|
+
name = "search_text"
|
|
14
|
+
description = "Search for exact text inside workspace files."
|
|
15
|
+
parameters = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"query": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Exact text to search for.",
|
|
21
|
+
},
|
|
22
|
+
"path": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Workspace-relative file or directory path.",
|
|
25
|
+
"default": ".",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
"required": ["query"],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def __init__(self, max_results: int = 20) -> None:
|
|
32
|
+
self.max_results = max_results
|
|
33
|
+
|
|
34
|
+
def run(self, root: Path, arguments: dict[str, object]) -> str:
|
|
35
|
+
query = arguments.get("query")
|
|
36
|
+
if not isinstance(query, str) or not query:
|
|
37
|
+
return error("query is required")
|
|
38
|
+
|
|
39
|
+
path = str(arguments.get("path", "."))
|
|
40
|
+
try:
|
|
41
|
+
permissions = WorkspacePermission(root)
|
|
42
|
+
workspace_path = permissions.require_access(path)
|
|
43
|
+
except FilePermissionError as exc:
|
|
44
|
+
return error(str(exc))
|
|
45
|
+
|
|
46
|
+
files = permissions.get_files_under_path(workspace_path)
|
|
47
|
+
results = []
|
|
48
|
+
for file_path in files:
|
|
49
|
+
try:
|
|
50
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
51
|
+
except (UnicodeDecodeError, OSError):
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
for line_number, line in enumerate(lines, start=1):
|
|
55
|
+
if query in line:
|
|
56
|
+
results.append(
|
|
57
|
+
{
|
|
58
|
+
"path": permissions.relative_to_workspace(file_path),
|
|
59
|
+
"line": line_number,
|
|
60
|
+
"text": line.strip(),
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
if len(results) >= self.max_results:
|
|
64
|
+
return ok({"query": query, "results": results, "truncated": True})
|
|
65
|
+
|
|
66
|
+
return ok({"query": query, "results": results, "truncated": False})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Dispatcher for workspace tools."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from simple_agent.tools.base_tool import BaseTool
|
|
6
|
+
from simple_agent.tools.utils import error
|
|
7
|
+
from simple_agent.tools.list_files import ListFilesTool
|
|
8
|
+
from simple_agent.tools.read_file import ReadFileTool
|
|
9
|
+
from simple_agent.tools.search_text import SearchTextTool
|
|
10
|
+
from simple_agent.tools.write_file import WriteFileTool
|
|
11
|
+
from simple_agent.tools.edit_file import EditFileTool
|
|
12
|
+
from simple_agent.tools.run_command import RunCommandTool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Tools:
|
|
16
|
+
"""Small tool dispatcher scoped to a workspace directory."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
root: str | Path = ".",
|
|
21
|
+
max_read_chars: int = 12_000,
|
|
22
|
+
max_search_results: int = 20,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.root = Path(root).resolve()
|
|
25
|
+
self.max_read_chars = max_read_chars
|
|
26
|
+
self.max_search_results = max_search_results
|
|
27
|
+
self.tools: dict[str, BaseTool] = {
|
|
28
|
+
tool.name: tool
|
|
29
|
+
for tool in [
|
|
30
|
+
ListFilesTool(),
|
|
31
|
+
ReadFileTool(max_chars=self.max_read_chars),
|
|
32
|
+
SearchTextTool(max_results=self.max_search_results),
|
|
33
|
+
WriteFileTool(),
|
|
34
|
+
EditFileTool(),
|
|
35
|
+
RunCommandTool(),
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def definitions(self) -> list[dict[str, object]]:
|
|
40
|
+
return [tool.definition for tool in self.tools.values()]
|
|
41
|
+
|
|
42
|
+
def run(self, name: str, arguments: dict[str, object]) -> str:
|
|
43
|
+
tool = self.tools.get(name)
|
|
44
|
+
if tool is None:
|
|
45
|
+
return error(f"unknown tool: {name}")
|
|
46
|
+
return tool.run(self.root, arguments)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Tool for creating or overwriting workspace files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from simple_agent.tools.base_tool import BaseTool
|
|
6
|
+
from simple_agent.tools.permissions.file_permission import FilePermissionError, WorkspacePermission
|
|
7
|
+
from simple_agent.tools.utils import error, ok
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WriteFileTool(BaseTool):
|
|
11
|
+
"""Create or overwrite a file inside the workspace."""
|
|
12
|
+
|
|
13
|
+
name = "write_file"
|
|
14
|
+
description = "Create or overwrite a file inside the workspace."
|
|
15
|
+
parameters = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"path": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Workspace-relative file path.",
|
|
21
|
+
},
|
|
22
|
+
"content": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Full file content.",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
"required": ["path", "content"],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
def run(self, root: Path, arguments: dict[str, object]) -> str:
|
|
31
|
+
path = arguments.get("path")
|
|
32
|
+
content = arguments.get("content")
|
|
33
|
+
if not isinstance(path, str):
|
|
34
|
+
return error("path is required")
|
|
35
|
+
if not isinstance(content, str) or not content:
|
|
36
|
+
return error("content is required")
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
permissions = WorkspacePermission(root)
|
|
40
|
+
workspace_path = permissions.require_access(path, must_exist=False)
|
|
41
|
+
except FilePermissionError as exc:
|
|
42
|
+
return error(str(exc))
|
|
43
|
+
|
|
44
|
+
if workspace_path.is_dir():
|
|
45
|
+
return error(f"path is not a file: {path}")
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
workspace_path.write_text(content, encoding="utf-8")
|
|
49
|
+
except OSError as exc:
|
|
50
|
+
return error(f"could not write file: {exc}")
|
|
51
|
+
|
|
52
|
+
return ok({
|
|
53
|
+
"path": permissions.relative_to_workspace(workspace_path),
|
|
54
|
+
"written": len(content),
|
|
55
|
+
})
|