makefile-agent 0.3.3__tar.gz → 0.3.5__tar.gz
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.
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/PKG-INFO +1 -1
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/agent.py +155 -40
- makefile_agent-0.3.5/make_agent/agent_shell.py +205 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/main.py +16 -13
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/memory.py +22 -1
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/tools.py +41 -17
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/PKG-INFO +1 -1
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/pyproject.toml +3 -1
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_agent.py +122 -88
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_main.py +1 -1
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_memory.py +45 -24
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_tools.py +30 -30
- makefile_agent-0.3.3/make_agent/agent_shell.py +0 -143
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/LICENSE +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/README.md +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/__init__.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/app_dirs.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/builtin_tools/__init__.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/builtin_tools/agent_tools.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/builtin_tools/memory_tools.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/commands.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/parser.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/settings.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/templates/orchestra.mk +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/SOURCES.txt +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/dependency_links.txt +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/entry_points.txt +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/requires.txt +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/top_level.txt +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/setup.cfg +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_app_dirs.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_builtin_tools.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_commands.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_e2e_smoke.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_parser.py +0 -0
- {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_settings.py +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
5
6
|
import time
|
|
7
|
+
from dataclasses import dataclass
|
|
6
8
|
from pathlib import Path
|
|
7
|
-
from typing import Any, NamedTuple
|
|
9
|
+
from typing import Any, AsyncGenerator, NamedTuple
|
|
8
10
|
from uuid import uuid4
|
|
9
11
|
|
|
10
12
|
import any_llm
|
|
@@ -34,6 +36,40 @@ _MAX_RUN_SECONDS_PER_REQUEST = 900
|
|
|
34
36
|
logger = logging.getLogger(__name__)
|
|
35
37
|
|
|
36
38
|
|
|
39
|
+
@dataclass
|
|
40
|
+
class TokenEvent:
|
|
41
|
+
"""A partial text token streamed from the LLM."""
|
|
42
|
+
|
|
43
|
+
text: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ToolStartEvent:
|
|
48
|
+
"""Emitted just before a tool call is executed."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
args: dict
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ToolDoneEvent:
|
|
56
|
+
"""Emitted after a tool call completes."""
|
|
57
|
+
|
|
58
|
+
name: str
|
|
59
|
+
output: str
|
|
60
|
+
is_error: bool
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class DoneEvent:
|
|
65
|
+
"""Emitted once the agent has a final text response (no more tool calls)."""
|
|
66
|
+
|
|
67
|
+
content: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
AgentEvent = TokenEvent | ToolStartEvent | ToolDoneEvent | DoneEvent
|
|
71
|
+
|
|
72
|
+
|
|
37
73
|
class AgentConfig(NamedTuple):
|
|
38
74
|
makefile_path: Path
|
|
39
75
|
model: str
|
|
@@ -66,7 +102,7 @@ def _parse_retry_after(e: any_llm.RateLimitError) -> float | None:
|
|
|
66
102
|
return None
|
|
67
103
|
|
|
68
104
|
|
|
69
|
-
def
|
|
105
|
+
async def _acompletion_with_retry(
|
|
70
106
|
model: str,
|
|
71
107
|
messages: list[dict],
|
|
72
108
|
tool_kwargs: dict[str, Any],
|
|
@@ -74,25 +110,36 @@ def _completion_with_retry(
|
|
|
74
110
|
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
|
75
111
|
reasoning_effort: str = _DEFAULT_REASONING_EFFORT,
|
|
76
112
|
) -> Any:
|
|
77
|
-
"""Call ``any_llm.
|
|
113
|
+
"""Call ``any_llm.acompletion`` with streaming, retrying on rate limit.
|
|
78
114
|
|
|
79
115
|
On each ``RateLimitError`` the wait time is read from the ``Retry-After``
|
|
80
116
|
response header when present, otherwise exponential backoff is used
|
|
81
117
|
(``2^attempt`` seconds, capped at 60 s). A message is printed before
|
|
82
118
|
each retry so the user can see what is happening.
|
|
119
|
+
|
|
120
|
+
Returns an ``AsyncIterator[ChatCompletionChunk]``.
|
|
83
121
|
"""
|
|
84
122
|
for attempt in range(max_retries + 1):
|
|
85
123
|
try:
|
|
86
|
-
return any_llm.
|
|
124
|
+
return await any_llm.acompletion(
|
|
125
|
+
model=model,
|
|
126
|
+
messages=messages,
|
|
127
|
+
max_tokens=max_tokens,
|
|
128
|
+
reasoning_effort=reasoning_effort,
|
|
129
|
+
stream=True,
|
|
130
|
+
stream_options={"include_usage": True},
|
|
131
|
+
**tool_kwargs,
|
|
132
|
+
)
|
|
87
133
|
except any_llm.RateLimitError as e:
|
|
88
134
|
if attempt == max_retries:
|
|
89
135
|
raise
|
|
90
136
|
wait = _parse_retry_after(e) or min(2**attempt, 60)
|
|
91
137
|
print(
|
|
92
|
-
f"Rate limited, retrying in {wait:.0f}s"
|
|
138
|
+
f"Rate limited, retrying in {wait:.0f}s"
|
|
139
|
+
f" (attempt {attempt + 1}/{max_retries})...",
|
|
93
140
|
flush=True,
|
|
94
141
|
)
|
|
95
|
-
|
|
142
|
+
await asyncio.sleep(wait)
|
|
96
143
|
|
|
97
144
|
|
|
98
145
|
def _parse_item(doc: Any) -> ChatCompletionMessageToolCall | None:
|
|
@@ -160,11 +207,12 @@ def _parse_disabled_builtins(value: str | None) -> frozenset[str]:
|
|
|
160
207
|
class Agent:
|
|
161
208
|
"""LLM agent that maintains conversation history and dispatches tool calls.
|
|
162
209
|
|
|
163
|
-
|
|
210
|
+
Await ``arun()`` with a user message to get the assistant's reply, or use
|
|
211
|
+
``astream()`` to receive events as they are produced::
|
|
164
212
|
|
|
165
|
-
config = AgentConfig(makefile_path=Path("Makefile"), model="anthropic/claude-haiku-4-5
|
|
213
|
+
config = AgentConfig(makefile_path=Path("Makefile"), model="anthropic/claude-haiku-4-5", session_id="example")
|
|
166
214
|
agent = Agent(config, memory=None)
|
|
167
|
-
reply = agent("List the files in the current directory.")
|
|
215
|
+
reply = await agent.arun("List the files in the current directory.")
|
|
168
216
|
"""
|
|
169
217
|
|
|
170
218
|
def __init__(self, config: AgentConfig, memory: Memory | None) -> None:
|
|
@@ -212,7 +260,7 @@ class Agent:
|
|
|
212
260
|
def model(self) -> str:
|
|
213
261
|
return self._model
|
|
214
262
|
|
|
215
|
-
def
|
|
263
|
+
async def _arun_agent(self, mk_path: Path, prompt: str) -> str:
|
|
216
264
|
"""Instantiate a specialist agent in-process and return its response."""
|
|
217
265
|
sub_disabled = self._disabled_builtin_tools | frozenset({"run_agent"})
|
|
218
266
|
logger.info("Instantiating sub-agent with model %s to run %s", self._agent_model, mk_path)
|
|
@@ -228,23 +276,23 @@ class Agent:
|
|
|
228
276
|
reasoning_effort=self._reasoning_effort,
|
|
229
277
|
session_id=self._session_id,
|
|
230
278
|
)
|
|
231
|
-
return Agent(sub_config, self._memory)(prompt)
|
|
279
|
+
return await Agent(sub_config, self._memory).arun(prompt)
|
|
232
280
|
|
|
233
281
|
def __repr__(self) -> str:
|
|
234
282
|
return f"Agent(model={self._model!r}, tools={self.tool_names!r})"
|
|
235
283
|
|
|
236
|
-
def
|
|
237
|
-
"""
|
|
284
|
+
async def astream(self, user_input: str) -> AsyncGenerator[AgentEvent, None]:
|
|
285
|
+
"""Stream events produced while processing *user_input*.
|
|
238
286
|
|
|
239
|
-
|
|
240
|
-
|
|
287
|
+
Yields :class:`TokenEvent` for each partial LLM token,
|
|
288
|
+
:class:`ToolStartEvent` / :class:`ToolDoneEvent` around each tool call,
|
|
289
|
+
and a final :class:`DoneEvent` when the agent is done.
|
|
241
290
|
"""
|
|
242
291
|
self._messages.append({"role": "user", "content": user_input})
|
|
243
292
|
logger.debug("[user]\n%s", user_input)
|
|
244
293
|
if self._memory is not None:
|
|
245
294
|
self._memory.store("user", user_input)
|
|
246
295
|
|
|
247
|
-
# Track consecutive identical failing tool calls to detect loops.
|
|
248
296
|
last_fail_key: str | None = None
|
|
249
297
|
consecutive_failures = 0
|
|
250
298
|
model_turns = 0
|
|
@@ -261,7 +309,7 @@ class Agent:
|
|
|
261
309
|
f"aborted: exceeded {_MAX_RUN_SECONDS_PER_REQUEST}s runtime in a single request"
|
|
262
310
|
)
|
|
263
311
|
|
|
264
|
-
|
|
312
|
+
stream = await _acompletion_with_retry(
|
|
265
313
|
self._model,
|
|
266
314
|
self._messages,
|
|
267
315
|
self._tool_kwargs,
|
|
@@ -270,23 +318,79 @@ class Agent:
|
|
|
270
318
|
self._reasoning_effort,
|
|
271
319
|
)
|
|
272
320
|
model_turns += 1
|
|
273
|
-
msg = response.choices[0].message
|
|
274
|
-
logger.debug("[model_response]\n%s", msg)
|
|
275
321
|
|
|
276
|
-
|
|
322
|
+
# Accumulate streaming response.
|
|
323
|
+
content_parts: list[str] = []
|
|
324
|
+
tool_call_acc: dict[int, dict] = {} # index → {id, name, arguments}
|
|
325
|
+
usage = None
|
|
326
|
+
|
|
327
|
+
async for chunk in stream:
|
|
328
|
+
if not chunk.choices:
|
|
329
|
+
if chunk.usage is not None:
|
|
330
|
+
usage = chunk.usage
|
|
331
|
+
continue
|
|
332
|
+
delta = chunk.choices[0].delta
|
|
333
|
+
if delta.content:
|
|
334
|
+
content_parts.append(delta.content)
|
|
335
|
+
yield TokenEvent(delta.content)
|
|
336
|
+
if delta.tool_calls:
|
|
337
|
+
for tc_delta in delta.tool_calls:
|
|
338
|
+
idx = tc_delta.index
|
|
339
|
+
if idx not in tool_call_acc:
|
|
340
|
+
tool_call_acc[idx] = {"id": tc_delta.id or "", "name": "", "arguments": ""}
|
|
341
|
+
if tc_delta.function:
|
|
342
|
+
tool_call_acc[idx]["name"] += tc_delta.function.name or ""
|
|
343
|
+
tool_call_acc[idx]["arguments"] += tc_delta.function.arguments or ""
|
|
344
|
+
if chunk.usage is not None:
|
|
345
|
+
usage = chunk.usage
|
|
346
|
+
|
|
347
|
+
content = "".join(content_parts)
|
|
348
|
+
logger.debug("[model_response] content=%r tool_calls=%d", content[:120], len(tool_call_acc))
|
|
349
|
+
|
|
350
|
+
if self._memory is not None and usage is not None:
|
|
277
351
|
self._memory.record_token_usage(
|
|
278
352
|
self._session_id or "",
|
|
279
353
|
self._makefile_path.name,
|
|
280
354
|
self._model,
|
|
281
|
-
|
|
282
|
-
|
|
355
|
+
usage.prompt_tokens,
|
|
356
|
+
usage.completion_tokens,
|
|
283
357
|
)
|
|
284
358
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
359
|
+
# Support models that embed tool calls as a JSON array in content.
|
|
360
|
+
content_tool_calls = None
|
|
361
|
+
if not tool_call_acc and content:
|
|
362
|
+
content_tool_calls = _parse_content_tool_calls(content)
|
|
363
|
+
|
|
364
|
+
if tool_call_acc or content_tool_calls:
|
|
365
|
+
if tool_call_acc:
|
|
366
|
+
sorted_tcs = [tool_call_acc[i] for i in sorted(tool_call_acc)]
|
|
367
|
+
assistant_msg: dict = {
|
|
368
|
+
"role": "assistant",
|
|
369
|
+
"content": content or None,
|
|
370
|
+
"tool_calls": [
|
|
371
|
+
{
|
|
372
|
+
"id": tc["id"],
|
|
373
|
+
"type": "function",
|
|
374
|
+
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
|
375
|
+
}
|
|
376
|
+
for tc in sorted_tcs
|
|
377
|
+
],
|
|
378
|
+
}
|
|
379
|
+
tool_calls_to_run = [
|
|
380
|
+
ChatCompletionMessageFunctionToolCall(
|
|
381
|
+
id=tc["id"],
|
|
382
|
+
type="function",
|
|
383
|
+
function=Function(name=tc["name"], arguments=tc["arguments"]),
|
|
384
|
+
)
|
|
385
|
+
for tc in sorted_tcs
|
|
386
|
+
]
|
|
387
|
+
else:
|
|
388
|
+
assistant_msg = {"role": "assistant", "content": content}
|
|
389
|
+
tool_calls_to_run = content_tool_calls # type: ignore[assignment]
|
|
390
|
+
|
|
391
|
+
self._messages.append(assistant_msg)
|
|
288
392
|
|
|
289
|
-
for tc in
|
|
393
|
+
for tc in tool_calls_to_run:
|
|
290
394
|
if tool_calls_executed >= _MAX_TOOL_CALLS_PER_REQUEST:
|
|
291
395
|
raise RuntimeError(
|
|
292
396
|
f"aborted: exceeded {_MAX_TOOL_CALLS_PER_REQUEST} tool calls in a single request"
|
|
@@ -302,6 +406,8 @@ class Agent:
|
|
|
302
406
|
continue
|
|
303
407
|
|
|
304
408
|
logger.debug("[tool_call] %s args=%s", target, arguments)
|
|
409
|
+
yield ToolStartEvent(name=target, args=arguments)
|
|
410
|
+
|
|
305
411
|
if target not in self._tool_name_set:
|
|
306
412
|
result = get_tool_result("", f"unknown tool: {target}", None)
|
|
307
413
|
else:
|
|
@@ -309,12 +415,12 @@ class Agent:
|
|
|
309
415
|
if target in self._builtins:
|
|
310
416
|
raw = self._builtins[target](**arguments)
|
|
311
417
|
if isinstance(raw, _RunAgent):
|
|
312
|
-
agent_result = self.
|
|
418
|
+
agent_result = await self._arun_agent(raw.mk_path, raw.prompt)
|
|
313
419
|
result = get_tool_result(agent_result, "", 0, self._max_tool_output)
|
|
314
420
|
else:
|
|
315
421
|
result = get_tool_result(str(raw), "", 0, self._max_tool_output)
|
|
316
422
|
else:
|
|
317
|
-
result = run_tool(
|
|
423
|
+
result = await run_tool(
|
|
318
424
|
target,
|
|
319
425
|
arguments,
|
|
320
426
|
self._makefile_path,
|
|
@@ -329,16 +435,10 @@ class Agent:
|
|
|
329
435
|
result = get_tool_result("", f"unexpected error: {e}", None)
|
|
330
436
|
|
|
331
437
|
logger.info("[tool_result] %s -> %s", target, result.output)
|
|
438
|
+
yield ToolDoneEvent(name=target, output=result.output, is_error=result.is_error)
|
|
332
439
|
|
|
333
|
-
self._messages.append(
|
|
334
|
-
{
|
|
335
|
-
"role": "tool",
|
|
336
|
-
"tool_call_id": tc.id,
|
|
337
|
-
"content": result.output,
|
|
338
|
-
}
|
|
339
|
-
)
|
|
440
|
+
self._messages.append({"role": "tool", "tool_call_id": tc.id, "content": result.output})
|
|
340
441
|
|
|
341
|
-
# Detect repeated identical failing tool calls.
|
|
342
442
|
call_key = f"{target}:{tc.function.arguments}"
|
|
343
443
|
if result.is_error and call_key == last_fail_key:
|
|
344
444
|
consecutive_failures += 1
|
|
@@ -361,12 +461,23 @@ class Agent:
|
|
|
361
461
|
last_fail_key = None
|
|
362
462
|
consecutive_failures = 0
|
|
363
463
|
else:
|
|
364
|
-
content = msg.content or ""
|
|
365
464
|
self._messages.append({"role": "assistant", "content": content})
|
|
366
465
|
logger.debug("[assistant]\n%s", content)
|
|
367
466
|
if self._memory is not None:
|
|
368
467
|
self._memory.store("agent", content)
|
|
369
|
-
|
|
468
|
+
yield DoneEvent(content=content)
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
async def arun(self, user_input: str) -> str:
|
|
472
|
+
"""Send *user_input* to the LLM and return the assistant's final reply.
|
|
473
|
+
|
|
474
|
+
Convenience wrapper around :meth:`astream` that discards intermediate
|
|
475
|
+
events and returns the final text.
|
|
476
|
+
"""
|
|
477
|
+
async for event in self.astream(user_input):
|
|
478
|
+
if isinstance(event, DoneEvent):
|
|
479
|
+
return event.content
|
|
480
|
+
return ""
|
|
370
481
|
|
|
371
482
|
|
|
372
483
|
class SessionNotFoundError(Exception):
|
|
@@ -400,9 +511,13 @@ class AgentManager:
|
|
|
400
511
|
except KeyError:
|
|
401
512
|
raise SessionNotFoundError(f"Session with id {session_id} not found.")
|
|
402
513
|
|
|
403
|
-
def
|
|
514
|
+
async def arun_agent(self, session_id: str, message: str) -> str:
|
|
515
|
+
agent = self.get_agent(session_id)
|
|
516
|
+
return await agent.arun(message)
|
|
517
|
+
|
|
518
|
+
def astream_agent(self, session_id: str, message: str) -> AsyncGenerator[AgentEvent, None]:
|
|
404
519
|
agent = self.get_agent(session_id)
|
|
405
|
-
return agent(message)
|
|
520
|
+
return agent.astream(message)
|
|
406
521
|
|
|
407
522
|
def export_conversation(self, session_id: str) -> Path | None:
|
|
408
523
|
agent = self.get_agent(session_id)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import readline
|
|
3
|
+
import signal
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from make_agent.agent import (
|
|
8
|
+
_DEFAULT_MAX_RETRIES,
|
|
9
|
+
_DEFAULT_MAX_TOKENS,
|
|
10
|
+
_DEFAULT_MAX_TOOL_OUTPUT,
|
|
11
|
+
_DEFAULT_REASONING_EFFORT,
|
|
12
|
+
_DEFAULT_TOOL_TIMEOUT,
|
|
13
|
+
AgentConfig,
|
|
14
|
+
AgentManager,
|
|
15
|
+
DoneEvent,
|
|
16
|
+
TokenEvent,
|
|
17
|
+
ToolDoneEvent,
|
|
18
|
+
ToolStartEvent,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MakeAgentShell:
|
|
23
|
+
"""Async interactive REPL that delegates all LLM interaction to an :class:`Agent`."""
|
|
24
|
+
|
|
25
|
+
prompt = "make-agent> "
|
|
26
|
+
|
|
27
|
+
def __init__(self, agent_manager: AgentManager, session_id: str) -> None:
|
|
28
|
+
self._agent_manager = agent_manager
|
|
29
|
+
self._session_id = session_id
|
|
30
|
+
self._commands: dict[str, Any] = {
|
|
31
|
+
"exit": self._cmd_exit,
|
|
32
|
+
"quit": self._cmd_exit,
|
|
33
|
+
"export": self._cmd_export,
|
|
34
|
+
"stats": self._cmd_stats,
|
|
35
|
+
"help": self._cmd_help,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# ── readline completion ────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def _setup_readline(self) -> None:
|
|
41
|
+
"""Configure readline so /cmd completions work."""
|
|
42
|
+
try:
|
|
43
|
+
readline.set_completer_delims(readline.get_completer_delims().replace("/", ""))
|
|
44
|
+
readline.set_completer(self._completer)
|
|
45
|
+
readline.parse_and_bind("tab: complete")
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
def _completer(self, text: str, state: int) -> str | None:
|
|
50
|
+
if not text.startswith("/"):
|
|
51
|
+
return None
|
|
52
|
+
cmd_text = text[1:]
|
|
53
|
+
matches = ["/" + name for name in self._commands if name.startswith(cmd_text)]
|
|
54
|
+
return matches[state] if state < len(matches) else None
|
|
55
|
+
|
|
56
|
+
# ── command handlers ───────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def _cmd_exit(self) -> bool:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def _cmd_export(self) -> bool:
|
|
62
|
+
path = self._agent_manager.export_conversation(self._session_id)
|
|
63
|
+
if path:
|
|
64
|
+
print(f"Conversation exported to {path}")
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def _cmd_stats(self) -> bool:
|
|
68
|
+
stats = self._agent_manager.get_token_stats(self._session_id)
|
|
69
|
+
if not stats:
|
|
70
|
+
print("No token usage stats available (memory not enabled or no LLM calls yet).")
|
|
71
|
+
return False
|
|
72
|
+
print(f"Token usage for session {self._session_id}:")
|
|
73
|
+
print(f" Model(s): {', '.join(stats['models'])}")
|
|
74
|
+
print(f" Input tokens: {stats['input_tokens']}")
|
|
75
|
+
print(f" Output tokens: {stats['output_tokens']}")
|
|
76
|
+
print(f" Total tokens: {stats['total_tokens']}")
|
|
77
|
+
|
|
78
|
+
# Per-agent breakdown
|
|
79
|
+
agents = stats.get("agents", {})
|
|
80
|
+
if agents:
|
|
81
|
+
print("\nPer-agent breakdown:")
|
|
82
|
+
for agent_name, agent_stats in sorted(agents.items()):
|
|
83
|
+
print(f" {agent_name}:")
|
|
84
|
+
print(f" Input: {agent_stats['input_tokens']}")
|
|
85
|
+
print(f" Output: {agent_stats['output_tokens']}")
|
|
86
|
+
print(f" Total: {agent_stats['total_tokens']}")
|
|
87
|
+
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
def _cmd_help(self) -> bool:
|
|
91
|
+
print("Commands: " + " ".join(f"/{name}" for name in self._commands))
|
|
92
|
+
print("Any other input is sent to the agent. Press Ctrl-C to cancel a running turn.")
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
def _dispatch_command(self, line: str) -> bool:
|
|
96
|
+
"""Dispatch a /command. Returns True if the shell should exit."""
|
|
97
|
+
name, *_ = line.strip().split(None, 1)
|
|
98
|
+
handler = self._commands.get(name)
|
|
99
|
+
if handler is None:
|
|
100
|
+
print(f"Unknown command: /{name} (type /help for a list)")
|
|
101
|
+
return False
|
|
102
|
+
return handler()
|
|
103
|
+
|
|
104
|
+
# ── agent turn ─────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
async def _stream_turn(self, message: str) -> None:
|
|
107
|
+
"""Stream one agent turn, printing events as they arrive."""
|
|
108
|
+
async for event in self._agent_manager.astream_agent(self._session_id, message):
|
|
109
|
+
if isinstance(event, TokenEvent):
|
|
110
|
+
print(event.text, end="", flush=True)
|
|
111
|
+
elif isinstance(event, ToolStartEvent):
|
|
112
|
+
print(f"\nRunning: {event.name}...", flush=True)
|
|
113
|
+
elif isinstance(event, ToolDoneEvent):
|
|
114
|
+
pass # tool output visible via agent logs; keep terminal clean
|
|
115
|
+
elif isinstance(event, DoneEvent):
|
|
116
|
+
print() # trailing newline after streamed content
|
|
117
|
+
|
|
118
|
+
async def _run_turn(self, message: str) -> None:
|
|
119
|
+
"""Run one agent turn with per-turn Ctrl-C cancellation."""
|
|
120
|
+
task = asyncio.create_task(self._stream_turn(message))
|
|
121
|
+
loop = asyncio.get_running_loop()
|
|
122
|
+
loop.add_signal_handler(signal.SIGINT, task.cancel)
|
|
123
|
+
try:
|
|
124
|
+
await task
|
|
125
|
+
except asyncio.CancelledError:
|
|
126
|
+
print("\nCancelled.")
|
|
127
|
+
except Exception as e:
|
|
128
|
+
print(f"Error: {e}")
|
|
129
|
+
finally:
|
|
130
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
131
|
+
|
|
132
|
+
# ── main loop ──────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
async def run(self) -> None:
|
|
135
|
+
"""Start the interactive REPL loop."""
|
|
136
|
+
self._setup_readline()
|
|
137
|
+
loop = asyncio.get_running_loop()
|
|
138
|
+
print(
|
|
139
|
+
"Type your message. Prefix shell commands with / "
|
|
140
|
+
"(e.g. /exit, /help). Press Ctrl-D or Ctrl-C twice to exit.\n"
|
|
141
|
+
)
|
|
142
|
+
while True:
|
|
143
|
+
try:
|
|
144
|
+
line = await loop.run_in_executor(None, input, self.prompt)
|
|
145
|
+
except EOFError:
|
|
146
|
+
print()
|
|
147
|
+
break
|
|
148
|
+
line = line.strip()
|
|
149
|
+
if not line:
|
|
150
|
+
continue
|
|
151
|
+
if line.startswith("/"):
|
|
152
|
+
should_exit = self._dispatch_command(line[1:])
|
|
153
|
+
if should_exit:
|
|
154
|
+
break
|
|
155
|
+
continue
|
|
156
|
+
await self._run_turn(line)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def run(
|
|
160
|
+
makefile_path: Path,
|
|
161
|
+
model: str,
|
|
162
|
+
agent_model: Optional[str] = None,
|
|
163
|
+
prompt: Optional[str] = None,
|
|
164
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
165
|
+
tool_timeout: int = _DEFAULT_TOOL_TIMEOUT,
|
|
166
|
+
max_tool_output: int = _DEFAULT_MAX_TOOL_OUTPUT,
|
|
167
|
+
max_tokens: int = _DEFAULT_MAX_TOKENS,
|
|
168
|
+
agents_dir: str | None = None,
|
|
169
|
+
with_memory: bool = False,
|
|
170
|
+
disabled_builtin_tools: frozenset[str] = frozenset(),
|
|
171
|
+
reasoning_effort: str = _DEFAULT_REASONING_EFFORT,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Start the interactive shell (or send a single prompt and return).
|
|
174
|
+
|
|
175
|
+
Reads the system prompt and tool definitions from *makefile_path*, then
|
|
176
|
+
enters a :class:`MakeAgentShell` loop. Press Ctrl-D or type ``/exit``
|
|
177
|
+
to leave. When *prompt* is given the shell is bypassed: the prompt is
|
|
178
|
+
sent to the agent and the reply is printed.
|
|
179
|
+
"""
|
|
180
|
+
agent_config = AgentConfig(
|
|
181
|
+
makefile_path=makefile_path,
|
|
182
|
+
model=model,
|
|
183
|
+
agent_model=agent_model,
|
|
184
|
+
max_retries=max_retries,
|
|
185
|
+
tool_timeout=tool_timeout,
|
|
186
|
+
max_tool_output=max_tool_output,
|
|
187
|
+
max_tokens=max_tokens,
|
|
188
|
+
agents_dir=agents_dir,
|
|
189
|
+
disabled_builtin_tools=disabled_builtin_tools,
|
|
190
|
+
reasoning_effort=reasoning_effort,
|
|
191
|
+
)
|
|
192
|
+
agent_manager = AgentManager()
|
|
193
|
+
session_id = agent_manager.create_session(agent_config, with_memory=with_memory)
|
|
194
|
+
print(f"Loaded {makefile_path}")
|
|
195
|
+
|
|
196
|
+
if prompt:
|
|
197
|
+
print("Sending initial prompt...\n")
|
|
198
|
+
print(await agent_manager.arun_agent(session_id, prompt))
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
shell = MakeAgentShell(agent_manager, session_id)
|
|
202
|
+
try:
|
|
203
|
+
await shell.run()
|
|
204
|
+
except KeyboardInterrupt:
|
|
205
|
+
print()
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""make-agent: an AI agent driven by a Makefile."""
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import asyncio
|
|
4
5
|
import logging
|
|
5
6
|
import sys
|
|
6
7
|
from pathlib import Path
|
|
@@ -119,19 +120,21 @@ def _cmd_run(args: argparse.Namespace) -> None:
|
|
|
119
120
|
except OSError as e:
|
|
120
121
|
sys.exit(f"make-agent run: {e}")
|
|
121
122
|
|
|
122
|
-
run(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
123
|
+
asyncio.run(
|
|
124
|
+
run(
|
|
125
|
+
makefile_path=Path(args.file),
|
|
126
|
+
model=args.model,
|
|
127
|
+
agent_model=args.agent_model if args.agent_model is not None else args.model,
|
|
128
|
+
prompt=prompt,
|
|
129
|
+
max_retries=args.max_retries,
|
|
130
|
+
tool_timeout=args.tool_timeout,
|
|
131
|
+
max_tool_output=args.max_tool_output,
|
|
132
|
+
max_tokens=args.max_tokens,
|
|
133
|
+
agents_dir=args.agents_dir,
|
|
134
|
+
with_memory=args.with_memory,
|
|
135
|
+
disabled_builtin_tools=_parse_disabled_tools(args.disable_builtin_tools),
|
|
136
|
+
reasoning_effort=args.reasoning_effort,
|
|
137
|
+
)
|
|
135
138
|
)
|
|
136
139
|
|
|
137
140
|
|
|
@@ -196,7 +196,8 @@ class Memory:
|
|
|
196
196
|
"""Return aggregated token usage totals for *session_id*.
|
|
197
197
|
|
|
198
198
|
Returns a dict with keys ``input_tokens``, ``output_tokens``,
|
|
199
|
-
``total_tokens``,
|
|
199
|
+
``total_tokens``, ``models`` (list of distinct model names used),
|
|
200
|
+
and ``agents`` (dict mapping agent name to per-agent stats),
|
|
200
201
|
or an empty dict when no rows exist for that session.
|
|
201
202
|
"""
|
|
202
203
|
conn = self._get_conn()
|
|
@@ -214,11 +215,31 @@ class Memory:
|
|
|
214
215
|
(session_id,),
|
|
215
216
|
).fetchall()
|
|
216
217
|
]
|
|
218
|
+
|
|
219
|
+
# Per-agent breakdown
|
|
220
|
+
agent_rows = conn.execute(
|
|
221
|
+
"SELECT agent, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens"
|
|
222
|
+
" FROM token_usage WHERE session_id = ? GROUP BY agent ORDER BY agent",
|
|
223
|
+
(session_id,),
|
|
224
|
+
).fetchall()
|
|
225
|
+
|
|
226
|
+
agents = {}
|
|
227
|
+
for arow in agent_rows:
|
|
228
|
+
agent_name = arow["agent"]
|
|
229
|
+
input_tok = arow["input_tokens"] or 0
|
|
230
|
+
output_tok = arow["output_tokens"] or 0
|
|
231
|
+
agents[agent_name] = {
|
|
232
|
+
"input_tokens": input_tok,
|
|
233
|
+
"output_tokens": output_tok,
|
|
234
|
+
"total_tokens": input_tok + output_tok,
|
|
235
|
+
}
|
|
236
|
+
|
|
217
237
|
return {
|
|
218
238
|
"input_tokens": row["input_tokens"],
|
|
219
239
|
"output_tokens": row["output_tokens"],
|
|
220
240
|
"total_tokens": row["input_tokens"] + row["output_tokens"],
|
|
221
241
|
"models": models,
|
|
242
|
+
"agents": agents,
|
|
222
243
|
}
|
|
223
244
|
|
|
224
245
|
def close(self) -> None:
|