KingNish commited on
Commit
10fbb32
·
1 Parent(s): 2407c30

Added Shell

Browse files
Files changed (5) hide show
  1. agent/__init__.py +5 -1
  2. agent/agent.py +149 -18
  3. agent/shell.py +180 -0
  4. agent/tools.py +130 -2
  5. app.py +9 -4
agent/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
  from .agent import Agent
2
- from .tools import Tool, tool, fetch_webpage, FETCH_WEBPAGE_TOOL
3
  from .mcp import MCPClient, mcp_tool, load_mcp_tools, load_all_mcp_tools, MCP_SERVERS
 
4
 
5
  __all__ = [
6
  "Agent",
@@ -8,9 +9,12 @@ __all__ = [
8
  "tool",
9
  "fetch_webpage",
10
  "FETCH_WEBPAGE_TOOL",
 
11
  "MCPClient",
12
  "mcp_tool",
13
  "load_mcp_tools",
14
  "load_all_mcp_tools",
15
  "MCP_SERVERS",
 
 
16
  ]
 
1
  from .agent import Agent
2
+ from .tools import Tool, tool, fetch_webpage, FETCH_WEBPAGE_TOOL, SHELL_TOOL
3
  from .mcp import MCPClient, mcp_tool, load_mcp_tools, load_all_mcp_tools, MCP_SERVERS
4
+ from .shell import ShellManager, get_shell_manager
5
 
6
  __all__ = [
7
  "Agent",
 
9
  "tool",
10
  "fetch_webpage",
11
  "FETCH_WEBPAGE_TOOL",
12
+ "SHELL_TOOL",
13
  "MCPClient",
14
  "mcp_tool",
15
  "load_mcp_tools",
16
  "load_all_mcp_tools",
17
  "MCP_SERVERS",
18
+ "ShellManager",
19
+ "get_shell_manager",
20
  ]
agent/agent.py CHANGED
@@ -14,7 +14,7 @@ from .mcp import MCPClient, load_mcp_tools, load_all_mcp_tools
14
  # {"type": "text", "content": "partial text"}
15
  # {"type": "reasoning","content": "model thinking"}
16
  # {"type": "tool_call", "name": str, "arguments": '{"url":"..."}'}
17
- # {"type": "tool_output","name": str, "content": "result"}
18
  # {"type": "done", "content": "full assistant response"}
19
  # {"type": "error", "content": "error message"}
20
  # ---------------------------------------------------------------------------
@@ -42,6 +42,7 @@ class Agent:
42
  self._final_tool_name: str | None = None
43
  self._max_iterations = max_iterations
44
  self.system_prompt = system_prompt
 
45
 
46
  # ------------------------------------------------------------------
47
  # Tool registration
@@ -50,6 +51,58 @@ class Agent:
50
  def register_tool(self, tool: Tool) -> None:
51
  self._tools.append(tool)
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def register_mcp(self, url: str, headers: dict[str, str] | None = None) -> list[Tool]:
54
  """Connect to an MCP server and register all its tools.
55
 
@@ -239,30 +292,108 @@ class Agent:
239
  tool_obj = next(
240
  (t for t in self._tools if t.name == tname), None
241
  )
242
- if tool_obj:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  try:
244
- result = tool_obj.run(**targs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  except Exception as e:
246
- result = f"Error executing {tname}: {e}"
247
  else:
248
- result = f"Error: Tool '{tname}' not found"
 
 
 
 
249
 
250
- result_str = str(result)
 
 
251
  if len(result_str) > 5_000:
252
- result_str = result_str[:5_000] + "\n...[truncated]"
253
- yield {
254
- "type": "tool_output",
255
- "name": tname,
256
- "content": result_str,
257
- }
258
-
259
- messages.append(
260
- {
261
- "role": "tool",
262
- "tool_call_id": tc_spec["id"],
 
 
 
 
 
 
263
  "content": result_str,
264
  }
265
- )
 
 
 
 
 
266
 
267
  continue # Loop back — model can call more tools or final_message
268
 
 
14
  # {"type": "text", "content": "partial text"}
15
  # {"type": "reasoning","content": "model thinking"}
16
  # {"type": "tool_call", "name": str, "arguments": '{"url":"..."}'}
17
+ # {"type": "tool_output","name": str, "arguments": str, "content": "result", "partial": bool}
18
  # {"type": "done", "content": "full assistant response"}
19
  # {"type": "error", "content": "error message"}
20
  # ---------------------------------------------------------------------------
 
42
  self._final_tool_name: str | None = None
43
  self._max_iterations = max_iterations
44
  self.system_prompt = system_prompt
45
+ self._tool_results: dict[str, str] = {} # tool_call_id → full result
46
 
47
  # ------------------------------------------------------------------
48
  # Tool registration
 
51
  def register_tool(self, tool: Tool) -> None:
52
  self._tools.append(tool)
53
 
54
+ def register_read_tool(self) -> None:
55
+ """Register a tool to read truncated tool responses by line range."""
56
+ results_cache = self._tool_results
57
+
58
+ def _read(tool_call_id: str, start_line: int, num_lines: int = 50) -> str:
59
+ """Read more lines from a truncated tool response.
60
+
61
+ tool_call_id (required): The tool_call_id from the truncated response
62
+ start_line (required): Line number to start reading from
63
+ num_lines: Number of lines to read (default 50)
64
+ """
65
+ full = results_cache.get(tool_call_id)
66
+ if full is None:
67
+ return f"Error: No result found for tool_call_id '{tool_call_id}'"
68
+ lines = full.split("\n")
69
+ total = len(lines)
70
+ if start_line >= total:
71
+ return f"Error: start_line {start_line} >= total lines {total}"
72
+ end = min(start_line + num_lines, total)
73
+ chunk = "\n".join(lines[start_line:end])
74
+ remaining = total - end
75
+ header = f"Lines {start_line}-{end} of {total}"
76
+ if remaining > 0:
77
+ header += f" ({remaining} lines remaining)"
78
+ return f"{header}\n\n{chunk}"
79
+
80
+ self._tools.append(
81
+ Tool(
82
+ name="read_tool_response",
83
+ description="Read more lines from a truncated tool response. Use when a previous tool output was truncated.",
84
+ parameters={
85
+ "type": "object",
86
+ "properties": {
87
+ "tool_call_id": {
88
+ "type": "string",
89
+ "description": "The tool_call_id from the truncated response",
90
+ },
91
+ "start_line": {
92
+ "type": "integer",
93
+ "description": "Line number to start reading from (0-indexed)",
94
+ },
95
+ "num_lines": {
96
+ "type": "integer",
97
+ "description": "Number of lines to read (default 50)",
98
+ },
99
+ },
100
+ "required": ["tool_call_id", "start_line"],
101
+ },
102
+ handler=_read,
103
+ )
104
+ )
105
+
106
  def register_mcp(self, url: str, headers: dict[str, str] | None = None) -> list[Tool]:
107
  """Connect to an MCP server and register all its tools.
108
 
 
292
  tool_obj = next(
293
  (t for t in self._tools if t.name == tname), None
294
  )
295
+
296
+ if tool_obj is None:
297
+ result_str = f"Error: Tool '{tname}' not found"
298
+ self._tool_results[tc_spec["id"]] = result_str
299
+ yield {
300
+ "type": "tool_output",
301
+ "name": tname,
302
+ "arguments": tc_spec["function"]["arguments"],
303
+ "content": result_str,
304
+ }
305
+ messages.append({
306
+ "role": "tool",
307
+ "tool_call_id": tc_spec["id"],
308
+ "content": result_str,
309
+ })
310
+ continue
311
+
312
+ # Streamable tool — yield partial results
313
+ if tool_obj.streamable:
314
+ accumulated = ""
315
+ last_chunk = ""
316
  try:
317
+ for chunk in tool_obj.stream(**targs):
318
+ accumulated += chunk
319
+ last_chunk = chunk
320
+ # Truncate display but keep full for read_tool_response
321
+ display = accumulated
322
+ if len(display) > 5_000:
323
+ lines = display.split("\n")
324
+ char_count = 0
325
+ cut_line = 0
326
+ for i, line in enumerate(lines):
327
+ char_count += len(line) + 1
328
+ if char_count > 5_000:
329
+ cut_line = i
330
+ break
331
+ display = "\n".join(lines[:cut_line])
332
+ yield {
333
+ "type": "tool_output",
334
+ "name": tname,
335
+ "arguments": tc_spec["function"]["arguments"],
336
+ "content": display,
337
+ "partial": True,
338
+ }
339
+ # Mark final yield as non-partial
340
+ display = accumulated
341
+ if len(display) > 5_000:
342
+ lines = display.split("\n")
343
+ char_count = 0
344
+ cut_line = 0
345
+ for i, line in enumerate(lines):
346
+ char_count += len(line) + 1
347
+ if char_count > 5_000:
348
+ cut_line = i
349
+ break
350
+ display = "\n".join(lines[:cut_line])
351
+ yield {
352
+ "type": "tool_output",
353
+ "name": tname,
354
+ "arguments": tc_spec["function"]["arguments"],
355
+ "content": display,
356
+ "partial": False,
357
+ }
358
+ result_str = accumulated
359
  except Exception as e:
360
+ result_str = f"Error executing {tname}: {e}"
361
  else:
362
+ # Regular tool single result
363
+ try:
364
+ result_str = str(tool_obj.run(**targs))
365
+ except Exception as e:
366
+ result_str = f"Error executing {tname}: {e}"
367
 
368
+ # Store full result and truncate for message history
369
+ self._tool_results[tc_spec["id"]] = result_str
370
+ lines = result_str.split("\n")
371
  if len(result_str) > 5_000:
372
+ char_count = 0
373
+ cut_line = 0
374
+ for i, line in enumerate(lines):
375
+ char_count += len(line) + 1
376
+ if char_count > 5_000:
377
+ cut_line = i
378
+ break
379
+ truncated = "\n".join(lines[:cut_line])
380
+ remaining = len(lines) - cut_line
381
+ result_str = f"{truncated}\n\n...[{remaining} lines truncated — use read_tool_response tool_call_id=\"{tc_spec['id']}\" start_line={cut_line} to read more]"
382
+
383
+ # Final tool_output (non-partial) for history
384
+ if not tool_obj.streamable:
385
+ yield {
386
+ "type": "tool_output",
387
+ "name": tname,
388
+ "arguments": tc_spec["function"]["arguments"],
389
  "content": result_str,
390
  }
391
+
392
+ messages.append({
393
+ "role": "tool",
394
+ "tool_call_id": tc_spec["id"],
395
+ "content": result_str,
396
+ })
397
 
398
  continue # Loop back — model can call more tools or final_message
399
 
agent/shell.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shell manager for running interactive subprocesses with streaming output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import IO
10
+
11
+
12
+ @dataclass
13
+ class ShellSession:
14
+ """A running shell session."""
15
+
16
+ session_id: str
17
+ process: subprocess.Popen[str]
18
+ output_buffer: list[str] = field(default_factory=list)
19
+ last_read_pos: int = 0
20
+ started_at: float = field(default_factory=time.time)
21
+ closed: bool = False
22
+
23
+ @property
24
+ def pid(self) -> int | None:
25
+ return self.process.pid
26
+
27
+ @property
28
+ def returncode(self) -> int | None:
29
+ return self.process.returncode
30
+
31
+ def read_new_output(self) -> str:
32
+ """Read new output since last read."""
33
+ new_lines = self.output_buffer[self.last_read_pos :]
34
+ self.last_read_pos = len(self.output_buffer)
35
+ return "".join(new_lines)
36
+
37
+ def get_full_output(self) -> str:
38
+ """Get all output."""
39
+ return "".join(self.output_buffer)
40
+
41
+ def is_running(self) -> bool:
42
+ """Check if process is still running."""
43
+ return self.process.poll() is None
44
+
45
+ def close(self) -> None:
46
+ """Close the session."""
47
+ if not self.closed:
48
+ self.closed = True
49
+ try:
50
+ self.process.terminate()
51
+ self.process.wait(timeout=5)
52
+ except Exception:
53
+ try:
54
+ self.process.kill()
55
+ except Exception:
56
+ pass
57
+
58
+
59
+ class ShellManager:
60
+ """Manages multiple shell sessions with streaming output."""
61
+
62
+ def __init__(self, default_timeout: float = 15.0) -> None:
63
+ self.sessions: dict[str, ShellSession] = {}
64
+ self._lock = threading.Lock()
65
+ self.default_timeout = default_timeout
66
+
67
+ def start(
68
+ self,
69
+ session_id: str,
70
+ command: str,
71
+ cwd: str | None = None,
72
+ env: dict[str, str] | None = None,
73
+ ) -> ShellSession:
74
+ """Start a new shell session."""
75
+ with self._lock:
76
+ # Close existing session with same ID
77
+ if session_id in self.sessions:
78
+ self.sessions[session_id].close()
79
+
80
+ # Use shell=True for interactive commands
81
+ process = subprocess.Popen(
82
+ command,
83
+ shell=True,
84
+ stdin=subprocess.PIPE,
85
+ stdout=subprocess.PIPE,
86
+ stderr=subprocess.STDOUT,
87
+ text=True,
88
+ bufsize=1,
89
+ cwd=cwd,
90
+ env=env,
91
+ )
92
+
93
+ session = ShellSession(session_id=session_id, process=process)
94
+ self.sessions[session_id] = session
95
+
96
+ # Start output reader thread
97
+ thread = threading.Thread(
98
+ target=self._read_output, args=(session,), daemon=True
99
+ )
100
+ thread.start()
101
+
102
+ return session
103
+
104
+ def _read_output(self, session: ShellSession) -> None:
105
+ """Read output from process in background thread."""
106
+ assert session.process.stdout is not None
107
+ try:
108
+ for line in session.process.stdout:
109
+ with self._lock:
110
+ session.output_buffer.append(line)
111
+ except Exception:
112
+ pass
113
+ finally:
114
+ with self._lock:
115
+ session.closed = True
116
+
117
+ def send_input(self, session_id: str, input_text: str) -> bool:
118
+ """Send input to a running session."""
119
+ with self._lock:
120
+ session = self.sessions.get(session_id)
121
+ if session is None or not session.is_running():
122
+ return False
123
+ if session.process.stdin is None:
124
+ return False
125
+ try:
126
+ session.process.stdin.write(input_text + "\n")
127
+ session.process.stdin.flush()
128
+ return True
129
+ except Exception:
130
+ return False
131
+
132
+ def get_output(self, session_id: str) -> str | None:
133
+ """Get full output from a session."""
134
+ with self._lock:
135
+ session = self.sessions.get(session_id)
136
+ if session is None:
137
+ return None
138
+ return session.get_full_output()
139
+
140
+ def poll_output(self, session_id: str) -> str | None:
141
+ """Get new output since last poll."""
142
+ with self._lock:
143
+ session = self.sessions.get(session_id)
144
+ if session is None:
145
+ return None
146
+ return session.read_new_output()
147
+
148
+ def is_running(self, session_id: str) -> bool:
149
+ """Check if a session is still running."""
150
+ with self._lock:
151
+ session = self.sessions.get(session_id)
152
+ if session is None:
153
+ return False
154
+ return session.is_running()
155
+
156
+ def close(self, session_id: str) -> None:
157
+ """Close a session."""
158
+ with self._lock:
159
+ session = self.sessions.get(session_id)
160
+ if session:
161
+ session.close()
162
+
163
+ def close_all(self) -> None:
164
+ """Close all sessions."""
165
+ with self._lock:
166
+ for session in self.sessions.values():
167
+ session.close()
168
+ self.sessions.clear()
169
+
170
+
171
+ # Global shell manager instance
172
+ _shell_manager: ShellManager | None = None
173
+
174
+
175
+ def get_shell_manager() -> ShellManager:
176
+ """Get or create the global shell manager."""
177
+ global _shell_manager
178
+ if _shell_manager is None:
179
+ _shell_manager = ShellManager()
180
+ return _shell_manager
agent/tools.py CHANGED
@@ -1,5 +1,5 @@
1
  from markitdown import MarkItDown
2
- from typing import Any, Callable, get_type_hints
3
  import inspect
4
  import requests
5
 
@@ -20,12 +20,18 @@ class Tool:
20
  """A callable tool the agent can invoke."""
21
 
22
  def __init__(
23
- self, name: str, description: str, parameters: dict, handler: Callable[..., str]
 
 
 
 
 
24
  ) -> None:
25
  self.name = name
26
  self.description = description
27
  self.parameters = parameters
28
  self.handler = handler
 
29
 
30
  def to_openai_spec(self) -> dict:
31
  return {
@@ -40,6 +46,20 @@ class Tool:
40
  def run(self, **kwargs: Any) -> str:
41
  return self.handler(**kwargs)
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  def _parse_docstring(docstring: str) -> tuple[str, dict[str, tuple[bool, str]]]:
45
  """Parse a tool docstring into description and param metadata.
@@ -147,3 +167,111 @@ def fetch_webpage(url: str) -> str:
147
  return md.convert(url).text_content
148
 
149
  FETCH_WEBPAGE_TOOL = fetch_webpage # @tool already makes it a Tool instance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from markitdown import MarkItDown
2
+ from typing import Any, Callable, Generator, get_type_hints
3
  import inspect
4
  import requests
5
 
 
20
  """A callable tool the agent can invoke."""
21
 
22
  def __init__(
23
+ self,
24
+ name: str,
25
+ description: str,
26
+ parameters: dict,
27
+ handler: Callable[..., str],
28
+ streamable: bool = False,
29
  ) -> None:
30
  self.name = name
31
  self.description = description
32
  self.parameters = parameters
33
  self.handler = handler
34
+ self.streamable = streamable
35
 
36
  def to_openai_spec(self) -> dict:
37
  return {
 
46
  def run(self, **kwargs: Any) -> str:
47
  return self.handler(**kwargs)
48
 
49
+ def stream(self, **kwargs: Any) -> Generator[str, None, None]:
50
+ """Yield partial results for streamable tools.
51
+
52
+ Override in subclasses or use streamable=True with a generator handler.
53
+ """
54
+ if self.streamable and callable(self.handler):
55
+ result = self.handler(**kwargs)
56
+ if isinstance(result, Generator):
57
+ yield from result
58
+ else:
59
+ yield str(result)
60
+ else:
61
+ yield self.handler(**kwargs)
62
+
63
 
64
  def _parse_docstring(docstring: str) -> tuple[str, dict[str, tuple[bool, str]]]:
65
  """Parse a tool docstring into description and param metadata.
 
167
  return md.convert(url).text_content
168
 
169
  FETCH_WEBPAGE_TOOL = fetch_webpage # @tool already makes it a Tool instance
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Shell tool (streamable)
174
+ # ---------------------------------------------------------------------------
175
+
176
+ import time
177
+ import uuid as _uuid
178
+ from .shell import get_shell_manager
179
+
180
+
181
+ def _shell_handler(
182
+ command: str,
183
+ session_id: str = "",
184
+ input_text: str = "",
185
+ timeout: float = 15.0,
186
+ ) -> Generator[str, None, None]:
187
+ """Run a shell command with streaming output.
188
+
189
+ command (required): The shell command to execute
190
+ session_id: Session ID to send input to (omit to auto-generate)
191
+ input_text: Text to send to running session's stdin
192
+ timeout: Seconds between output updates (default 15)
193
+ """
194
+ manager = get_shell_manager()
195
+
196
+ # Check if session_id refers to an existing session
197
+ existing_session = session_id and session_id in manager.sessions
198
+
199
+ # If session exists and has input, send it
200
+ if existing_session and input_text:
201
+ sent = manager.send_input(session_id, input_text)
202
+ if not sent:
203
+ yield f"Error: Session '{session_id}' closed or cannot accept input"
204
+ return
205
+ time.sleep(0.5)
206
+ output = manager.poll_output(session_id)
207
+ if output:
208
+ yield f"Sent input. New output:\n{output}"
209
+ else:
210
+ yield "Input sent (no new output yet)"
211
+ return
212
+
213
+ # If session exists without input, return current output
214
+ if existing_session:
215
+ output = manager.get_output(session_id)
216
+ if output is None:
217
+ yield f"Error: Session '{session_id}' not found"
218
+ return
219
+ running = manager.is_running(session_id)
220
+ status = "running" if running else f"exited (code {manager.sessions[session_id].returncode})"
221
+ yield f"Session {session_id} [{status}]:\n{output}"
222
+ return
223
+
224
+ # Start new command (with provided session_id or auto-generated)
225
+ sid = session_id or str(_uuid.uuid4())[:8]
226
+ session = manager.start(sid, command)
227
+ yield f"Started session {sid} (PID {session.pid})"
228
+
229
+ # Stream output — poll frequently, yield when there's new output
230
+ last_yield = time.time()
231
+ while session.is_running():
232
+ time.sleep(0.5)
233
+ output = session.read_new_output()
234
+ if output:
235
+ print(f"Debug: New output for session {sid}:\n{output}")
236
+ yield f"[{sid}] Output:\n{output}"
237
+ last_yield = time.time()
238
+
239
+ # Final output
240
+ time.sleep(0.2)
241
+ final = session.read_new_output()
242
+ code = session.process.returncode
243
+ status = f"exited with code {code}" if code is not None else "exited"
244
+ if final:
245
+ yield f"[{sid}] Final ({status}):\n{final}"
246
+ else:
247
+ yield f"[{sid}] {status}"
248
+
249
+
250
+ SHELL_TOOL = Tool(
251
+ name="shell",
252
+ description="Run shell commands with streaming output. Supports interactive sessions — send input to running commands.",
253
+ parameters={
254
+ "type": "object",
255
+ "properties": {
256
+ "command": {
257
+ "type": "string",
258
+ "description": "The shell command to execute",
259
+ },
260
+ "session_id": {
261
+ "type": "string",
262
+ "description": "Session ID to send input to or get output from (omit to start new command)",
263
+ },
264
+ "input_text": {
265
+ "type": "string",
266
+ "description": "Text to send to running session's stdin",
267
+ },
268
+ "timeout": {
269
+ "type": "number",
270
+ "description": "Seconds between output updates (default 15)",
271
+ },
272
+ },
273
+ "required": ["command"],
274
+ },
275
+ handler=_shell_handler,
276
+ streamable=True,
277
+ )
app.py CHANGED
@@ -2,7 +2,7 @@ import time
2
  import uuid
3
  import gradio as gr
4
  from dotenv import load_dotenv
5
- from agent import Agent, FETCH_WEBPAGE_TOOL
6
  import os
7
  from pathlib import Path
8
 
@@ -31,7 +31,9 @@ agent = Agent(
31
  system_prompt=_SYSTEM_PROMPT,
32
  )
33
  agent.register_tool(FETCH_WEBPAGE_TOOL)
 
34
  agent.register_all_mcp()
 
35
  agent.register_final_message_tool()
36
 
37
  # Load JS from external files
@@ -156,13 +158,15 @@ class GradioEvents:
156
  # Grab the tool name from the existing message
157
  tool_name = display_messages[tool_call_idx]["metadata"]["title"].split("Used tool ")[-1]
158
  display_messages[tool_call_idx]["content"] = (
159
- f"```\n{tool_name}(...)\n```\n\n"
160
  f"**Output:**\n```\n{cc}\n```"
161
  )
162
  display_messages[tool_call_idx]["metadata"] = {
163
  "title": f"🛠️ {tool_name} — {len(ev['content'])} chars",
164
  }
165
- tool_call_idx = None
 
 
166
 
167
  elif t == "error":
168
  display_messages.append({
@@ -343,7 +347,8 @@ with gr.Blocks(fill_width=True, title="Demo Chat") as demo:
343
  elem_id="chatbot",
344
  show_label=False,
345
  buttons=[],
346
- layout="bubble"
 
347
  )
348
  with gr.Row(elem_id="input-row"):
349
  msg = gr.Textbox(
 
2
  import uuid
3
  import gradio as gr
4
  from dotenv import load_dotenv
5
+ from agent import Agent, FETCH_WEBPAGE_TOOL, SHELL_TOOL
6
  import os
7
  from pathlib import Path
8
 
 
31
  system_prompt=_SYSTEM_PROMPT,
32
  )
33
  agent.register_tool(FETCH_WEBPAGE_TOOL)
34
+ agent.register_tool(SHELL_TOOL)
35
  agent.register_all_mcp()
36
+ agent.register_read_tool()
37
  agent.register_final_message_tool()
38
 
39
  # Load JS from external files
 
158
  # Grab the tool name from the existing message
159
  tool_name = display_messages[tool_call_idx]["metadata"]["title"].split("Used tool ")[-1]
160
  display_messages[tool_call_idx]["content"] = (
161
+ f"```\n{tool_name}({ev['arguments']})\n```\n\n"
162
  f"**Output:**\n```\n{cc}\n```"
163
  )
164
  display_messages[tool_call_idx]["metadata"] = {
165
  "title": f"🛠️ {tool_name} — {len(ev['content'])} chars",
166
  }
167
+ # Only clear tool_call_idx if NOT partial (final output)
168
+ if not ev.get("partial"):
169
+ tool_call_idx = None
170
 
171
  elif t == "error":
172
  display_messages.append({
 
347
  elem_id="chatbot",
348
  show_label=False,
349
  buttons=[],
350
+ layout="bubble",
351
+ autoscroll=False
352
  )
353
  with gr.Row(elem_id="input-row"):
354
  msg = gr.Textbox(