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 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"