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.
Files changed (36) hide show
  1. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/PKG-INFO +1 -1
  2. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/agent.py +155 -40
  3. makefile_agent-0.3.5/make_agent/agent_shell.py +205 -0
  4. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/main.py +16 -13
  5. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/memory.py +22 -1
  6. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/tools.py +41 -17
  7. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/PKG-INFO +1 -1
  8. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/pyproject.toml +3 -1
  9. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_agent.py +122 -88
  10. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_main.py +1 -1
  11. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_memory.py +45 -24
  12. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_tools.py +30 -30
  13. makefile_agent-0.3.3/make_agent/agent_shell.py +0 -143
  14. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/LICENSE +0 -0
  15. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/README.md +0 -0
  16. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/__init__.py +0 -0
  17. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/app_dirs.py +0 -0
  18. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/builtin_tools/__init__.py +0 -0
  19. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/builtin_tools/agent_tools.py +0 -0
  20. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/builtin_tools/memory_tools.py +0 -0
  21. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/commands.py +0 -0
  22. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/parser.py +0 -0
  23. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/settings.py +0 -0
  24. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/make_agent/templates/orchestra.mk +0 -0
  25. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/SOURCES.txt +0 -0
  26. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/dependency_links.txt +0 -0
  27. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/entry_points.txt +0 -0
  28. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/requires.txt +0 -0
  29. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/makefile_agent.egg-info/top_level.txt +0 -0
  30. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/setup.cfg +0 -0
  31. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_app_dirs.py +0 -0
  32. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_builtin_tools.py +0 -0
  33. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_commands.py +0 -0
  34. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_e2e_smoke.py +0 -0
  35. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_parser.py +0 -0
  36. {makefile_agent-0.3.3 → makefile_agent-0.3.5}/tests/test_settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: makefile-agent
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: AI‑assistant‑as‑Makefile: a tool to create and manage AI agents using a Makefile.
5
5
  Author: Dmitriy Sorochenkov
6
6
  License-Expression: MIT
@@ -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 _completion_with_retry(
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.completion``, retrying on rate limit up to *max_retries* times.
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.completion(model=model, messages=messages, max_tokens=max_tokens, reasoning_effort=reasoning_effort, **tool_kwargs)
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" f" (attempt {attempt + 1}/{max_retries})...",
138
+ f"Rate limited, retrying in {wait:.0f}s"
139
+ f" (attempt {attempt + 1}/{max_retries})...",
93
140
  flush=True,
94
141
  )
95
- time.sleep(wait)
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
- Call the instance with a user message to get the assistant's reply::
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-20251001", session_id="example")
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 _run_agent(self, mk_path: Path, prompt: str) -> str:
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 __call__(self, user_input: str) -> str:
237
- """Send *user_input* to the LLM and return the assistant's reply.
284
+ async def astream(self, user_input: str) -> AsyncGenerator[AgentEvent, None]:
285
+ """Stream events produced while processing *user_input*.
238
286
 
239
- Dispatches tool calls in a loop until the model returns a plain
240
- text response.
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
- response = _completion_with_retry(
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
- if self._memory is not None and response.usage is not None:
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
- response.usage.prompt_tokens,
282
- response.usage.completion_tokens,
355
+ usage.prompt_tokens,
356
+ usage.completion_tokens,
283
357
  )
284
358
 
285
- tool_calls = msg.tool_calls or _parse_content_tool_calls(msg.content or "")
286
- if tool_calls:
287
- self._messages.append(msg.model_dump(exclude_none=True))
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 tool_calls:
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._run_agent(raw.mk_path, raw.prompt)
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
- return content
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 notify_agent(self, session_id: str, message: str) -> str:
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
- makefile_path=Path(args.file),
124
- model=args.model,
125
- agent_model=args.agent_model if args.agent_model is not None else args.model,
126
- prompt=prompt,
127
- max_retries=args.max_retries,
128
- tool_timeout=args.tool_timeout,
129
- max_tool_output=args.max_tool_output,
130
- max_tokens=args.max_tokens,
131
- agents_dir=args.agents_dir,
132
- with_memory=args.with_memory,
133
- disabled_builtin_tools=_parse_disabled_tools(args.disable_builtin_tools),
134
- reasoning_effort=args.reasoning_effort,
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``, and ``models`` (list of distinct model names used),
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: