makefile-agent 0.3.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.
- make_agent/__init__.py +0 -0
- make_agent/agent.py +190 -0
- make_agent/agent_shell.py +93 -0
- make_agent/app_dirs.py +51 -0
- make_agent/builtin_tools.py +380 -0
- make_agent/create_agent.py +228 -0
- make_agent/main.py +210 -0
- make_agent/memory.py +170 -0
- make_agent/parser.py +351 -0
- make_agent/settings.py +92 -0
- make_agent/templates/orchestra.mk +99 -0
- make_agent/tools.py +193 -0
- makefile_agent-0.3.0.dist-info/METADATA +265 -0
- makefile_agent-0.3.0.dist-info/RECORD +18 -0
- makefile_agent-0.3.0.dist-info/WHEEL +5 -0
- makefile_agent-0.3.0.dist-info/entry_points.txt +3 -0
- makefile_agent-0.3.0.dist-info/licenses/LICENSE +21 -0
- makefile_agent-0.3.0.dist-info/top_level.txt +1 -0
make_agent/__init__.py
ADDED
|
File without changes
|
make_agent/agent.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Interactive REPL agent loop for the make-agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, NamedTuple
|
|
10
|
+
|
|
11
|
+
import any_llm
|
|
12
|
+
|
|
13
|
+
from make_agent.app_dirs import default_agents_dir
|
|
14
|
+
from make_agent.builtin_tools import BUILTIN_SCHEMAS, get_builtin_tools, get_memory_schemas
|
|
15
|
+
from make_agent.memory import Memory
|
|
16
|
+
from make_agent.parser import parse_file, validate_or_raise
|
|
17
|
+
from make_agent.tools import build_tools, format_tool_result, run_tool
|
|
18
|
+
|
|
19
|
+
_DEFAULT_MODEL = "anthropic/claude-haiku-4-5-20251001"
|
|
20
|
+
_DEFAULT_MAX_RETRIES = 5
|
|
21
|
+
_DEFAULT_TOOL_TIMEOUT = 600 # seconds
|
|
22
|
+
_DEFAULT_MAX_TOOL_OUTPUT = 20000 # characters; 0 = unlimited
|
|
23
|
+
_DEFAULT_MAX_TOKENS = 4096
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AgentConfig(NamedTuple):
|
|
29
|
+
makefile_path: Path
|
|
30
|
+
model: str = _DEFAULT_MODEL
|
|
31
|
+
max_retries: int = _DEFAULT_MAX_RETRIES
|
|
32
|
+
tool_timeout: int = _DEFAULT_TOOL_TIMEOUT
|
|
33
|
+
max_tool_output: int = _DEFAULT_MAX_TOOL_OUTPUT
|
|
34
|
+
max_tokens: int = _DEFAULT_MAX_TOKENS
|
|
35
|
+
agents_dir: str | None = None
|
|
36
|
+
debug: bool = False
|
|
37
|
+
memory: Memory | None = None
|
|
38
|
+
disabled_builtin_tools: frozenset[str] = frozenset()
|
|
39
|
+
agent_model: str | None = None # model used by run_agent; falls back to model
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_retry_after(e: any_llm.RateLimitError) -> float | None:
|
|
43
|
+
"""Return the wait time in seconds from a RateLimitError's response headers.
|
|
44
|
+
|
|
45
|
+
Checks ``retry-after-ms`` (milliseconds) then ``retry-after`` (seconds).
|
|
46
|
+
Returns ``None`` when neither header is present.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
orig = e.original_exception
|
|
50
|
+
headers = orig.response.headers if orig is not None and hasattr(orig, "response") and orig.response is not None else {}
|
|
51
|
+
except Exception:
|
|
52
|
+
return None
|
|
53
|
+
if ms := headers.get("retry-after-ms"):
|
|
54
|
+
return float(ms) / 1000
|
|
55
|
+
if sec := headers.get("retry-after"):
|
|
56
|
+
return float(sec)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _completion_with_retry(
|
|
61
|
+
model: str,
|
|
62
|
+
messages: list[dict],
|
|
63
|
+
tool_kwargs: dict[str, Any],
|
|
64
|
+
max_retries: int,
|
|
65
|
+
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
|
66
|
+
) -> Any:
|
|
67
|
+
"""Call ``any_llm.completion``, retrying on rate limit up to *max_retries* times.
|
|
68
|
+
|
|
69
|
+
On each ``RateLimitError`` the wait time is read from the ``Retry-After``
|
|
70
|
+
response header when present, otherwise exponential backoff is used
|
|
71
|
+
(``2^attempt`` seconds, capped at 60 s). A message is printed before
|
|
72
|
+
each retry so the user can see what is happening.
|
|
73
|
+
"""
|
|
74
|
+
for attempt in range(max_retries + 1):
|
|
75
|
+
try:
|
|
76
|
+
return any_llm.completion(model=model, messages=messages, max_tokens=max_tokens, **tool_kwargs)
|
|
77
|
+
except any_llm.RateLimitError as e:
|
|
78
|
+
if attempt == max_retries:
|
|
79
|
+
raise
|
|
80
|
+
wait = _parse_retry_after(e) or min(2**attempt, 60)
|
|
81
|
+
print(
|
|
82
|
+
f"Rate limited, retrying in {wait:.0f}s" f" (attempt {attempt + 1}/{max_retries})...",
|
|
83
|
+
flush=True,
|
|
84
|
+
)
|
|
85
|
+
time.sleep(wait)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Agent:
|
|
89
|
+
"""LLM agent that maintains conversation history and dispatches tool calls.
|
|
90
|
+
|
|
91
|
+
Call the instance with a user message to get the assistant's reply::
|
|
92
|
+
|
|
93
|
+
agent = Agent(Path("Makefile"), model="anthropic/claude-haiku-4-5-20251001")
|
|
94
|
+
reply = agent("List the files in the current directory.")
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def __init__(self, config: AgentConfig) -> None:
|
|
98
|
+
mf = parse_file(config.makefile_path)
|
|
99
|
+
validate_or_raise(mf)
|
|
100
|
+
self._model = config.model
|
|
101
|
+
self._makefile_path = config.makefile_path
|
|
102
|
+
self._max_retries = config.max_retries
|
|
103
|
+
self._max_tokens = config.max_tokens
|
|
104
|
+
self._tool_timeout = config.tool_timeout
|
|
105
|
+
self._max_tool_output = config.max_tool_output
|
|
106
|
+
self._memory = config.memory
|
|
107
|
+
agents_dir = config.agents_dir if config.agents_dir is not None else default_agents_dir()
|
|
108
|
+
self._builtins = get_builtin_tools(agents_dir, config.agent_model or config.model, config.debug, config.memory, config.disabled_builtin_tools, config.tool_timeout)
|
|
109
|
+
makefile_tools = build_tools(mf)
|
|
110
|
+
memory_schemas = get_memory_schemas() if config.memory is not None else []
|
|
111
|
+
active_builtin_schemas = [s for s in BUILTIN_SCHEMAS if s["function"]["name"] not in config.disabled_builtin_tools]
|
|
112
|
+
active_memory_schemas = [s for s in memory_schemas if s["function"]["name"] not in config.disabled_builtin_tools]
|
|
113
|
+
self._tools = active_builtin_schemas + active_memory_schemas + makefile_tools
|
|
114
|
+
self._tool_kwargs: dict = {"tools": self._tools, "tool_choice": "auto"} if self._tools else {}
|
|
115
|
+
self._messages: list[dict] = []
|
|
116
|
+
if mf.system_prompt:
|
|
117
|
+
self._messages.append({"role": "system", "content": mf.system_prompt})
|
|
118
|
+
logger.debug("[system]\n%s", mf.system_prompt)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def tool_names(self) -> list[str]:
|
|
122
|
+
return [t["function"]["name"] for t in self._tools]
|
|
123
|
+
|
|
124
|
+
def __call__(self, user_input: str) -> str:
|
|
125
|
+
"""Send *user_input* to the LLM and return the assistant's reply.
|
|
126
|
+
|
|
127
|
+
Dispatches tool calls in a loop until the model returns a plain
|
|
128
|
+
text response.
|
|
129
|
+
"""
|
|
130
|
+
self._messages.append({"role": "user", "content": user_input})
|
|
131
|
+
logger.debug("[user]\n%s", user_input)
|
|
132
|
+
if self._memory is not None:
|
|
133
|
+
self._memory.store("user", user_input)
|
|
134
|
+
|
|
135
|
+
while True:
|
|
136
|
+
response = _completion_with_retry(
|
|
137
|
+
self._model,
|
|
138
|
+
self._messages,
|
|
139
|
+
self._tool_kwargs,
|
|
140
|
+
self._max_retries,
|
|
141
|
+
self._max_tokens,
|
|
142
|
+
)
|
|
143
|
+
msg = response.choices[0].message
|
|
144
|
+
|
|
145
|
+
if msg.tool_calls:
|
|
146
|
+
self._messages.append(msg.model_dump(exclude_none=True))
|
|
147
|
+
|
|
148
|
+
for tc in msg.tool_calls:
|
|
149
|
+
target = tc.function.name
|
|
150
|
+
try:
|
|
151
|
+
arguments = json.loads(tc.function.arguments)
|
|
152
|
+
except json.JSONDecodeError as e:
|
|
153
|
+
output = format_tool_result("", f"malformed JSON arguments: {e}", None)
|
|
154
|
+
logger.debug("[tool_result] %s -> %s", target, output)
|
|
155
|
+
self._messages.append({"role": "tool", "tool_call_id": tc.id, "content": output})
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
logger.debug("[tool_call] %s args=%s", target, arguments)
|
|
159
|
+
try:
|
|
160
|
+
if target in self._builtins:
|
|
161
|
+
raw = self._builtins[target](**arguments)
|
|
162
|
+
output = format_tool_result(str(raw), "", 0, self._max_tool_output)
|
|
163
|
+
else:
|
|
164
|
+
output = run_tool(
|
|
165
|
+
target,
|
|
166
|
+
arguments,
|
|
167
|
+
self._makefile_path,
|
|
168
|
+
self._tool_timeout,
|
|
169
|
+
self._max_tool_output,
|
|
170
|
+
)
|
|
171
|
+
except TypeError as e:
|
|
172
|
+
output = format_tool_result("", f"argument type error: {e}", None)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
output = format_tool_result("", f"unexpected error: {e}", None)
|
|
175
|
+
logger.debug("[tool_result] %s -> %s", target, output)
|
|
176
|
+
|
|
177
|
+
self._messages.append(
|
|
178
|
+
{
|
|
179
|
+
"role": "tool",
|
|
180
|
+
"tool_call_id": tc.id,
|
|
181
|
+
"content": output,
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
content = msg.content or ""
|
|
186
|
+
self._messages.append({"role": "assistant", "content": content})
|
|
187
|
+
logger.debug("[assistant]\n%s", content)
|
|
188
|
+
if self._memory is not None:
|
|
189
|
+
self._memory.store("agent", content)
|
|
190
|
+
return content
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import cmd
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from make_agent.agent import _DEFAULT_MAX_RETRIES, _DEFAULT_MAX_TOKENS, _DEFAULT_MAX_TOOL_OUTPUT, _DEFAULT_MODEL, _DEFAULT_TOOL_TIMEOUT, Agent, AgentConfig
|
|
6
|
+
from make_agent.memory import Memory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MakeAgentShell(cmd.Cmd):
|
|
10
|
+
"""Interactive shell that delegates all LLM interaction to an :class:`Agent`."""
|
|
11
|
+
|
|
12
|
+
prompt = "make-agent> "
|
|
13
|
+
intro = ""
|
|
14
|
+
|
|
15
|
+
def __init__(self, agent: Agent) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._agent = agent
|
|
18
|
+
|
|
19
|
+
def default(self, line: str) -> None:
|
|
20
|
+
"""Send *line* to the agent and print the reply."""
|
|
21
|
+
try:
|
|
22
|
+
print(self._agent(line))
|
|
23
|
+
except Exception as e:
|
|
24
|
+
print(f"Error: {e}")
|
|
25
|
+
|
|
26
|
+
def emptyline(self) -> None:
|
|
27
|
+
"""Do nothing on an empty line (overrides cmd.Cmd's repeat-last-command)."""
|
|
28
|
+
|
|
29
|
+
def do_EOF(self, line: str) -> bool:
|
|
30
|
+
"""Exit on Ctrl-D."""
|
|
31
|
+
print()
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
def do_exit(self, line: str) -> bool:
|
|
35
|
+
"""Exit the shell."""
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
def do_quit(self, line: str) -> bool:
|
|
39
|
+
"""Exit the shell."""
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run(
|
|
44
|
+
makefile_path: Path,
|
|
45
|
+
model: str = _DEFAULT_MODEL,
|
|
46
|
+
prompt: Optional[str] = None,
|
|
47
|
+
debug: bool = False,
|
|
48
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
49
|
+
tool_timeout: int = _DEFAULT_TOOL_TIMEOUT,
|
|
50
|
+
max_tool_output: int = _DEFAULT_MAX_TOOL_OUTPUT,
|
|
51
|
+
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
|
52
|
+
agents_dir: str | None = None,
|
|
53
|
+
memory: Memory | None = None,
|
|
54
|
+
disabled_builtin_tools: frozenset[str] = frozenset(),
|
|
55
|
+
agent_model: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Start the interactive shell.
|
|
58
|
+
|
|
59
|
+
Reads the system prompt and tool definitions from *makefile_path*, then
|
|
60
|
+
enters a :class:`MakeAgentShell` loop. Press Ctrl-D, Ctrl-C, or type
|
|
61
|
+
``exit`` / ``quit`` to leave.
|
|
62
|
+
|
|
63
|
+
When *debug* is ``True`` all messages are logged to
|
|
64
|
+
``~/.make-agent/<project>/logs/make-agent.log``.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
agent_config = AgentConfig(
|
|
68
|
+
makefile_path=makefile_path,
|
|
69
|
+
model=model,
|
|
70
|
+
max_retries=max_retries,
|
|
71
|
+
tool_timeout=tool_timeout,
|
|
72
|
+
max_tool_output=max_tool_output,
|
|
73
|
+
max_tokens=max_tokens,
|
|
74
|
+
agents_dir=agents_dir,
|
|
75
|
+
debug=debug,
|
|
76
|
+
memory=memory,
|
|
77
|
+
disabled_builtin_tools=disabled_builtin_tools,
|
|
78
|
+
agent_model=agent_model,
|
|
79
|
+
)
|
|
80
|
+
agent = Agent(agent_config)
|
|
81
|
+
print(f"Loaded {makefile_path} | tools: {agent.tool_names}")
|
|
82
|
+
|
|
83
|
+
if prompt:
|
|
84
|
+
print("Sending initial prompt...\n")
|
|
85
|
+
print(agent(prompt))
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
print("Type your message. Press Ctrl-D or Ctrl-C to exit.\n")
|
|
89
|
+
shell = MakeAgentShell(agent)
|
|
90
|
+
try:
|
|
91
|
+
shell.cmdloop()
|
|
92
|
+
except KeyboardInterrupt:
|
|
93
|
+
print()
|
make_agent/app_dirs.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Centralised path helpers for ~/.make-agent project directories.
|
|
2
|
+
|
|
3
|
+
All app-related files live under a hidden directory in the user's home folder::
|
|
4
|
+
|
|
5
|
+
~/.make-agent/<project-slug>/agents/ # default agents directory
|
|
6
|
+
~/.make-agent/<project-slug>/logs/ # log files
|
|
7
|
+
|
|
8
|
+
The *project slug* is derived from the absolute working directory by stripping
|
|
9
|
+
the leading ``/`` and replacing every remaining ``/`` with ``_``.
|
|
10
|
+
|
|
11
|
+
Example: ``/Users/alice/proj/myapp`` → ``Users_alice_proj_myapp``
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_APP_HOME = Path.home() / ".make-agent"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def project_slug(cwd: str | None = None) -> str:
|
|
23
|
+
"""Return the project slug for *cwd* (defaults to ``os.getcwd()``)."""
|
|
24
|
+
path = cwd or os.getcwd()
|
|
25
|
+
return path.lstrip("/").replace("/", "_")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def project_dir(cwd: str | None = None) -> Path:
|
|
29
|
+
"""Return ``~/.make-agent/<slug>/``, creating it if necessary."""
|
|
30
|
+
directory = _APP_HOME / project_slug(cwd)
|
|
31
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
return directory
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def default_agents_dir(cwd: str | None = None) -> str:
|
|
36
|
+
"""Return ``~/.make-agent/<slug>/agents/`` as a string, creating it if necessary."""
|
|
37
|
+
directory = project_dir(cwd) / "agents"
|
|
38
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
return str(directory)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def log_file(cwd: str | None = None) -> str:
|
|
43
|
+
"""Return ``~/.make-agent/<slug>/logs/make-agent.log`` as a string, creating the logs dir if necessary."""
|
|
44
|
+
logs_dir = project_dir(cwd) / "logs"
|
|
45
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
return str(logs_dir / "make-agent.log")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def settings_file(cwd: str | None = None) -> Path:
|
|
50
|
+
"""Return ``~/.make-agent/<slug>/settings.yaml`` as a Path (does not create the file)."""
|
|
51
|
+
return project_dir(cwd) / "settings.yaml"
|