axons 0.1.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.
- axon/__init__.py +6 -0
- axon/agent.py +436 -0
- axon/cli.py +361 -0
- axon/config.py +145 -0
- axon/llm.py +221 -0
- axon/mcp_server.py +78 -0
- axon/tools.py +895 -0
- axons-0.1.0.dist-info/METADATA +101 -0
- axons-0.1.0.dist-info/RECORD +12 -0
- axons-0.1.0.dist-info/WHEEL +5 -0
- axons-0.1.0.dist-info/entry_points.txt +2 -0
- axons-0.1.0.dist-info/top_level.txt +1 -0
axon/__init__.py
ADDED
axon/agent.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Agent components — memory manager, tool executor, verification, and orchestration loop."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
from rich import box
|
|
15
|
+
|
|
16
|
+
from axon.config import AxonConfig
|
|
17
|
+
from axon.llm import LLMMessage, ToolCall, create_provider, LLMProvider
|
|
18
|
+
from axon.tools import TOOL_DEFINITIONS, PLANNING_TOOL_DEFINITIONS, execute_tool, ToolResult
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
CONTEXT_WINDOW_KEEP = 20
|
|
23
|
+
MAX_IDENTICAL_RESULTS = 3
|
|
24
|
+
# Detect alternating-loop patterns over this many recent calls
|
|
25
|
+
LOOP_WINDOW = 8
|
|
26
|
+
LOOP_REPEAT_THRESHOLD = 3 # if any single signature appears this many times in the window
|
|
27
|
+
|
|
28
|
+
# Approval levels
|
|
29
|
+
MODE_AUTO = "auto" # auto-run all tools
|
|
30
|
+
MODE_NORMAL = "normal" # ask before write_file, edit_file, apply_patch
|
|
31
|
+
MODE_SAFE = "safe" # ask before write, edit, run_command, git_commit
|
|
32
|
+
|
|
33
|
+
# Which tools need approval per mode
|
|
34
|
+
RISKY_TOOLS = {"write_file", "apply_patch"}
|
|
35
|
+
VERY_RISKY_TOOLS = {"write_file", "apply_patch", "run_command", "git_commit"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ─── Memory Manager ──────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
class ConversationMemory:
|
|
41
|
+
"""Manages conversation history with safe compression.
|
|
42
|
+
|
|
43
|
+
The first user message (the original task) is pinned and survives all
|
|
44
|
+
compression so the model never forgets what it was asked to do.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, system_prompt: str, persist: bool = False):
|
|
48
|
+
self.messages: list[LLMMessage] = []
|
|
49
|
+
self.persist = persist
|
|
50
|
+
self._pinned_task: Optional[LLMMessage] = None # #1: pinned original task
|
|
51
|
+
self.messages.append(LLMMessage(role="system", content=system_prompt))
|
|
52
|
+
|
|
53
|
+
def add_user(self, content: str):
|
|
54
|
+
msg = LLMMessage(role="user", content=content)
|
|
55
|
+
# Pin the very first user message as the original task
|
|
56
|
+
if self._pinned_task is None:
|
|
57
|
+
self._pinned_task = msg
|
|
58
|
+
self.messages.append(msg)
|
|
59
|
+
|
|
60
|
+
def add_assistant(self, content: str, tool_calls: Optional[list[ToolCall]] = None):
|
|
61
|
+
self.messages.append(LLMMessage(role="assistant", content=content, tool_calls=tool_calls or []))
|
|
62
|
+
|
|
63
|
+
def add_tool_result(self, tc: ToolCall, result: ToolResult):
|
|
64
|
+
self.messages.append(
|
|
65
|
+
LLMMessage(role="tool", content=result.to_str(), tool_call_id=tc.id)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def compress(self):
|
|
69
|
+
"""Trim old messages while preserving valid role structure.
|
|
70
|
+
|
|
71
|
+
Rules:
|
|
72
|
+
- Always keep messages[0] (system prompt).
|
|
73
|
+
- Always re-inject the pinned task message after trimming.
|
|
74
|
+
- Keep the most recent CONTEXT_WINDOW_KEEP non-system messages.
|
|
75
|
+
- After trimming, drop orphaned tool/assistant messages at the boundary.
|
|
76
|
+
- Prepend a trim notice only when the kept slice doesn't start with a user msg.
|
|
77
|
+
"""
|
|
78
|
+
non_system = self.messages[1:]
|
|
79
|
+
if len(non_system) <= CONTEXT_WINDOW_KEEP:
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
kept = non_system[-(CONTEXT_WINDOW_KEEP):]
|
|
83
|
+
|
|
84
|
+
# Drop orphaned tool results at the front (no paired assistant above them)
|
|
85
|
+
while kept and kept[0].role == "tool":
|
|
86
|
+
kept = kept[1:]
|
|
87
|
+
|
|
88
|
+
# Drop an assistant-with-tool-calls at the front if its results were trimmed
|
|
89
|
+
if kept and kept[0].role == "assistant" and kept[0].tool_calls:
|
|
90
|
+
call_ids = {tc.id for tc in kept[0].tool_calls}
|
|
91
|
+
result_ids = {m.tool_call_id for m in kept if m.role == "tool"}
|
|
92
|
+
if not call_ids.issubset(result_ids):
|
|
93
|
+
kept = kept[1:]
|
|
94
|
+
while kept and kept[0].role == "tool":
|
|
95
|
+
kept = kept[1:]
|
|
96
|
+
|
|
97
|
+
if not kept:
|
|
98
|
+
return # nothing safe to keep — don't corrupt history
|
|
99
|
+
|
|
100
|
+
# #4 FIX: only insert trim notice when the first kept msg is NOT already a user msg
|
|
101
|
+
# (previously both branches did the same insert, causing double user messages)
|
|
102
|
+
if kept[0].role != "user":
|
|
103
|
+
trim_notice = LLMMessage(
|
|
104
|
+
role="user",
|
|
105
|
+
content="[Earlier conversation was trimmed. Continue working on the current task.]"
|
|
106
|
+
)
|
|
107
|
+
kept.insert(0, trim_notice)
|
|
108
|
+
|
|
109
|
+
# #1: Re-inject pinned task at the front if it's not already there
|
|
110
|
+
if self._pinned_task is not None and kept[0] is not self._pinned_task:
|
|
111
|
+
# Avoid duplicating if the pinned task is already in kept
|
|
112
|
+
if self._pinned_task not in kept:
|
|
113
|
+
kept.insert(0, self._pinned_task)
|
|
114
|
+
|
|
115
|
+
self.messages = [self.messages[0]] + kept
|
|
116
|
+
|
|
117
|
+
def get_messages(self) -> list[LLMMessage]:
|
|
118
|
+
return self.messages
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ─── Verification Manager ──────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
class VerificationManager:
|
|
124
|
+
"""Tracks verification checkpoints and ensures changes are verified."""
|
|
125
|
+
|
|
126
|
+
def __init__(self):
|
|
127
|
+
self.changes_made: list[dict] = [] # list of {tool, path, time}
|
|
128
|
+
self.verified = False
|
|
129
|
+
|
|
130
|
+
def record_change(self, tool_name: str, path: str):
|
|
131
|
+
self.changes_made.append({
|
|
132
|
+
"tool": tool_name,
|
|
133
|
+
"path": path,
|
|
134
|
+
"time": datetime.now().isoformat(),
|
|
135
|
+
})
|
|
136
|
+
self.verified = False
|
|
137
|
+
|
|
138
|
+
def mark_verified(self):
|
|
139
|
+
self.verified = True
|
|
140
|
+
|
|
141
|
+
def has_unverified_changes(self) -> bool:
|
|
142
|
+
return len(self.changes_made) > 0 and not self.verified
|
|
143
|
+
|
|
144
|
+
def summary(self) -> str:
|
|
145
|
+
if not self.changes_made:
|
|
146
|
+
return ""
|
|
147
|
+
lines = [f"[Changes made this session ({len(self.changes_made)}):]"]
|
|
148
|
+
for c in self.changes_made:
|
|
149
|
+
lines.append(f" - {c['tool']}: {c['path']}")
|
|
150
|
+
if self.verified:
|
|
151
|
+
lines.append(" ✓ Verified")
|
|
152
|
+
else:
|
|
153
|
+
lines.append(" ⚠ Not yet verified")
|
|
154
|
+
return "\n".join(lines)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ─── Tool Executor ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
class ToolExecutor:
|
|
160
|
+
"""Handles tool call execution and result tracking."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, workspace: Path, approval_mode: str = MODE_AUTO):
|
|
163
|
+
self.workspace = workspace
|
|
164
|
+
self.last_signatures: list[str] = []
|
|
165
|
+
self.approval_mode = approval_mode
|
|
166
|
+
|
|
167
|
+
def set_approval_mode(self, mode: str):
|
|
168
|
+
self.approval_mode = mode
|
|
169
|
+
|
|
170
|
+
def needs_approval(self, tool_name: str) -> bool:
|
|
171
|
+
if self.approval_mode == MODE_AUTO:
|
|
172
|
+
return False
|
|
173
|
+
if self.approval_mode == MODE_SAFE:
|
|
174
|
+
return tool_name in VERY_RISKY_TOOLS
|
|
175
|
+
if self.approval_mode == MODE_NORMAL:
|
|
176
|
+
return tool_name in RISKY_TOOLS
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
async def execute(self, tc: ToolCall) -> ToolResult:
|
|
180
|
+
# Ask for approval if needed
|
|
181
|
+
if self.needs_approval(tc.name):
|
|
182
|
+
args_str = json.dumps(tc.arguments, indent=2)[:500]
|
|
183
|
+
print(f"\n[Axon needs approval]")
|
|
184
|
+
print(f"Tool: {tc.name}")
|
|
185
|
+
print(f"Args:\n{args_str}")
|
|
186
|
+
try:
|
|
187
|
+
loop = asyncio.get_event_loop()
|
|
188
|
+
answer = await loop.run_in_executor(None, input, "Run this? (Y/n): ")
|
|
189
|
+
if answer.strip().lower() == "n":
|
|
190
|
+
return ToolResult(
|
|
191
|
+
success=False,
|
|
192
|
+
error="Skipped — user declined approval.",
|
|
193
|
+
tool_name=tc.name,
|
|
194
|
+
)
|
|
195
|
+
except (EOFError, KeyboardInterrupt):
|
|
196
|
+
return ToolResult(
|
|
197
|
+
success=False,
|
|
198
|
+
error="Skipped — interrupted.",
|
|
199
|
+
tool_name=tc.name,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
result = await execute_tool(tc.name, tc.arguments, self.workspace)
|
|
203
|
+
sig = f"{tc.name}:{json.dumps(tc.arguments, sort_keys=True)}"
|
|
204
|
+
self.last_signatures.append(sig)
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
def is_looping(self) -> bool:
|
|
208
|
+
"""Detect both identical-repeat and alternating-loop patterns."""
|
|
209
|
+
if len(self.last_signatures) < MAX_IDENTICAL_RESULTS:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
# Classic: last N calls all identical
|
|
213
|
+
recent = self.last_signatures[-MAX_IDENTICAL_RESULTS:]
|
|
214
|
+
if len(set(recent)) == 1:
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
# Alternating: within the last LOOP_WINDOW calls, any signature
|
|
218
|
+
# appears LOOP_REPEAT_THRESHOLD or more times
|
|
219
|
+
if len(self.last_signatures) >= LOOP_WINDOW:
|
|
220
|
+
window = self.last_signatures[-LOOP_WINDOW:]
|
|
221
|
+
counts = Counter(window)
|
|
222
|
+
if counts.most_common(1)[0][1] >= LOOP_REPEAT_THRESHOLD:
|
|
223
|
+
return True
|
|
224
|
+
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
def reset_loop_detection(self):
|
|
228
|
+
self.last_signatures = []
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ─── Orchestrator ──────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
class Agent:
|
|
234
|
+
"""High-level agent that orchestrates the AI coding loop."""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
config: AxonConfig,
|
|
239
|
+
workspace: Path,
|
|
240
|
+
task: str,
|
|
241
|
+
interactive: bool = True,
|
|
242
|
+
persist: bool = False,
|
|
243
|
+
approval_mode: str = MODE_AUTO,
|
|
244
|
+
):
|
|
245
|
+
self.config = config
|
|
246
|
+
self.workspace = workspace
|
|
247
|
+
self.interactive = interactive
|
|
248
|
+
self.approval_mode = approval_mode
|
|
249
|
+
self.memory = ConversationMemory(config.system_prompt, persist=persist)
|
|
250
|
+
self.executor = ToolExecutor(workspace, approval_mode)
|
|
251
|
+
self.verifier = VerificationManager()
|
|
252
|
+
self.provider: LLMProvider = create_provider(config.llm)
|
|
253
|
+
self.persist = persist
|
|
254
|
+
self.active_plan: Optional[str] = None
|
|
255
|
+
|
|
256
|
+
self.memory.add_user(task)
|
|
257
|
+
|
|
258
|
+
def set_approval_mode(self, mode: str):
|
|
259
|
+
self.approval_mode = mode
|
|
260
|
+
self.executor.set_approval_mode(mode)
|
|
261
|
+
|
|
262
|
+
def add_task(self, new_task: str):
|
|
263
|
+
"""Add a follow-up task to the existing conversation (reuse provider)."""
|
|
264
|
+
self.memory.add_user(new_task)
|
|
265
|
+
|
|
266
|
+
def set_plan(self, plan: str):
|
|
267
|
+
"""Set a structured plan that the agent can reference."""
|
|
268
|
+
self.active_plan = plan
|
|
269
|
+
self.memory.add_user(
|
|
270
|
+
f"[PLAN FOR THIS TASK]\n{plan}\n\n"
|
|
271
|
+
"Follow this plan step by step. Update progress after each step."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
async def _run_planning_phase(self) -> str:
|
|
275
|
+
"""#5: Dedicated planning phase — one call with read-only tools, returns the plan.
|
|
276
|
+
|
|
277
|
+
The model is given only safe/read tools so it can't accidentally start
|
|
278
|
+
making changes while planning. It must respond with a numbered plan.
|
|
279
|
+
"""
|
|
280
|
+
console.print("[dim]Planning...[/]")
|
|
281
|
+
|
|
282
|
+
# Inject a planning-specific instruction
|
|
283
|
+
planning_prompt = LLMMessage(
|
|
284
|
+
role="user",
|
|
285
|
+
content=(
|
|
286
|
+
"[PLANNING PHASE]\n"
|
|
287
|
+
"Before doing anything, produce a numbered step-by-step plan for this task.\n"
|
|
288
|
+
"- Use the available tools to explore the codebase if needed.\n"
|
|
289
|
+
"- Your response MUST end with a section starting with '## Plan' listing numbered steps.\n"
|
|
290
|
+
"- Do NOT make any file edits or run any commands yet — this is planning only.\n"
|
|
291
|
+
"- Keep steps concrete: specify which files and what changes."
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
planning_messages = self.memory.get_messages() + [planning_prompt]
|
|
296
|
+
|
|
297
|
+
content, _ = await self.provider.chat(planning_messages, PLANNING_TOOL_DEFINITIONS)
|
|
298
|
+
|
|
299
|
+
if content:
|
|
300
|
+
_print_assistant(content, title="Axon · Planning")
|
|
301
|
+
|
|
302
|
+
return content or ""
|
|
303
|
+
|
|
304
|
+
async def run(self) -> str:
|
|
305
|
+
iteration = 0
|
|
306
|
+
final_result = ""
|
|
307
|
+
|
|
308
|
+
# #5: Structural planning phase (unless plan was manually set via /plan)
|
|
309
|
+
if self.active_plan is None:
|
|
310
|
+
plan_text = await self._run_planning_phase()
|
|
311
|
+
if plan_text:
|
|
312
|
+
self.active_plan = plan_text
|
|
313
|
+
self.memory.add_assistant(plan_text)
|
|
314
|
+
self.memory.add_user(
|
|
315
|
+
"[Planning complete. Now execute the plan step by step. "
|
|
316
|
+
"After each step update progress. Verify changes after edits. "
|
|
317
|
+
"Call finish_task when done.]"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
while iteration < self.config.max_iterations:
|
|
321
|
+
iteration += 1
|
|
322
|
+
|
|
323
|
+
content, tool_calls = await self.provider.chat(
|
|
324
|
+
self.memory.get_messages(), TOOL_DEFINITIONS
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# #8: Buffer all content — print once per turn as one panel
|
|
328
|
+
turn_content_parts: list[str] = []
|
|
329
|
+
if content:
|
|
330
|
+
turn_content_parts.append(content)
|
|
331
|
+
|
|
332
|
+
if tool_calls:
|
|
333
|
+
self.memory.add_assistant(content, tool_calls)
|
|
334
|
+
|
|
335
|
+
for tc in tool_calls:
|
|
336
|
+
_print_tool_call(tc)
|
|
337
|
+
result = await self.executor.execute(tc)
|
|
338
|
+
_print_tool_result(tc.name, result.to_str())
|
|
339
|
+
self.memory.add_tool_result(tc, result)
|
|
340
|
+
|
|
341
|
+
# Track changes for verification
|
|
342
|
+
if tc.name in ("write_file", "apply_patch"):
|
|
343
|
+
path = tc.arguments.get("path", "unknown")
|
|
344
|
+
self.verifier.record_change(tc.name, path)
|
|
345
|
+
|
|
346
|
+
# After a change tool, prompt verification
|
|
347
|
+
if tc.name in ("write_file", "apply_patch"):
|
|
348
|
+
self.memory.add_user(
|
|
349
|
+
"[Change made. Now verify: check the diff with git_diff, run relevant tests, "
|
|
350
|
+
"and confirm everything works before continuing. If you wrote a new file, check it compiles/runs correctly.]"
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Mark verified when the agent checks the diff
|
|
354
|
+
if tc.name == "git_diff" and result.success:
|
|
355
|
+
self.verifier.mark_verified()
|
|
356
|
+
|
|
357
|
+
# #6: Handle ask_question with optional numbered options
|
|
358
|
+
if tc.name == "ask_question":
|
|
359
|
+
answer = result.to_str()
|
|
360
|
+
# answer is already captured by _ask_question in tools.py
|
|
361
|
+
# just make sure it feeds back into memory (already done via add_tool_result)
|
|
362
|
+
|
|
363
|
+
if tc.name == "finish_task":
|
|
364
|
+
# #2: Guard finish_task — block if there are unverified changes
|
|
365
|
+
if self.verifier.has_unverified_changes():
|
|
366
|
+
self.memory.add_user(
|
|
367
|
+
"[finish_task blocked: you have unverified changes. "
|
|
368
|
+
"Run git_diff to review your edits, then run tests or the linter "
|
|
369
|
+
"to confirm everything works. Only call finish_task after verifying.]"
|
|
370
|
+
)
|
|
371
|
+
# Don't break — let the loop continue so the model can verify
|
|
372
|
+
else:
|
|
373
|
+
final_result = tc.arguments.get("result", "")
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
if final_result:
|
|
377
|
+
break
|
|
378
|
+
|
|
379
|
+
# Loop detection
|
|
380
|
+
if self.executor.is_looping():
|
|
381
|
+
self.memory.add_user(
|
|
382
|
+
"[You appear to be stuck in a loop — the same actions keep repeating. "
|
|
383
|
+
"Step back, think about why the approach isn't working, and try a fundamentally different strategy. "
|
|
384
|
+
"If you are truly blocked, call ask_question to get help from the user.]"
|
|
385
|
+
)
|
|
386
|
+
self.executor.reset_loop_detection()
|
|
387
|
+
else:
|
|
388
|
+
self.memory.add_assistant(content)
|
|
389
|
+
|
|
390
|
+
# #8: Print all buffered content for this turn as one panel
|
|
391
|
+
if turn_content_parts:
|
|
392
|
+
_print_assistant("\n\n".join(turn_content_parts))
|
|
393
|
+
|
|
394
|
+
self.memory.compress()
|
|
395
|
+
|
|
396
|
+
if self.interactive and content:
|
|
397
|
+
try:
|
|
398
|
+
resp = input("\n[Axon] Continue? (Y/n): ").strip().lower()
|
|
399
|
+
if resp == "n":
|
|
400
|
+
final_result = "Session ended by user."
|
|
401
|
+
break
|
|
402
|
+
except (EOFError, KeyboardInterrupt):
|
|
403
|
+
final_result = "Session ended by user."
|
|
404
|
+
break
|
|
405
|
+
|
|
406
|
+
if self.persist and final_result:
|
|
407
|
+
summary = final_result
|
|
408
|
+
if self.verifier.has_unverified_changes():
|
|
409
|
+
summary += "\n⚠ Some changes may not have been verified."
|
|
410
|
+
self.memory.add_user(f"[Previous task completed] {summary}")
|
|
411
|
+
|
|
412
|
+
return final_result or "Task completed."
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ─── UI helpers ────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
def _print_assistant(content: str, title: str = "Axon"):
|
|
418
|
+
"""#8: Print assistant content as a single panel — one box per call."""
|
|
419
|
+
console.print()
|
|
420
|
+
console.print(Panel(Markdown(content), title=title, border_style="blue", title_align="left"))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _print_tool_call(tc: ToolCall):
|
|
424
|
+
args_str = json.dumps(tc.arguments, indent=2)
|
|
425
|
+
console.print(Panel(
|
|
426
|
+
f"[bold yellow]Tool:[/] [cyan]{tc.name}[/]\n[bold yellow]Args:[/]\n{args_str}",
|
|
427
|
+
title="🔧 Tool Call", border_style="yellow", title_align="left",
|
|
428
|
+
))
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _print_tool_result(tool_name: str, result: str):
|
|
432
|
+
display = result[:1000] + "\n... (truncated)" if len(result) > 1000 else result
|
|
433
|
+
console.print(Panel(
|
|
434
|
+
Syntax(display, "text", theme="monokai"),
|
|
435
|
+
title=f"📦 Result: {tool_name}", border_style="green", title_align="left",
|
|
436
|
+
))
|