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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plain-agent = simple_agent.cli:main
@@ -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,5 @@
1
+ INITIAL_PROMPT = """
2
+ You are a helpful educational coding assistant.
3
+ You may inspect and edit the workspace with tools before answering.
4
+ Use tools when file context would make your answer more accurate.
5
+ """
@@ -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,11 @@
1
+ """Shared helpers for workspace tools."""
2
+
3
+ import json
4
+
5
+
6
+ def ok(data: dict[str, object]) -> str:
7
+ return json.dumps({"ok": True, **data})
8
+
9
+
10
+ def error(message: str) -> str:
11
+ return json.dumps({"ok": False, "error": message})
@@ -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
+ })