Merge MythosHarness history into OpenMythos
Browse files- .env.example +3 -0
- .gitignore +26 -0
- agent/__init__.py +20 -0
- agent/agent.py +412 -0
- agent/mcp.py +261 -0
- agent/responses.py +29 -0
- agent/shell.py +180 -0
- agent/tools.py +272 -0
- app.css +396 -0
- app.py +443 -0
- requirements.txt +6 -0
- static/js/landing.js +74 -0
- static/js/storage.load.js +20 -0
- static/js/storage.save.js +28 -0
- static/svg/logo.svg +4 -0
- static/svg/send.svg +4 -0
- static/svg/stop.svg +3 -0
.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
|
|