KingNish commited on
Commit
ebe22dc
·
2 Parent(s): 42bd56d3cf0269

Merge MythosHarness history into OpenMythos

Browse files
.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ OPENAI_API_KEY=sk-your-api-key-here
2
+ OPENAI_BASE_URL=https://api.openai.com/v1
3
+ OPENAI_MODEL=gpt-3.5-turbo
.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+
10
+ # Virtual environment
11
+ .venv/
12
+ venv/
13
+
14
+ # Environment variables
15
+ .env
16
+
17
+ # IDE
18
+ .vscode/
19
+ .idea/
20
+
21
+ # OS
22
+ .DS_Store
23
+ Thumbs.db
24
+
25
+ # Gradio (caches temp files)
26
+ gradio_cached_examples/
agent/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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",
8
+ "Tool",
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 ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any, Generator
3
+
4
+ from openai import OpenAI
5
+
6
+ from .tools import Tool
7
+ from .mcp import MCPClient, load_mcp_tools, load_all_mcp_tools
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Streaming event contract
11
+ # ---------------------------------------------------------------------------
12
+ # Each yield from Agent.stream() is a dict with a "type" key:
13
+ #
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
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ class Agent:
24
+ """OpenAI-compatible tool-calling agent with streaming.
25
+
26
+ When ``register_final_message_tool()`` is used the model **must** call
27
+ ``final_message`` to signal completion — plain text responses without
28
+ the tool will keep the conversation loop alive.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ base_url: str,
34
+ api_key: str,
35
+ model: str,
36
+ max_iterations: int = 150,
37
+ system_prompt: str | None = None,
38
+ ) -> None:
39
+ self.client = OpenAI(base_url=base_url, api_key=api_key)
40
+ self.model = model
41
+ self._tools: list[Tool] = []
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
49
+ # ------------------------------------------------------------------
50
+
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
+
109
+ Returns the list of registered Tool instances.
110
+ """
111
+ tools = load_mcp_tools(url=url, headers=headers)
112
+ self._tools.extend(tools)
113
+ return tools
114
+
115
+ def register_all_mcp(self) -> dict[str, list[Tool]]:
116
+ """Load tools from all pre-configured MCP servers.
117
+
118
+ Returns ``{server_name: [Tool, ...]}`` and registers them.
119
+ """
120
+ all_tools = load_all_mcp_tools()
121
+ for server_tools in all_tools.values():
122
+ self._tools.extend(server_tools)
123
+ return all_tools
124
+
125
+ def register_final_message_tool(self) -> None:
126
+ """Register a no-input ``final_message`` tool the model **must** call
127
+ to signal that it is done.
128
+
129
+ Until the model calls this tool the agent keeps looping — plain
130
+ text responses or other tool calls will not end the conversation.
131
+
132
+ The tool call is handled internally: no ``tool_call`` /
133
+ ``tool_output`` events are yielded and the caller only sees a
134
+ ``done`` event.
135
+ """
136
+ self._final_tool_name = "final_message"
137
+ self._tools.append(
138
+ Tool(
139
+ name="final_message",
140
+ description=(
141
+ "Signal that you have completed your response and want "
142
+ "to end the conversation. Call this ONLY when you are "
143
+ "truly done. Until you call this tool, the conversation "
144
+ "will continue. Means you will multiple times answer the"
145
+ "same question or can get stuck in loops if you never call it."
146
+ ),
147
+ parameters={"type": "object", "properties": {}, "required": []},
148
+ handler=lambda: "",
149
+ )
150
+ )
151
+
152
+ # ------------------------------------------------------------------
153
+ # Streaming loop
154
+ # ------------------------------------------------------------------
155
+
156
+ def stream(self, messages: list[dict]) -> Generator[dict, None, None]:
157
+ """Yield streaming events until the model calls ``final_message``.
158
+
159
+ *messages* is mutated in-place — after the generator completes it
160
+ contains the full conversation history.
161
+ """
162
+ iteration = 0
163
+
164
+ # Inject system prompt once at the front if set
165
+ if self.system_prompt and (
166
+ not messages or messages[0].get("role") != "system"
167
+ ):
168
+ messages.insert(0, {"role": "system", "content": self.system_prompt})
169
+
170
+ while True:
171
+ iteration += 1
172
+ if iteration > self._max_iterations:
173
+ yield {
174
+ "type": "error",
175
+ "content": f"Agent did not call final_message after "
176
+ f"{self._max_iterations} iterations",
177
+ }
178
+ return
179
+
180
+ specs = [t.to_openai_spec() for t in self._tools] if self._tools else None
181
+
182
+ collected_content = ""
183
+ collected_tool_calls: dict[int, dict] = {}
184
+
185
+ kwargs: dict[str, Any] = dict(
186
+ model=self.model,
187
+ messages=messages,
188
+ stream=True,
189
+ extra_body={"thinking_token_budget": 2000}
190
+ )
191
+ if specs:
192
+ kwargs["tools"] = specs
193
+
194
+ try:
195
+ stream = self.client.chat.completions.create(**kwargs)
196
+ except Exception as exc:
197
+ yield {"type": "error", "content": str(exc)}
198
+ return
199
+
200
+ for chunk in stream:
201
+ cd = chunk.to_dict()
202
+ choices = cd.get("choices", [])
203
+ if not choices:
204
+ continue
205
+ delta = choices[0].get("delta", {})
206
+
207
+ reasoning = delta.get("reasoning_content") or delta.get("reasoning")
208
+ if reasoning:
209
+ yield {"type": "reasoning", "content": reasoning}
210
+
211
+ # Text content
212
+ content = delta.get("content", "")
213
+ if content:
214
+ collected_content += content
215
+ yield {"type": "text", "content": content}
216
+
217
+ # Tool call fragments
218
+ for tc in delta.get("tool_calls", []):
219
+ idx = tc.get("index", 0)
220
+ if idx not in collected_tool_calls:
221
+ collected_tool_calls[idx] = {
222
+ "id": tc.get("id", ""),
223
+ "function": {"name": "", "arguments": ""},
224
+ }
225
+ if tc.get("id"):
226
+ collected_tool_calls[idx]["id"] = tc["id"]
227
+ fn = tc.get("function", {})
228
+ if fn.get("name"):
229
+ collected_tool_calls[idx]["function"]["name"] += fn["name"]
230
+ if fn.get("arguments"):
231
+ collected_tool_calls[idx]["function"]["arguments"] += fn[
232
+ "arguments"
233
+ ]
234
+
235
+ # --- Handle tool calls ---
236
+ if collected_tool_calls:
237
+ tool_call_list: list[dict[str, Any]] = []
238
+ for idx in sorted(collected_tool_calls.keys()):
239
+ tc = collected_tool_calls[idx]
240
+ tool_call_list.append(
241
+ {
242
+ "id": tc["id"],
243
+ "type": "function",
244
+ "function": {
245
+ "name": tc["function"]["name"],
246
+ "arguments": tc["function"]["arguments"],
247
+ },
248
+ }
249
+ )
250
+
251
+ # Assistant message with tool_calls (appended before we decide
252
+ # whether to continue or stop so the conversation is coherent)
253
+ assistant_msg: dict[str, Any] = {
254
+ "role": "assistant",
255
+ "content": collected_content or None,
256
+ }
257
+ assistant_msg["tool_calls"] = tool_call_list
258
+ messages.append(assistant_msg)
259
+
260
+ # --- final_message check (handled internally) ---
261
+ if self._final_tool_name:
262
+ for tc_spec in tool_call_list:
263
+ if tc_spec["function"]["name"] == self._final_tool_name:
264
+ # Dummy tool result so history stays well-formed
265
+ messages.append(
266
+ {
267
+ "role": "tool",
268
+ "tool_call_id": tc_spec["id"],
269
+ "content": "",
270
+ }
271
+ )
272
+ messages.append(
273
+ {"role": "assistant", "content": collected_content}
274
+ )
275
+ yield {"type": "done", "content": collected_content}
276
+ return
277
+
278
+ # --- Execute real tools ---
279
+ for tc_spec in tool_call_list:
280
+ tname = tc_spec["function"]["name"]
281
+ try:
282
+ targs = json.loads(tc_spec["function"]["arguments"])
283
+ except json.JSONDecodeError:
284
+ targs = {}
285
+
286
+ yield {
287
+ "type": "tool_call",
288
+ "name": tname,
289
+ "arguments": tc_spec["function"]["arguments"],
290
+ }
291
+
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
+
400
+ # --- No tool calls ---
401
+ if self._final_tool_name:
402
+ # final_message is expected but wasn't called — keep the
403
+ # conversation loop alive so the model gets another chance
404
+ messages.append(
405
+ {"role": "assistant", "content": collected_content}
406
+ )
407
+ continue # Loop back
408
+
409
+ # No final_message tool registered — normal end
410
+ messages.append({"role": "assistant", "content": collected_content})
411
+ yield {"type": "done", "content": collected_content}
412
+ break
agent/mcp.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MCP (Model Context Protocol) client for remote tool servers.
2
+
3
+ Supports Streamable HTTP transport (JSON-RPC 2.0 over HTTP).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import uuid
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from .tools import Tool
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # MCP Client
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ class MCPClient:
23
+ """Connect to a remote MCP server via Streamable HTTP transport."""
24
+
25
+ def __init__(
26
+ self,
27
+ url: str,
28
+ headers: dict[str, str] | None = None,
29
+ timeout: float = 30.0,
30
+ ) -> None:
31
+ self.url = url
32
+ self.headers = headers or {}
33
+ self.timeout = timeout
34
+ self._session_id: str | None = None
35
+ self._initialized = False
36
+
37
+ # ------------------------------------------------------------------
38
+ # Low-level JSON-RPC
39
+ # ------------------------------------------------------------------
40
+
41
+ def _request(self, method: str, params: dict[str, Any] | None = None) -> Any:
42
+ """Send a JSON-RPC request and return the result."""
43
+ payload: dict[str, Any] = {
44
+ "jsonrpc": "2.0",
45
+ "id": str(uuid.uuid4()),
46
+ "method": method,
47
+ }
48
+ if params is not None:
49
+ payload["params"] = params
50
+
51
+ headers = {
52
+ "Content-Type": "application/json",
53
+ "Accept": "application/json, text/event-stream",
54
+ **self.headers,
55
+ }
56
+ if self._session_id:
57
+ headers["Mcp-Session-Id"] = self._session_id
58
+
59
+ with httpx.Client(timeout=self.timeout) as client:
60
+ resp = client.post(self.url, json=payload, headers=headers)
61
+
62
+ # Capture session ID from response
63
+ sid = resp.headers.get("mcp-session-id")
64
+ if sid:
65
+ self._session_id = sid
66
+
67
+ resp.raise_for_status()
68
+ content_type = resp.headers.get("content-type", "")
69
+
70
+ # Handle SSE response
71
+ if "text/event-stream" in content_type:
72
+ return self._parse_sse_response(resp.text)
73
+
74
+ # Handle JSON response
75
+ data = resp.json()
76
+ if "error" in data:
77
+ raise RuntimeError(f"MCP error: {data['error']}")
78
+ return data.get("result")
79
+
80
+ def _parse_sse_response(self, text: str) -> Any:
81
+ """Parse SSE response text, extracting JSON-RPC results."""
82
+ result = None
83
+ for line in text.split("\n"):
84
+ line = line.strip()
85
+ if line.startswith("data:"):
86
+ data_str = line[len("data:") :].strip()
87
+ if data_str:
88
+ try:
89
+ data = json.loads(data_str)
90
+ if "result" in data:
91
+ result = data["result"]
92
+ elif "error" in data:
93
+ raise RuntimeError(f"MCP error: {data['error']}")
94
+ except json.JSONDecodeError:
95
+ continue
96
+ return result
97
+
98
+ # ------------------------------------------------------------------
99
+ # Lifecycle
100
+ # ------------------------------------------------------------------
101
+
102
+ def initialize(self) -> dict[str, Any]:
103
+ """Initialize the MCP session."""
104
+ result = self._request(
105
+ "initialize",
106
+ {
107
+ "protocolVersion": "2025-03-26",
108
+ "capabilities": {},
109
+ "clientInfo": {"name": "mythos-agent", "version": "0.1.0"},
110
+ },
111
+ )
112
+ self._initialized = True
113
+ # Send initialized notification (no response expected)
114
+ self._notify("notifications/initialized", {})
115
+ return result or {}
116
+
117
+ def _notify(self, method: str, params: dict[str, Any]) -> None:
118
+ """Send a JSON-RPC notification (no id, no response expected)."""
119
+ payload = {"jsonrpc": "2.0", "method": method, "params": params}
120
+ headers = {
121
+ "Content-Type": "application/json",
122
+ "Accept": "application/json, text/event-stream",
123
+ **self.headers,
124
+ }
125
+ if self._session_id:
126
+ headers["Mcp-Session-Id"] = self._session_id
127
+
128
+ with httpx.Client(timeout=self.timeout) as client:
129
+ client.post(self.url, json=payload, headers=headers)
130
+
131
+ # ------------------------------------------------------------------
132
+ # Tool discovery
133
+ # ------------------------------------------------------------------
134
+
135
+ def list_tools(self) -> list[dict[str, Any]]:
136
+ """List available tools from the server."""
137
+ if not self._initialized:
138
+ self.initialize()
139
+ result = self._request("tools/list", {})
140
+ return (result or {}).get("tools", [])
141
+
142
+ # ------------------------------------------------------------------
143
+ # Tool execution
144
+ # ------------------------------------------------------------------
145
+
146
+ def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
147
+ """Call a tool on the server."""
148
+ if not self._initialized:
149
+ self.initialize()
150
+ return self._request("tools/call", {"name": name, "arguments": arguments})
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # MCP Tool wrapper
155
+ # ---------------------------------------------------------------------------
156
+
157
+
158
+ def _json_schema_type_to_python(type_str: str) -> type:
159
+ """Map JSON Schema type to Python type."""
160
+ mapping = {"string": str, "integer": int, "number": float, "boolean": bool, "array": list, "object": dict}
161
+ return mapping.get(type_str, str)
162
+
163
+
164
+ def mcp_tool(client: MCPClient, mcp_tool_def: dict[str, Any]) -> Tool:
165
+ """Wrap an MCP tool definition as a local Tool instance.
166
+
167
+ Args:
168
+ client: Connected MCPClient.
169
+ mcp_tool_def: Tool dict from ``tools/list`` response.
170
+ """
171
+ name = mcp_tool_def["name"]
172
+ description = mcp_tool_def.get("description", "")
173
+ input_schema = mcp_tool_def.get("inputSchema", {})
174
+
175
+ # Convert inputSchema to OpenAI-style parameters
176
+ properties: dict[str, dict] = {}
177
+ for param_name, param_def in input_schema.get("properties", {}).items():
178
+ prop: dict[str, Any] = {"type": param_def.get("type", "string")}
179
+ if "description" in param_def:
180
+ prop["description"] = param_def["description"]
181
+ if "enum" in param_def:
182
+ prop["enum"] = param_def["enum"]
183
+ properties[param_name] = prop
184
+
185
+ required = input_schema.get("required", [])
186
+
187
+ parameters = {
188
+ "type": "object",
189
+ "properties": properties,
190
+ "required": required,
191
+ }
192
+
193
+ def handler(**kwargs: Any) -> str:
194
+ result = client.call_tool(name, kwargs)
195
+ # Extract text content from MCP result
196
+ if isinstance(result, dict) and "content" in result:
197
+ parts = []
198
+ for block in result["content"]:
199
+ if isinstance(block, dict) and block.get("type") == "text":
200
+ parts.append(block.get("text", ""))
201
+ return "\n".join(parts) if parts else json.dumps(result)
202
+ return json.dumps(result) if not isinstance(result, str) else result
203
+
204
+ return Tool(name=name, description=description, parameters=parameters, handler=handler)
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Convenience: connect and load all tools
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ def load_mcp_tools(url: str, headers: dict[str, str] | None = None) -> list[Tool]:
213
+ """Connect to an MCP server and return all its tools as Tool instances."""
214
+ client = MCPClient(url=url, headers=headers)
215
+ client.initialize()
216
+ tools_defs = client.list_tools()
217
+ return [mcp_tool(client, td) for td in tools_defs]
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Pre-configured MCP servers
222
+ # ---------------------------------------------------------------------------
223
+
224
+ MCP_SERVERS: dict[str, dict[str, Any]] = {
225
+ "exa": {
226
+ "url": "https://mcp.exa.ai/mcp",
227
+ "description": "Web search via Exa",
228
+ },
229
+ "context7": {
230
+ "url": "https://mcp.context7.com/mcp",
231
+ "description": "Library documentation lookup",
232
+ },
233
+ "grep_app": {
234
+ "url": "https://mcp.grep.app",
235
+ "description": "GitHub code search via grep.app",
236
+ },
237
+ }
238
+
239
+
240
+ def load_all_mcp_tools(
241
+ servers: dict[str, dict[str, Any]] | None = None,
242
+ ) -> dict[str, list[Tool]]:
243
+ """Load tools from all configured MCP servers.
244
+
245
+ Returns:
246
+ ``{server_name: [Tool, ...]}``
247
+ """
248
+ if servers is None:
249
+ servers = MCP_SERVERS
250
+
251
+ result: dict[str, list[Tool]] = {}
252
+ for name, cfg in servers.items():
253
+ try:
254
+ result[name] = load_mcp_tools(
255
+ url=cfg["url"],
256
+ headers=cfg.get("headers"),
257
+ )
258
+ except Exception as e:
259
+ print(f"[MCP] Failed to load {name}: {e}")
260
+ result[name] = []
261
+ return result
agent/responses.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Streaming event types yielded by Agent.stream().
3
+
4
+ Each event is a plain dict with at least a ``"type"`` key.
5
+ """
6
+
7
+ # ── Text chunk ──────────────────────────────────────────────────────────
8
+ # {"type": "text", "content": "partial assistant message"}
9
+ TEXT = "text"
10
+
11
+ # ── Reasoning (e.g. DeepSeek R1 chain-of-thought) ──────────────────────
12
+ # {"type": "reasoning", "content": "model thinking"}
13
+ REASONING = "reasoning"
14
+
15
+ # ── Tool was called by the model ────────────────────────────────────────
16
+ # {"type": "tool_call", "name": "fetch_webpage", "arguments": '{"url":"..."}'}
17
+ TOOL_CALL = "tool_call"
18
+
19
+ # ── Tool execution output ───────────────────────────────────────────────
20
+ # {"type": "tool_output", "name": "fetch_webpage", "content": "<html>..."}
21
+ TOOL_OUTPUT = "tool_output"
22
+
23
+ # ── Final response (no more tool calls) ─────────────────────────────────
24
+ # {"type": "done", "content": "full assistant message"}
25
+ DONE = "done"
26
+
27
+ # ── Fatal error ─────────────────────────────────────────────────────────
28
+ # {"type": "error", "content": "API key invalid"}
29
+ ERROR = "error"
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 ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from markitdown import MarkItDown
2
+ from typing import Any, Callable, Generator, get_type_hints
3
+ import inspect
4
+ import requests
5
+
6
+
7
+ def python_type_to_json_schema(tp: type) -> str:
8
+ """Map a Python type to a JSON Schema type string."""
9
+ mapping = {
10
+ str: "string",
11
+ int: "integer",
12
+ float: "number",
13
+ bool: "boolean",
14
+ list: "array",
15
+ dict: "object",
16
+ }
17
+ return mapping.get(tp, "string")
18
+
19
+ class Tool:
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 {
38
+ "type": "function",
39
+ "function": {
40
+ "name": self.name,
41
+ "description": self.description,
42
+ "parameters": self.parameters,
43
+ },
44
+ }
45
+
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.
66
+
67
+ Returns:
68
+ (description, {param_name: (required, description)})
69
+
70
+ Expected format:
71
+ First line: tool description.
72
+ Subsequent lines: ``param_name (required): description`` or
73
+ ``param_name: description``.
74
+ """
75
+ lines = (docstring or "").strip().split("\n")
76
+ description = lines[0].strip()
77
+ param_info: dict[str, tuple[bool, str]] = {}
78
+
79
+ for line in lines[1:]:
80
+ line = line.strip()
81
+ if not line:
82
+ continue
83
+ # Match: param_name (required): description
84
+ # or: param_name: description
85
+ if ":" not in line:
86
+ continue
87
+ key, desc = line.split(":", 1)
88
+ key = key.strip()
89
+ desc = desc.strip()
90
+ required = False
91
+ if key.endswith("(required)"):
92
+ required = True
93
+ key = key[: -len("(required)")].strip()
94
+ if key:
95
+ param_info[key] = (required, desc)
96
+
97
+ return description, param_info
98
+
99
+
100
+ def tool(fn: Callable[..., str]) -> Tool:
101
+ """Decorator that converts a function into a Tool instance.
102
+
103
+ Extracts name, description (first line of docstring), and parameters
104
+ from the function's type hints and signature.
105
+
106
+ Docstring format:
107
+ First line: tool description.
108
+ Subsequent lines: ``param_name (required): description`` or
109
+ ``param_name: description``.
110
+ """
111
+ name = fn.__name__
112
+ docstring = fn.__doc__ or ""
113
+ description, param_info = _parse_docstring(docstring)
114
+
115
+ hints = get_type_hints(fn)
116
+ sig = inspect.signature(fn)
117
+
118
+ properties: dict[str, dict] = {}
119
+ required: list[str] = []
120
+
121
+ for param_name, param in sig.parameters.items():
122
+ if param_name in hints:
123
+ param_schema: dict[str, Any] = {
124
+ "type": python_type_to_json_schema(hints[param_name])
125
+ }
126
+ # Enrich with docstring info if present
127
+ if param_name in param_info:
128
+ doc_required, doc_desc = param_info[param_name]
129
+ if doc_desc:
130
+ param_schema["description"] = doc_desc
131
+ # Docstring (required) overrides signature default check
132
+ if doc_required:
133
+ required.append(param_name)
134
+ elif param.default is inspect.Parameter.empty:
135
+ required.append(param_name)
136
+ elif param.default is inspect.Parameter.empty:
137
+ required.append(param_name)
138
+ properties[param_name] = param_schema
139
+
140
+ parameters = {
141
+ "type": "object",
142
+ "properties": properties,
143
+ "required": required,
144
+ }
145
+
146
+ return Tool(
147
+ name=name,
148
+ description=description,
149
+ parameters=parameters,
150
+ handler=fn,
151
+ )
152
+
153
+
154
+ @tool
155
+ def fetch_webpage(url: str) -> str:
156
+ """Fetch a webpage and return its text content.
157
+
158
+ url (required): The URL to fetch
159
+ """
160
+ try:
161
+ jina_ai_url = "https://r.jina.ai/"
162
+ response = requests.get(jina_ai_url + url)
163
+ response.raise_for_status()
164
+ return response.text
165
+ except Exception as e:
166
+ md = MarkItDown()
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
+ ) -> str:
186
+ """Run shell commands interactively.
187
+
188
+ command (required): The shell command to execute (omit when checking output or sending input)
189
+ session_id: Session ID to check output or send input to (omit to start new command)
190
+ input_text: Text to send to running session's stdin
191
+
192
+ How it works:
193
+ - Start new command: returns session_id immediately (non-blocking)
194
+ - Check output: call with session_id to get current output
195
+ - Send input: call with session_id + input_text
196
+ - Command runs in background, never blocks the agent
197
+ """
198
+ manager = get_shell_manager()
199
+
200
+ existing_session = session_id and session_id in manager.sessions
201
+
202
+ # Send input to running session
203
+ if existing_session and input_text:
204
+ sent = manager.send_input(session_id, input_text)
205
+ if not sent:
206
+ return f"Error: Session '{session_id}' closed"
207
+ time.sleep(0.3)
208
+ output = manager.poll_output(session_id)
209
+ running = manager.is_running(session_id)
210
+ status = "running" if running else f"exited (code {manager.sessions[session_id].returncode})"
211
+ if output:
212
+ return f"[{session_id}] {status}:\n{output}"
213
+ return f"[{session_id}] {status} (no new output)"
214
+
215
+ # Check output of existing session
216
+ if existing_session:
217
+ output = manager.get_output(session_id)
218
+ running = manager.is_running(session_id)
219
+ code = manager.sessions[session_id].returncode
220
+ status = "running" if running else f"exited with code {code}"
221
+ if output:
222
+ return f"[{session_id}] {status}:\n{output}"
223
+ return f"[{session_id}] {status}"
224
+
225
+ # Start new command
226
+ sid = session_id or str(_uuid.uuid4())[:8]
227
+ session = manager.start(sid, command)
228
+
229
+ # Wait a bit to capture initial output (fast commands finish here)
230
+ time.sleep(0.5)
231
+ initial = session.read_new_output()
232
+ running = session.is_running()
233
+
234
+ if not running:
235
+ # Command finished quickly
236
+ code = session.process.returncode
237
+ final = session.read_new_output()
238
+ output = (initial + final).strip()
239
+ if output:
240
+ return f"[{sid}] exited with code {code}:\n{output}"
241
+ return f"[{sid}] exited with code {code}"
242
+
243
+ # Command still running — return status so model can check later
244
+ if initial:
245
+ return f"[{sid}] running (PID {session.pid}):\n{initial}\n\nCall again with session_id=\"{sid}\" to check output."
246
+ return f"[{sid}] running (PID {session.pid})\n\nCall again with session_id=\"{sid}\" to check output."
247
+
248
+
249
+ SHELL_TOOL = Tool(
250
+ name="shell",
251
+ description="Run shell commands interactively. Start command -> returns session_id. Call again with session_id to check output or send input. Never blocks.",
252
+ parameters={
253
+ "type": "object",
254
+ "properties": {
255
+ "command": {
256
+ "type": "string",
257
+ "description": "The shell command to execute (omit when checking output or sending input)",
258
+ },
259
+ "session_id": {
260
+ "type": "string",
261
+ "description": "Session ID to check output or send input to (omit to start new command)",
262
+ },
263
+ "input_text": {
264
+ "type": "string",
265
+ "description": "Text to send to running session's stdin",
266
+ },
267
+ },
268
+ "required": ["command"],
269
+ },
270
+ handler=_shell_handler,
271
+ streamable=False,
272
+ )
app.css ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
2
+
3
+ .gradio-container, .main {
4
+ padding: 0 !important;
5
+ --layout-gap: 0 !important;
6
+ }
7
+
8
+ footer {
9
+ display: none !important;
10
+ }
11
+
12
+ html {
13
+ scroll-behavior: smooth;
14
+ }
15
+
16
+ #main-row {
17
+ height: 100vh;
18
+ max-height: 1500px;
19
+ }
20
+
21
+ button[aria-label="Clear"] {
22
+ display: none;
23
+ }
24
+
25
+ #chat-column {
26
+ height: 100%;
27
+ display: flex;
28
+ flex-direction: column;
29
+ padding: 0;
30
+ overflow: hidden;
31
+ position: relative;
32
+ }
33
+
34
+ #chatbot {
35
+ flex: 1;
36
+ min-height: 100px;
37
+ border: 0;
38
+ }
39
+
40
+ .sidebar {
41
+ border-right: 1px solid var(--border-color-primary);
42
+ box-shadow: none;
43
+ }
44
+
45
+ .sidebar-parent:has(.sidebar.open:not(.right)) {
46
+ padding-left: 320px;
47
+ }
48
+
49
+ .sidebar-content {
50
+ position: relative;
51
+ padding: 50px 0 0 0 !important;
52
+ background: none;
53
+ }
54
+
55
+ .sidebar-content::before {
56
+ content: "";
57
+ position: absolute;
58
+ inset: 0;
59
+ background: url("/gradio_api/file=static/svg/logo.svg") no-repeat top;
60
+ background-size: 180px 52px;
61
+ pointer-events: none;
62
+ z-index: 0;
63
+ }
64
+
65
+ .toggle-button {
66
+ top: 10px;
67
+ }
68
+
69
+ /* New Conversation button - top spacing */
70
+ .sidebar > :first-child {
71
+ margin-bottom: 8px;
72
+ }
73
+
74
+ /* Sidebar buttons (New/Del) */
75
+ #main-row .sidebar button {
76
+ transition: all 0.15s ease;
77
+ }
78
+
79
+ #main-row .sidebar button:hover {
80
+ transform: translateY(-1px);
81
+ }
82
+
83
+ .sidebar-content .form {
84
+ border: 0;
85
+ background: transparent;
86
+ }
87
+
88
+ .wrap {
89
+ gap: 0;
90
+ }
91
+
92
+ /* Conversation items — card-style list */
93
+ #conversations-radio {
94
+ padding: 0;
95
+ border: 0;
96
+ background: transparent;
97
+ }
98
+
99
+ #conversations-radio > div:last-child {
100
+ display: grid;
101
+ gap: 2px;
102
+ }
103
+
104
+ #conversations-radio input[type="radio"] {
105
+ opacity: 0;
106
+ position: absolute;
107
+ width: 0;
108
+ height: 0;
109
+ }
110
+
111
+ #conversations-radio span[data-testid="block-info"] {
112
+ display: none;
113
+ }
114
+
115
+ #conversations-radio label {
116
+ padding: 10px 14px;
117
+ margin: 0;
118
+ cursor: pointer;
119
+ display: flex;
120
+ align-items: center;
121
+ transition: all 0.2s ease;
122
+ overflow: hidden;
123
+ text-overflow: ellipsis;
124
+ white-space: nowrap;
125
+ background: transparent;
126
+ color: var(--button-secondary-text-color);
127
+ border-bottom: 1px solid var(--border-color-primary);
128
+ }
129
+
130
+ label span {
131
+ margin: 0;
132
+ }
133
+
134
+ .icon-button-wrapper {
135
+ display: hidden;
136
+ }
137
+
138
+ /* --- Chat Message Animations --- */
139
+ @keyframes messageSlideIn {
140
+ from {
141
+ opacity: 0;
142
+ transform: translateY(16px);
143
+ }
144
+ to {
145
+ opacity: 1;
146
+ transform: translateY(0);
147
+ }
148
+ }
149
+
150
+ /* Animate new messages as they appear */
151
+ #chatbot [data-testid="bot"],
152
+ #chatbot [data-testid="user"],
153
+ #chatbot [class*="message"] {
154
+ animation: messageSlideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1);
155
+ }
156
+
157
+ /* --- Input Area — Docked Bottom --- */
158
+
159
+
160
+ /* --- Send / Stop Button Micro-animations --- */
161
+ #send-btn, #stop-btn {
162
+ font-size: 0;
163
+ height: 100%;
164
+ min-width: 40px;
165
+ transition: transform 0.15s ease, opacity 0.15s ease;
166
+ cursor: pointer;
167
+ padding: 0;
168
+ }
169
+
170
+ #send-btn::after {
171
+ content: "";
172
+ display: inline-block;
173
+ width: 20px;
174
+ height: 20px;
175
+ background: url("/gradio_api/file=static/svg/send.svg") no-repeat center;
176
+ background-size: contain;
177
+ }
178
+
179
+ #stop-btn::after {
180
+ content: "";
181
+ display: inline-block;
182
+ width: 20px;
183
+ height: 20px;
184
+ background: url("/gradio_api/file=static/svg/stop.svg") no-repeat center;
185
+ background-size: contain;
186
+ }
187
+
188
+ /* --- Custom Scrollbars --- */
189
+ #chatbot::-webkit-scrollbar {
190
+ width: 6px;
191
+ }
192
+
193
+ #chatbot::-webkit-scrollbar-track {
194
+ background: transparent;
195
+ }
196
+
197
+ #chatbot::-webkit-scrollbar-thumb {
198
+ background: var(--border-color-primary);
199
+ transition: background 0.2s ease;
200
+ }
201
+
202
+ #chatbot::-webkit-scrollbar-thumb:hover {
203
+ background: var(--neutral-400);
204
+ }
205
+
206
+ /* Sidebar scrollbar — thinner, subtle */
207
+ .sidebar::-webkit-scrollbar {
208
+ width: 4px;
209
+ }
210
+
211
+ .sidebar::-webkit-scrollbar-track {
212
+ background: transparent;
213
+ }
214
+
215
+ .sidebar::-webkit-scrollbar-thumb {
216
+ background: var(--border-color-primary);
217
+ border-radius: 2px;
218
+ }
219
+
220
+ .input-container textarea {
221
+ background: var(--background-fill-secondary) !important;
222
+ }
223
+
224
+ .bubble-wrap {
225
+ background: var(--background-fill-primary) !important;
226
+ }
227
+
228
+ /* ============================================
229
+ LANDING PAGE - Terminal-inspired Design
230
+ ============================================ */
231
+
232
+ #landing-page-container {
233
+ position: absolute;
234
+ top: 0;
235
+ left: 0;
236
+ right: 0;
237
+ bottom: 0;
238
+ display: flex;
239
+ align-items: center;
240
+ justify-content: center;
241
+ background: var(--background-fill-primary);
242
+ z-index: 10;
243
+ pointer-events: auto;
244
+ }
245
+
246
+ #landing-page {
247
+ width: 100%;
248
+ height: 100%;
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: center;
252
+ }
253
+
254
+ .landing-content {
255
+ display: flex;
256
+ flex-direction: column;
257
+ align-items: center;
258
+ gap: 32px;
259
+ padding: 40px;
260
+ max-width: 600px;
261
+ width: 100%;
262
+ }
263
+
264
+ /* Logo styling */
265
+ .landing-logo {
266
+ margin-bottom: 100px !important;
267
+ }
268
+
269
+ .landing-logo svg {
270
+ height: 60px;
271
+ width: auto;
272
+ }
273
+
274
+ /* Hide landing page when chat has messages */
275
+ #landing-page-container.hidden {
276
+ display: none !important;
277
+ }
278
+
279
+ .landing-prompt {
280
+ bottom: 5%;
281
+ position: absolute;
282
+ text-align: center;
283
+ }
284
+
285
+ body:not(.dark) .landing-logo img, body:not(.dark) .sidebar-content::before {
286
+ filter: invert(0.8);
287
+ }
288
+
289
+ /* ============================================
290
+ INPUT ROW - Terminal-style Global
291
+ ============================================ */
292
+
293
+ /* When landing page is visible, center the input row */
294
+ #chat-column.landing-active #input-row {
295
+ position: absolute;
296
+ top: 50%;
297
+ left: 50%;
298
+ transform: translateX(-50%);
299
+ width: 100%;
300
+ max-width: 600px;
301
+ z-index: 20;
302
+ }
303
+
304
+ #chat-column.landing-active #send-btn,
305
+ #chat-column.landing-active #stop-btn {
306
+ align-self: stretch;
307
+ min-width: 56px;
308
+ padding: 0 8px;
309
+ height: auto;
310
+ }
311
+
312
+ /* When landing page is hidden (chat active), keep input at bottom */
313
+ #chat-column:not(.landing-active) #input-row {
314
+ position: relative;
315
+ bottom: 3%;
316
+ left: auto;
317
+ transform: none;
318
+ max-width: none;
319
+ }
320
+
321
+ /* Terminal-style input row */
322
+ #input-row {
323
+ z-index: 15;
324
+ position: relative;
325
+ }
326
+
327
+ #input-row .form {
328
+ background: rgba(255, 255, 255, 0.03) !important;
329
+ border: 1px solid rgba(255, 255, 255, 0.1) !important;
330
+ border-radius: 8px !important;
331
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace !important;
332
+ transition: all 0.2s ease !important;
333
+ }
334
+
335
+ #input-row .form:focus-within {
336
+ border-color: rgba(255, 255, 255, 0.3) !important;
337
+ background: rgba(255, 255, 255, 0.05) !important;
338
+ box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.05) !important;
339
+ }
340
+
341
+ /* Terminal cursor effect on input */
342
+ #input-row textarea,
343
+ #input-row input[type="text"] {
344
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace !important;
345
+ caret-color: var(--body-text-color) !important;
346
+ caret-shape: block !important;
347
+ }
348
+
349
+ /* Blinking cursor animation for input */
350
+ @keyframes inputCursorBlink {
351
+ 0%, 50% { border-right-color: var(--body-text-color); }
352
+ 51%, 100% { border-right-color: transparent; }
353
+ }
354
+
355
+ /* Responsive adjustments */
356
+ @media (max-width: 768px) {
357
+ .landing-content {
358
+ padding: 20px;
359
+ gap: 24px;
360
+ }
361
+
362
+ .landing-logo svg {
363
+ height: 40px;
364
+ }
365
+ }
366
+
367
+ /* Center chat column on larger screens */
368
+ @media (min-width: 749px) {
369
+ #chat-column {
370
+ max-width: 748px;
371
+ margin: 0 auto;
372
+ }
373
+ }
374
+
375
+ /* Apply monospace font to body for terminal feel */
376
+ body, .gradio-container {
377
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace !important;
378
+ }
379
+
380
+ /* Chatbot styling when empty */
381
+ #chatbot:empty,
382
+ #chatbot .empty-state {
383
+ display: flex;
384
+ align-items: center;
385
+ justify-content: center;
386
+ height: 100%;
387
+ }
388
+
389
+ /* Ensure proper z-index stacking */
390
+ #chatbot {
391
+ z-index: 5;
392
+ }
393
+
394
+ .message-wrap {
395
+ margin-bottom: 40px;
396
+ }
app.py ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
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
+
9
+ load_dotenv()
10
+ gr.set_static_paths("static/")
11
+
12
+ _SYSTEM_PROMPT = """\
13
+ You are OpenMythos, a powerful AI agent specialized in cybersecurity-related tasks.
14
+
15
+ You have access to tools that you can use to accomplish your goals.
16
+
17
+ === IMPORTANT: How to end the conversation ===
18
+ You MUST call the `final_message` tool when you have completed your response and want to end.
19
+ If you do NOT call `final_message`, you will be stuck in a loop:
20
+ - You respond → system waits for final_message → you did not call it
21
+ - → system sends your response back to you → you must respond again
22
+ - → this repeats until you call `final_message`
23
+ To break out of the loop, simply call `final_message` with no arguments.
24
+ Only call `final_message` when you are done or already responded or stuck in a loop.
25
+ """
26
+
27
+ agent = Agent(
28
+ base_url=os.getenv("OPENAI_BASE_URL"),
29
+ api_key=os.getenv("OPENAI_API_KEY"),
30
+ model=os.getenv("OPENAI_MODEL"),
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
40
+ _js_dir = Path(__file__).parent / "static" / "js"
41
+ JS_LOAD_STATE = (_js_dir / "storage.load.js").read_text()
42
+ JS_SAVE_STATE = (_js_dir / "storage.save.js").read_text()
43
+ LANDING_PAGE_SCRIPT = (_js_dir / "landing.js").read_text()
44
+
45
+
46
+ def _conv_choices(state_value):
47
+ convs = sorted(state_value["conversations"], key=lambda c: c.get("last_updated", 0), reverse=True)
48
+ return gr.update(
49
+ choices=[(c["label"], c["key"]) for c in convs],
50
+ value=state_value.get("conversation_id") or None,
51
+ )
52
+
53
+
54
+ class GradioEvents:
55
+ """Event handlers for the chatbot UI."""
56
+
57
+ @staticmethod
58
+ def stream_response(message, state_value):
59
+ """Stream a chat completion into the active conversation."""
60
+ if not message or not message.strip():
61
+ yield gr.skip()
62
+ return
63
+
64
+ if not state_value.get("conversation_id"):
65
+ conv_id = str(uuid.uuid4())
66
+ state_value["conversation_id"] = conv_id
67
+ state_value["conversations"].append(
68
+ {"label": message[:30], "key": conv_id, "last_updated": int(time.time() * 1000)})
69
+ state_value["conversation_contexts"][conv_id] = {
70
+ "history": []
71
+ }
72
+ else:
73
+ conv_id = state_value["conversation_id"]
74
+ state_value["conversation_contexts"].setdefault(
75
+ conv_id, {"history": []})
76
+ # Update last_updated for existing conversation
77
+ for c in state_value["conversations"]:
78
+ if c["key"] == conv_id:
79
+ c["last_updated"] = int(time.time() * 1000)
80
+ break
81
+
82
+ ctx = state_value["conversation_contexts"][conv_id]
83
+
84
+ for c in state_value["conversations"]:
85
+ if c["key"] == conv_id and not c.get("label"):
86
+ c["label"] = message[:30]
87
+ break
88
+
89
+ ctx["history"].append({"role": "user", "content": message})
90
+
91
+ yield { msg: gr.update(value=""), chatbot: gr.update(value=ctx["history"]), state: gr.update(value=state_value), conv_choice: _conv_choices(state_value), send_btn: gr.update(visible=False), stop_btn: gr.update(visible=True)}
92
+
93
+ # Build display as separate titled messages (smolagents style)
94
+ display_messages: list[dict] = list(ctx["history"])
95
+ text_msg_idx: int | None = None
96
+ thinking_msg_idx: int | None = None
97
+ tool_call_idx: int | None = None
98
+ spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
99
+ spinner_idx = 0
100
+
101
+ try:
102
+ for ev in agent.stream(ctx["history"]):
103
+ t = ev["type"]
104
+
105
+ if t == "reasoning":
106
+ spinner_idx += 1
107
+ content = f"<span class=\"thinking-indicator\">{spinner_frames[spinner_idx % len(spinner_frames)]} Thinking...</span>"
108
+ if thinking_msg_idx is not None:
109
+ display_messages[thinking_msg_idx]["content"] = content
110
+ else:
111
+ display_messages.append({"role": "assistant", "content": content, "metadata": {}})
112
+ thinking_msg_idx = len(display_messages) - 1
113
+
114
+ elif t == "text":
115
+ # Remove spinner message if showing — it was a separate bubble
116
+ if thinking_msg_idx is not None:
117
+ display_messages.pop(thinking_msg_idx)
118
+ thinking_msg_idx = None
119
+ # Re-index since we popped
120
+ if text_msg_idx is not None and thinking_msg_idx is not None:
121
+ if text_msg_idx > thinking_msg_idx:
122
+ text_msg_idx -= 1
123
+
124
+ if text_msg_idx is not None:
125
+ display_messages[text_msg_idx]["content"] += ev["content"]
126
+ else:
127
+ display_messages.append({"role": "assistant", "content": ev["content"], "metadata": {}})
128
+ text_msg_idx = len(display_messages) - 1
129
+
130
+ elif t == "tool_call":
131
+ # Remove spinner if present
132
+ if thinking_msg_idx is not None:
133
+ display_messages.pop(thinking_msg_idx)
134
+ if text_msg_idx is not None and text_msg_idx > thinking_msg_idx:
135
+ text_msg_idx -= 1
136
+ thinking_msg_idx = None
137
+
138
+ # Finalize any in-flight text message (keep if it has real content)
139
+ if text_msg_idx is not None:
140
+ c = display_messages[text_msg_idx].get("content", "").strip()
141
+ if not c:
142
+ display_messages.pop(text_msg_idx)
143
+ text_msg_idx = None
144
+ tool_call_idx = None
145
+
146
+ tool_name = ev["name"]
147
+ display_messages.append({
148
+ "role": "assistant",
149
+ "content": f"```\n{tool_name}({ev['arguments']})\n```\n⏳ Running...",
150
+ "metadata": {"title": f"🛠️ Used tool {tool_name}"},
151
+ })
152
+ tool_call_idx = len(display_messages) - 1
153
+
154
+ elif t == "tool_output":
155
+ if tool_call_idx is not None:
156
+ snippet = ev["content"][:500]
157
+ cc = snippet if len(ev["content"]) <= 500 else snippet + "\n..."
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({
173
+ "role": "assistant",
174
+ "content": f'<span style="color: var(--color-red-500)">{ev["content"]}</span>',
175
+ "metadata": {"title": "💥 Error"},
176
+ })
177
+ yield {
178
+ chatbot: gr.update(value=display_messages),
179
+ state: gr.update(value=state_value),
180
+ send_btn: gr.update(visible=True),
181
+ stop_btn: gr.update(visible=False),
182
+ }
183
+ return
184
+
185
+ elif t == "done":
186
+ break
187
+
188
+ yield {
189
+ chatbot: gr.update(value=display_messages),
190
+ state: gr.update(value=state_value),
191
+ }
192
+
193
+ # Sync history with display format so saves remember tool call bubbles
194
+ ctx["history"] = display_messages
195
+
196
+ yield {
197
+ chatbot: gr.update(value=ctx["history"]),
198
+ state: gr.update(value=state_value),
199
+ send_btn: gr.update(visible=True),
200
+ stop_btn: gr.update(visible=False),
201
+ }
202
+
203
+ except Exception as exc:
204
+ display_messages.append({
205
+ "role": "assistant",
206
+ "content": f'<span style="color: var(--color-red-400)">{exc}</span>',
207
+ "metadata": {"title": "💥 Error"},
208
+ })
209
+ yield {
210
+ chatbot: gr.update(value=display_messages),
211
+ state: gr.update(value=state_value),
212
+ send_btn: gr.update(visible=True),
213
+ stop_btn: gr.update(visible=False),
214
+ }
215
+
216
+ @staticmethod
217
+ def new_chat(state_value):
218
+ state_value["conversation_id"] = ""
219
+ return (
220
+ gr.update(value=None),
221
+ gr.update(value=None),
222
+ gr.update(value=state_value),
223
+ )
224
+
225
+ @staticmethod
226
+ def select_conversation(choice, state_value):
227
+ if not choice:
228
+ return gr.skip()
229
+ if choice == state_value.get("conversation_id"):
230
+ return gr.skip()
231
+
232
+ state_value["conversation_id"] = choice
233
+ ctx = state_value["conversation_contexts"].get(choice, {})
234
+ return (
235
+ gr.update(value=ctx.get("history", [])),
236
+ gr.update(value=state_value),
237
+ )
238
+
239
+ @staticmethod
240
+ def delete_selected_conversation(choice, state_value):
241
+ if not choice:
242
+ return gr.skip()
243
+
244
+ state_value["conversation_contexts"].pop(choice, None)
245
+ state_value["conversations"] = [
246
+ c for c in state_value["conversations"] if c["key"] != choice
247
+ ]
248
+ was_active = state_value.get("conversation_id") == choice
249
+ if was_active:
250
+ state_value["conversation_id"] = ""
251
+ return (
252
+ _conv_choices(state_value),
253
+ gr.update(value=None),
254
+ gr.update(value=state_value),
255
+ )
256
+ return (
257
+ _conv_choices(state_value),
258
+ gr.skip(),
259
+ gr.update(value=state_value),
260
+ )
261
+
262
+ @staticmethod
263
+ def cancel_stream(state_value):
264
+ """Mark the current assistant message as cancelled."""
265
+ if not state_value.get("conversation_id"):
266
+ return gr.skip()
267
+ ctx = state_value["conversation_contexts"][
268
+ state_value["conversation_id"]]
269
+ if ctx.get("history") and ctx["history"][-1].get("role") == "assistant":
270
+ ctx["history"][-1]["metadata"] = ctx["history"][-1].get(
271
+ "metadata", {})
272
+ ctx["history"][-1]["metadata"]["footer"] = "Chat completion paused"
273
+ return (gr.update(value=ctx.get("history", [])), gr.update(value=state_value), gr.update(visible=True), gr.update(visible=False))
274
+
275
+ @staticmethod
276
+ def load_from_js(serialised_json, state_value):
277
+ """Receive the JSON string that JS_LOAD_STATE returned, merge into state."""
278
+ import json
279
+ if not serialised_json:
280
+ return gr.skip(), gr.skip()
281
+ try:
282
+ loaded = json.loads(serialised_json)
283
+ except Exception:
284
+ return gr.skip(), gr.skip()
285
+
286
+ state_value["conversations"] = loaded.get("conversations", [])
287
+ state_value["conversation_contexts"] = loaded.get("conversation_contexts", {})
288
+ return _conv_choices(state_value), gr.update(value=state_value)
289
+
290
+ @staticmethod
291
+ def prepare_save(state_value):
292
+ """Serialise state to JSON so JS_SAVE_STATE can write it to localStorage."""
293
+ import json
294
+ return json.dumps({
295
+ "conversations": state_value.get("conversations", []),
296
+ "conversation_contexts": state_value.get("conversation_contexts", {}),
297
+ })
298
+
299
+ with gr.Blocks(fill_width=True, title="Demo Chat") as demo:
300
+ state = gr.State({
301
+ "conversation_contexts": {},
302
+ "conversations": [],
303
+ "conversation_id": "",
304
+ })
305
+
306
+ js_load_output = gr.Textbox(visible=False, elem_id="js-load-output")
307
+ js_save_input = gr.Textbox(visible=False, elem_id="js-save-input")
308
+
309
+ with gr.Row(elem_id="main-row"):
310
+ with gr.Sidebar(open=False):
311
+ new_chat_btn = gr.Button(
312
+ value="New Conversation",
313
+ variant="primary",
314
+ )
315
+ conv_choice = gr.Radio(
316
+ choices=[],
317
+ label=None,
318
+ interactive=True,
319
+ elem_id="conversations-radio",
320
+ )
321
+ delete_btn = gr.Button(
322
+ value="Delete Selected",
323
+ variant="stop",
324
+ visible=False
325
+ )
326
+
327
+ with gr.Column(elem_id="chat-column"):
328
+
329
+ # Landing page shown when chat is empty
330
+ landing_page = gr.HTML(
331
+ value="""
332
+ <div id="landing-page">
333
+ <div class="landing-content">
334
+ <div class="landing-logo">
335
+ <img src="/gradio_api/file=static/svg/logo.svg" alt="MythosHarness" width="420" height="70" />
336
+ </div>
337
+ </div>
338
+ <div class="landing-prompt">
339
+ <p>Made with ❤️ by KingNish and Himanshu</p>
340
+ </div>
341
+ </div>
342
+ """,
343
+ elem_id="landing-page-container",
344
+ )
345
+
346
+ chatbot = gr.Chatbot(
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(
355
+ placeholder="Enter your message here...",
356
+ show_label=False,
357
+ scale=4,
358
+ container=False,
359
+ max_lines=10,
360
+ )
361
+ send_btn = gr.Button(
362
+ "Send",
363
+ variant="primary",
364
+ scale=0,
365
+ min_width=40,
366
+ elem_id="send-btn",
367
+ )
368
+ stop_btn = gr.Button(
369
+ "Stop",
370
+ variant="stop",
371
+ scale=0,
372
+ min_width=40,
373
+ visible=False,
374
+ elem_id="stop-btn",
375
+ )
376
+
377
+ new_chat_btn.click(
378
+ fn=GradioEvents.new_chat,
379
+ inputs=[state],
380
+ outputs=[conv_choice, chatbot, state],
381
+ )
382
+
383
+ conv_choice.change(
384
+ fn=GradioEvents.select_conversation,
385
+ inputs=[conv_choice, state],
386
+ outputs=[chatbot, state],
387
+ )
388
+
389
+ delete_btn.click(
390
+ fn=GradioEvents.delete_selected_conversation,
391
+ inputs=[conv_choice, state],
392
+ outputs=[conv_choice, chatbot, state],
393
+ )
394
+
395
+ submit_event = send_btn.click(
396
+ fn=GradioEvents.stream_response,
397
+ inputs=[msg, state],
398
+ outputs=[msg, chatbot, state, conv_choice, send_btn, stop_btn],
399
+ )
400
+ msg.submit(
401
+ fn=GradioEvents.stream_response,
402
+ inputs=[msg, state],
403
+ outputs=[msg, chatbot, state, conv_choice, send_btn, stop_btn],
404
+ )
405
+
406
+ stop_btn.click(
407
+ fn=GradioEvents.cancel_stream,
408
+ inputs=[state],
409
+ outputs=[chatbot, state, send_btn, stop_btn],
410
+ cancels=[submit_event],
411
+ )
412
+
413
+ state.change(
414
+ fn=GradioEvents.prepare_save,
415
+ inputs=[state],
416
+ outputs=[js_save_input],
417
+ ).then(
418
+ fn=lambda x: x,
419
+ inputs=[js_save_input],
420
+ outputs=[js_save_input],
421
+ js=JS_SAVE_STATE,
422
+ )
423
+
424
+ demo.load(
425
+ fn=lambda: None,
426
+ inputs=None,
427
+ outputs=None,
428
+ js=LANDING_PAGE_SCRIPT,
429
+ ).then(
430
+ fn=lambda x: x,
431
+ inputs=[js_load_output],
432
+ outputs=[js_load_output],
433
+ js=JS_LOAD_STATE,
434
+ ).then(
435
+ fn=GradioEvents.load_from_js,
436
+ inputs=[js_load_output, state],
437
+ outputs=[conv_choice, state],
438
+ )
439
+
440
+ theme = gr.themes.Base(radius_size="none")
441
+
442
+ if __name__ == "__main__":
443
+ demo.queue(default_concurrency_limit=100, max_size=100).launch(ssr_mode=False, max_threads=100, css_paths="app.css", theme=theme)
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio==6.8.0
2
+ openai==2.41.0
3
+ python-dotenv==1.2.2
4
+ cloudscraper>=1.2,<2
5
+ trafilatura>=2.0,<3
6
+ markitdown>=0.1,<1
static/js/landing.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ () => {
2
+ // Landing page toggle logic
3
+ const landingPage = document.getElementById('landing-page-container');
4
+ const chatbot = document.getElementById('chatbot');
5
+ const msgInput = document.querySelector('#input-row textarea, #input-row input[type="text"]');
6
+
7
+ if (!landingPage || !chatbot) return;
8
+
9
+ function toggleLanding() {
10
+ // Check if chatbot has any messages
11
+ const messages = chatbot.querySelectorAll('[data-testid="bot"], [data-testid="user"], [class*="message"]');
12
+ const hasMessages = messages.length > 0;
13
+
14
+ // Also check for empty state indicators
15
+ const emptyState = chatbot.querySelector('.empty-state, .placeholder, [class*="empty"]');
16
+ const chatColumn = document.getElementById('chat-column');
17
+
18
+ if (hasMessages && !emptyState) {
19
+ landingPage.classList.add('hidden');
20
+ if (chatColumn) chatColumn.classList.remove('landing-active');
21
+ } else {
22
+ landingPage.classList.remove('hidden');
23
+ if (chatColumn) chatColumn.classList.add('landing-active');
24
+ }
25
+ }
26
+
27
+ // Initial check
28
+ toggleLanding();
29
+
30
+ // Set up mutation observer to watch for chat changes
31
+ const observer = new MutationObserver((mutations) => {
32
+ let shouldToggle = false;
33
+ for (const mutation of mutations) {
34
+ if (mutation.type === 'childList' || mutation.type === 'subtree') {
35
+ shouldToggle = true;
36
+ break;
37
+ }
38
+ }
39
+ if (shouldToggle) {
40
+ // Debounce the toggle check
41
+ clearTimeout(window._landingToggleTimeout);
42
+ window._landingToggleTimeout = setTimeout(toggleLanding, 100);
43
+ }
44
+ });
45
+
46
+ observer.observe(chatbot, {
47
+ childList: true,
48
+ subtree: true,
49
+ attributes: true,
50
+ attributeFilter: ['class']
51
+ });
52
+
53
+ // Also watch for the input area to handle new chat creation
54
+ const inputRow = document.getElementById('input-row');
55
+ if (inputRow) {
56
+ observer.observe(inputRow, {
57
+ childList: true,
58
+ subtree: true
59
+ });
60
+ }
61
+
62
+ // Click on landing prompt to focus input
63
+ const landingPrompt = document.querySelector('.landing-prompt');
64
+ if (landingPrompt && msgInput) {
65
+ landingPrompt.addEventListener('click', () => {
66
+ msgInput.focus();
67
+ msgInput.click();
68
+ });
69
+ }
70
+
71
+ // Store reference for cleanup
72
+ window._landingPageObserver = observer;
73
+ window._landingPage = landingPage;
74
+ }
static/js/storage.load.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (_) => {
2
+ const titles = JSON.parse(localStorage.getItem("titles") || "{}");
3
+ const conversations = [];
4
+ const conversation_contexts = {};
5
+
6
+ for (const [id, data] of Object.entries(titles)) {
7
+ const raw = localStorage.getItem("chat_id_" + id);
8
+ if (raw) {
9
+ const label = typeof data === 'string' ? data : data.label;
10
+ const last_updated = typeof data === 'string' ? 0 : (data.last_updated || 0);
11
+ conversations.push({ key: id, label: label, last_updated: last_updated });
12
+ conversation_contexts[id] = JSON.parse(raw);
13
+ }
14
+ }
15
+
16
+ // Sort newest first
17
+ conversations.sort((a, b) => (b.last_updated || 0) - (a.last_updated || 0));
18
+
19
+ return JSON.stringify({ conversations, conversation_contexts });
20
+ }
static/js/storage.save.js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (stateJson) => {
2
+ const state = JSON.parse(stateJson);
3
+ const titles = {};
4
+
5
+ for (const conv of (state.conversations || [])) {
6
+ titles[conv.key] = { label: conv.label, last_updated: conv.last_updated || 0 };
7
+ const ctx = (state.conversation_contexts || {})[conv.key];
8
+ if (ctx !== undefined) {
9
+ localStorage.setItem("chat_id_" + conv.key, JSON.stringify(ctx));
10
+ }
11
+ }
12
+
13
+ // Remove stale chat_id_* keys that are no longer in conversations
14
+ const validIds = new Set(Object.keys(titles));
15
+ for (let i = 0; i < localStorage.length; i++) {
16
+ const k = localStorage.key(i);
17
+ if (k && k.startsWith("chat_id_")) {
18
+ const id = k.slice("chat_id_".length);
19
+ if (!validIds.has(id)) {
20
+ localStorage.removeItem(k);
21
+ i--; // adjust index after removal
22
+ }
23
+ }
24
+ }
25
+
26
+ localStorage.setItem("titles", JSON.stringify(titles));
27
+ return stateJson;
28
+ }
static/svg/logo.svg ADDED
static/svg/send.svg ADDED
static/svg/stop.svg ADDED