agentropic 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentropic
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A minimal, hackable agentic framework: LLM calls via litellm, @tool-decorated functions, and MCP server support.
5
5
  Author-email: Vishal Tiwari <vishaltiwari.up2019@gmail.com>
6
6
  License: MIT
@@ -31,7 +31,7 @@ async def main():
31
31
  tools=[get_weather, add],
32
32
  # Optional: pull in tools from a live MCP server too.
33
33
  # mcp_servers=[StdioServer(command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "."])],
34
- max_iterations=6,
34
+ max_iterations=2,
35
35
  )
36
36
 
37
37
  result = await agent.arun("What's the weather in Tokyo, and what's 12 + 30?")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agentropic"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "A minimal, hackable agentic framework: LLM calls via litellm, @tool-decorated functions, and MCP server support."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -3,6 +3,7 @@ from .tools import tool, Tool, ToolRegistry
3
3
  from .llm import LLMClient, LLMResponse
4
4
  from .memory import Memory
5
5
  from .mcp.client import MCPConnection, StdioServer, SSEServer, MCPServerConfig
6
+ from .tracing import AgentTracer, NullTracer
6
7
 
7
8
  __version__ = "0.2.0"
8
9
 
@@ -21,4 +22,6 @@ __all__ = [
21
22
  "StdioServer",
22
23
  "SSEServer",
23
24
  "MCPServerConfig",
25
+ "AgentTracer",
26
+ "NullTracer",
24
27
  ]
@@ -3,6 +3,11 @@ optional MCP servers into a single run()/arun() loop that:
3
3
  1. sends messages + tool schemas to the LLM
4
4
  2. if the LLM asks for tool calls, executes them and feeds results back
5
5
  3. repeats until the LLM returns a final answer or max_iterations hits
6
+
7
+ Set `verbose=True` (default) to get CrewAI-style colored terminal traces
8
+ of every step — LLM calls, tool calls/results, final answer — via the
9
+ zero-dependency `tracing.AgentTracer`. Pass `verbose=False` to silence it,
10
+ or `tracer=YourTracer(...)` to plug in your own sink.
6
11
  """
7
12
  from __future__ import annotations
8
13
 
@@ -15,6 +20,7 @@ from .llm import LLMClient, LLMResponse
15
20
  from .memory import Memory
16
21
  from .tools import Tool, ToolRegistry
17
22
  from .mcp.client import MCPConnection, MCPServerConfig
23
+ from .tracing import AgentTracer, NullTracer
18
24
 
19
25
 
20
26
  class AgentError(Exception):
@@ -120,6 +126,8 @@ class Agent:
120
126
  api_key: t.Optional[str] = None,
121
127
  api_base: t.Optional[str] = None,
122
128
  temperature: float = 0.7,
129
+ verbose: bool = True,
130
+ tracer: t.Optional[AgentTracer] = None,
123
131
  **llm_kwargs,
124
132
  ):
125
133
  self.name = name
@@ -137,6 +145,12 @@ class Agent:
137
145
  )
138
146
  self._mcp_tools_loaded = False
139
147
 
148
+ # -- tracing / pretty terminal logs ----------------------------
149
+ # `tracer` wins if explicitly supplied; otherwise `verbose` picks
150
+ # between the real colorful tracer and a silent no-op one.
151
+ self.verbose = verbose
152
+ self.tracer = tracer or (AgentTracer(name) if verbose else NullTracer(name))
153
+
140
154
  # -- tool management -------------------------------------------------
141
155
 
142
156
  def add_tool(self, item: t.Union[Tool, t.Callable]) -> Tool:
@@ -170,6 +184,8 @@ class Agent:
170
184
  error = False
171
185
  started = time.monotonic()
172
186
 
187
+ self.tracer.tool_call_start(name, args)
188
+
173
189
  if tl is None:
174
190
  result = f"Error: tool '{name}' is not registered."
175
191
  error = True
@@ -181,6 +197,7 @@ class Agent:
181
197
  error = True
182
198
 
183
199
  duration = time.monotonic() - started
200
+ self.tracer.tool_call_end(name, str(result), duration, error)
184
201
 
185
202
  tool_msg = {
186
203
  "role": "tool",
@@ -208,6 +225,8 @@ class Agent:
208
225
 
209
226
  async def arun(self, user_input: str) -> AgentRunResult:
210
227
  run_started = time.monotonic()
228
+ self.tracer.run_start(user_input)
229
+
211
230
  await self._load_mcp_tools()
212
231
 
213
232
  messages = self._build_messages(user_input)
@@ -219,9 +238,18 @@ class Agent:
219
238
  raw_responses: list[LLMResponse] = []
220
239
 
221
240
  for iteration in range(1, self.max_iterations + 1):
241
+ self.tracer.iteration_start(iteration, self.max_iterations)
242
+ llm_started = time.monotonic()
222
243
  response = await self.llm.acomplete(messages, tools=tools_schema)
244
+ llm_duration = time.monotonic() - llm_started
223
245
  raw_responses.append(response)
224
246
 
247
+ self.tracer.llm_response(
248
+ response.content or "",
249
+ [tc.function.name for tc in (response.tool_calls or [])],
250
+ llm_duration,
251
+ )
252
+
225
253
  if response.tool_calls:
226
254
  assistant_msg = {
227
255
  "role": "assistant",
@@ -242,7 +270,7 @@ class Agent:
242
270
 
243
271
  # No tool calls -> final answer
244
272
  self.memory.add("assistant", response.content or "")
245
- return AgentRunResult(
273
+ result = AgentRunResult(
246
274
  output=response.content or "",
247
275
  messages=messages,
248
276
  tool_calls=all_tool_calls,
@@ -252,6 +280,11 @@ class Agent:
252
280
  stopped_on_max_iterations=False,
253
281
  duration_seconds=time.monotonic() - run_started,
254
282
  )
283
+ self.tracer.final_answer(
284
+ result.output, result.iterations, len(result.tool_calls),
285
+ result.duration_seconds, result.usage,
286
+ )
287
+ return result
255
288
 
256
289
  # Hit max_iterations without a final answer. Return what we have
257
290
  # instead of only raising, so the caller can inspect what happened —
@@ -267,6 +300,7 @@ class Agent:
267
300
  stopped_on_max_iterations=True,
268
301
  duration_seconds=time.monotonic() - run_started,
269
302
  )
303
+ self.tracer.max_iterations_error(self.max_iterations)
270
304
  error = AgentError(
271
305
  f"Agent '{self.name}' hit max_iterations ({self.max_iterations}) without a final answer."
272
306
  )
@@ -288,10 +322,12 @@ class Agent:
288
322
  yield result.output
289
323
  return
290
324
 
325
+ self.tracer.run_start(user_input)
291
326
  messages = self._build_messages(user_input)
292
327
  self.memory.add("user", user_input)
293
328
  full = ""
294
329
  async for chunk in self.llm.astream(messages):
295
330
  full += chunk
296
331
  yield chunk
297
- self.memory.add("assistant", full)
332
+ self.memory.add("assistant", full)
333
+ self.tracer.final_answer(full, 1, 0, 0.0, {})
@@ -0,0 +1,269 @@
1
+ """Colorful terminal tracing for Agent.run()/arun(), CrewAI-style.
2
+
3
+ Zero dependencies (pure ANSI escape codes) so it works in any terminal
4
+ without needing `rich` or similar installed. Wraps every step of the
5
+ agent loop — LLM calls, tool calls, tool results, final answer — in
6
+ boxed, colored, timestamped output.
7
+
8
+ Usage:
9
+ agent = Agent(..., verbose=True) # on by default
10
+ agent = Agent(..., verbose=False) # silence it
11
+ agent = Agent(..., tracer=MyTracer(...)) # bring your own sink
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import sys
17
+ import textwrap
18
+ import time
19
+ import typing as t
20
+
21
+
22
+ # --------------------------------------------------------------------------
23
+ # Low-level ANSI helpers
24
+ # --------------------------------------------------------------------------
25
+
26
+ class C:
27
+ RESET = "\033[0m"
28
+ BOLD = "\033[1m"
29
+ DIM = "\033[2m"
30
+ ITALIC = "\033[3m"
31
+
32
+ CYAN = "\033[36m"
33
+ MAGENTA = "\033[35m"
34
+ YELLOW = "\033[33m"
35
+ GREEN = "\033[32m"
36
+ RED = "\033[31m"
37
+ BLUE = "\033[34m"
38
+ WHITE = "\033[97m"
39
+ GREY = "\033[90m"
40
+
41
+ BG_CYAN = "\033[46m"
42
+
43
+
44
+ def _supports_color() -> bool:
45
+ """Disable colors when piped/redirected (mirrors how most CLI tools behave)."""
46
+ return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
47
+
48
+
49
+ def _term_width(default: int = 88) -> int:
50
+ try:
51
+ return min(shutil.get_terminal_size().columns, 120)
52
+ except Exception:
53
+ return default
54
+
55
+
56
+ def _wrap(text: str, width: int, indent: str = "") -> list[str]:
57
+ out: list[str] = []
58
+ for line in (text or "").splitlines() or [""]:
59
+ wrapped = textwrap.wrap(
60
+ line, width=width, subsequent_indent=indent, initial_indent=indent
61
+ ) or [indent]
62
+ out.extend(wrapped)
63
+ return out
64
+
65
+
66
+ def _truncate(text: str, n: int) -> str:
67
+ text = text or ""
68
+ return text if len(text) <= n else text[: n - 1].rstrip() + "…"
69
+
70
+
71
+ def _display_width(text: str) -> int:
72
+ """Approximate terminal column width, counting emoji/wide symbols as 2
73
+ columns (most terminals render them that way even though len() sees 1
74
+ codepoint). Good enough for box alignment without a wcwidth dependency."""
75
+ width = 0
76
+ for ch in text:
77
+ cp = ord(ch)
78
+ if (
79
+ 0x1F300 <= cp <= 0x1FAFF # emoji blocks
80
+ or 0x2600 <= cp <= 0x27BF # misc symbols / dingbats (✅ ⚠ etc.)
81
+ or cp in (0x2705, 0x274C, 0x2714, 0x2716)
82
+ ):
83
+ width += 2
84
+ else:
85
+ width += 1
86
+ return width
87
+
88
+
89
+ # --------------------------------------------------------------------------
90
+ # Tracer
91
+ # --------------------------------------------------------------------------
92
+
93
+ # Module-level so the welcome banner shows exactly once per process,
94
+ # no matter how many Agent/AgentTracer instances get created.
95
+ _WELCOME_SHOWN = False
96
+
97
+
98
+ class AgentTracer:
99
+ """Renders one Agent run as a live, colorized trace in the terminal.
100
+
101
+ Every method is a no-op if `enabled=False`, so it's cheap to leave
102
+ wired in and toggle via a single flag.
103
+ """
104
+
105
+ def __init__(self, agent_name: str, enabled: bool = True, stream: t.TextIO = sys.stdout):
106
+ self.agent_name = agent_name
107
+ self.enabled = enabled
108
+ self.stream = stream
109
+ self._color = enabled and _supports_color()
110
+ self._run_started: t.Optional[float] = None
111
+ self._iter_started: t.Optional[float] = None
112
+
113
+ # -- internals --------------------------------------------------------
114
+
115
+ def _c(self, code: str) -> str:
116
+ return code if self._color else ""
117
+
118
+ def _print(self, text: str = "") -> None:
119
+ if not self.enabled:
120
+ return
121
+ print(text, file=self.stream, flush=True)
122
+
123
+ def _rule(self, char: str = "─", color: str = "") -> str:
124
+ width = _term_width()
125
+ return f"{self._c(color)}{char * width}{self._c(C.RESET)}"
126
+
127
+ def _box(
128
+ self,
129
+ title: str,
130
+ lines: list[str],
131
+ color: str,
132
+ title_color: str = "",
133
+ center_title: bool = False,
134
+ center_lines: bool = False,
135
+ ) -> None:
136
+ width = _term_width()
137
+ inner = width - 4
138
+ title_color = title_color or color
139
+ top = f"{self._c(color)}╭{'─' * (width - 2)}╮{self._c(C.RESET)}"
140
+ bot = f"{self._c(color)}╰{'─' * (width - 2)}╯{self._c(C.RESET)}"
141
+
142
+ if center_title:
143
+ left_pad = max((inner - _display_width(title)) // 2, 0)
144
+ right_pad = max(inner - _display_width(title) - left_pad, 0)
145
+ else:
146
+ left_pad, right_pad = 0, max(inner - _display_width(title), 0)
147
+
148
+ head = (
149
+ f"{self._c(color)}│ {self._c(C.RESET)}{' ' * left_pad}"
150
+ f"{self._c(C.BOLD)}{self._c(title_color)}{title}{self._c(C.RESET)}"
151
+ f"{' ' * right_pad}{self._c(color)} │{self._c(C.RESET)}"
152
+ )
153
+ self._print(top)
154
+ self._print(head)
155
+ if lines:
156
+ self._print(f"{self._c(color)}├{'─' * (width - 2)}┤{self._c(C.RESET)}")
157
+ for raw in lines:
158
+ for wrapped in _wrap(raw, inner) or [""]:
159
+ w = _display_width(wrapped)
160
+ if center_lines:
161
+ lp = max((inner - w) // 2, 0)
162
+ rp = max(inner - w - lp, 0)
163
+ else:
164
+ lp, rp = 0, max(inner - w, 0)
165
+ self._print(
166
+ f"{self._c(color)}│ {self._c(C.RESET)}{' ' * lp}{wrapped}"
167
+ f"{' ' * rp}{self._c(color)} │{self._c(C.RESET)}"
168
+ )
169
+ self._print(bot)
170
+
171
+ # -- lifecycle events ---------------------------------------------------
172
+
173
+ def welcome_banner(self, force: bool = False) -> None:
174
+ """Prints a one-time 'Welcome to Agentropic' banner. Shown
175
+ automatically before the first run in a process; call with
176
+ force=True to show it again (e.g. at the very start of a script)."""
177
+ global _WELCOME_SHOWN
178
+ if _WELCOME_SHOWN and not force:
179
+ return
180
+ _WELCOME_SHOWN = True
181
+ self._print()
182
+ self._box(
183
+ "🚀 Welcome to Agentropic — Agentic AI System",
184
+ [f"{self._c(C.DIM)}Powered by your agents, tools, and memory.{self._c(C.RESET)}"],
185
+ color=C.MAGENTA,
186
+ center_title=True,
187
+ center_lines=True,
188
+ )
189
+
190
+ def run_start(self, user_input: str) -> None:
191
+ self.welcome_banner()
192
+ self._run_started = time.monotonic()
193
+ self._print()
194
+ self._box(
195
+ f"🤖 Agent: {self.agent_name}",
196
+ [f"{self._c(C.DIM)}Task ▸{self._c(C.RESET)} {_truncate(user_input, 400)}"],
197
+ color=C.CYAN,
198
+ )
199
+
200
+ def iteration_start(self, iteration: int, max_iterations: int) -> None:
201
+ self._iter_started = time.monotonic()
202
+ self._print(
203
+ f"\n{self._c(C.BOLD)}{self._c(C.BLUE)}▸ Iteration {iteration}/{max_iterations}"
204
+ f"{self._c(C.RESET)} {self._c(C.GREY)}— calling LLM…{self._c(C.RESET)}"
205
+ )
206
+
207
+ def llm_response(self, content: str, tool_call_names: list[str], duration: float) -> None:
208
+ dur = f"{duration:.2f}s"
209
+ if tool_call_names:
210
+ calls = ", ".join(tool_call_names)
211
+ self._print(
212
+ f" {self._c(C.GREY)}↳ response in {dur}{self._c(C.RESET)} "
213
+ f"{self._c(C.MAGENTA)}requests {len(tool_call_names)} tool call(s):{self._c(C.RESET)} "
214
+ f"{self._c(C.BOLD)}{calls}{self._c(C.RESET)}"
215
+ )
216
+ if content and content.strip():
217
+ self._print(f" {self._c(C.DIM)}💭 {_truncate(content.strip(), 200)}{self._c(C.RESET)}")
218
+ else:
219
+ self._print(f" {self._c(C.GREY)}↳ response in {dur} — final answer{self._c(C.RESET)}")
220
+
221
+ def tool_call_start(self, name: str, arguments: dict) -> None:
222
+ args_str = ", ".join(f"{k}={v!r}" for k, v in (arguments or {}).items())
223
+ self._print(
224
+ f" {self._c(C.YELLOW)}🔧 {self._c(C.BOLD)}{name}{self._c(C.RESET)}"
225
+ f"{self._c(C.GREY)}({_truncate(args_str, 140)}){self._c(C.RESET)}"
226
+ )
227
+
228
+ def tool_call_end(self, name: str, result: str, duration: float, error: bool) -> None:
229
+ icon = "❌" if error else "✅"
230
+ color = C.RED if error else C.GREEN
231
+ self._print(
232
+ f" {self._c(color)}{icon} {name} → {duration:.2f}s{self._c(C.RESET)} "
233
+ f"{self._c(C.DIM)}{_truncate(str(result).replace(chr(10), ' '), 160)}{self._c(C.RESET)}"
234
+ )
235
+
236
+ def final_answer(self, output: str, iterations: int, tool_call_count: int, duration: float, usage: dict) -> None:
237
+ stats = (
238
+ f"{self._c(C.DIM)}⏱ {duration:.2f}s 🔁 {iterations} iteration(s) "
239
+ f"🔧 {tool_call_count} tool call(s)"
240
+ )
241
+ if usage and usage.get("total_tokens"):
242
+ stats += f" 🎫 {usage['total_tokens']} tokens"
243
+ stats += self._c(C.RESET)
244
+ self._print()
245
+ self._box(
246
+ "✅ Final Answer",
247
+ [output, "", stats],
248
+ color=C.GREEN,
249
+ )
250
+ self._print()
251
+
252
+ def max_iterations_error(self, max_iterations: int) -> None:
253
+ self._print()
254
+ self._box(
255
+ "⚠️ Max iterations reached",
256
+ [f"Agent '{self.agent_name}' hit max_iterations ({max_iterations}) without a final answer."],
257
+ color=C.RED,
258
+ )
259
+ self._print()
260
+
261
+ def error(self, message: str) -> None:
262
+ self._print(f" {self._c(C.RED)}{self._c(C.BOLD)}✖ {message}{self._c(C.RESET)}")
263
+
264
+
265
+ class NullTracer(AgentTracer):
266
+ """Same interface, does nothing — used when verbose=False."""
267
+
268
+ def __init__(self, agent_name: str = ""):
269
+ super().__init__(agent_name, enabled=False)
File without changes
File without changes
File without changes