chuckfinca Claude Opus 4.6 (1M context) commited on
Commit
7d4b523
·
0 Parent(s):

Initial document-explorer product

Browse files

Gradio web app for exploring document workspaces with an LLM agent.
Uses E2B sandboxes for code execution, depends on a-simple-llm-harness
for the agent loop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (4) hide show
  1. README.md +34 -0
  2. app.py +225 -0
  3. requirements.txt +4 -0
  4. sandbox_e2b.py +100 -0
README.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Document Explorer
3
+ emoji: 📄
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: "6.9.0"
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # Document Explorer
13
+
14
+ Upload text or CSV files and ask questions. The model explores your documents
15
+ by writing and running Python code in a sandboxed E2B environment.
16
+
17
+ Built on [a-simple-llm-harness](https://github.com/chuckfinca/a-simple-llm-harness).
18
+
19
+ ## Setup
20
+
21
+ Set environment variables:
22
+
23
+ ```
24
+ LH_MODEL=openrouter/qwen/qwen3-coder
25
+ LH_ACCESS_TOKEN=your-secret-token
26
+ E2B_API_KEY=your-e2b-api-key
27
+ ```
28
+
29
+ Run locally:
30
+
31
+ ```
32
+ pip install -r requirements.txt
33
+ python app.py
34
+ ```
app.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Web interface for exploring document workspaces with an LLM agent.
2
+
3
+ Usage:
4
+ python app.py
5
+
6
+ Requires LH_MODEL and LH_ACCESS_TOKEN in .env or environment.
7
+ Uses E2B sandboxes for code execution (no Docker required).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import tempfile
14
+ import time
15
+ from dataclasses import asdict
16
+ from pathlib import Path
17
+
18
+ import gradio as gr
19
+ import litellm
20
+ from dotenv import load_dotenv
21
+
22
+ from llm_harness.agent import run_agent_loop
23
+ from llm_harness.prompt import build_system_prompt
24
+ from llm_harness.tools import TOOL_DEFINITIONS
25
+ from llm_harness.types import Message, ToolCallEvent, ToolResultEvent
26
+
27
+ from sandbox_e2b import run_python as e2b_run_python
28
+
29
+ load_dotenv()
30
+ litellm.suppress_debug_info = True
31
+
32
+ MODEL = os.environ.get("LH_MODEL", "")
33
+ ACCESS_TOKEN = os.environ.get("LH_ACCESS_TOKEN", "")
34
+ MAX_SESSION_COST = float(os.environ.get("LH_MAX_SESSION_COST", "0.50"))
35
+
36
+
37
+ def authenticate(username: str, password: str) -> bool:
38
+ return password == ACCESS_TOKEN
39
+
40
+
41
+ def save_uploaded_files(files: list[str]) -> Path:
42
+ workspace = Path(tempfile.mkdtemp(prefix="lh-workspace-"))
43
+ for file_path in files:
44
+ src = Path(file_path)
45
+ (workspace / src.name).write_bytes(src.read_bytes())
46
+ return workspace
47
+
48
+
49
+ def format_stats(trace: object) -> str:
50
+ cost_str = f"${trace.cost:.4f}" if trace.cost else "n/a"
51
+ cached = trace.cached_tokens
52
+ cache_str = f" ({cached} cached)" if cached else ""
53
+ scratchpad = len(trace.scratch_files)
54
+ model_name = trace.model.split("/")[-1] if trace.model else ""
55
+ stats = (
56
+ f"*{model_name}"
57
+ f" · {trace.prompt_tokens + trace.completion_tokens:,} tokens{cache_str}"
58
+ f" · {len(trace.tool_calls)} tool calls"
59
+ f" · {trace.wall_time_s:.1f}s"
60
+ f" · {cost_str}"
61
+ )
62
+ if scratchpad:
63
+ stats += f" · {scratchpad} scratchpad files"
64
+ return stats + "*"
65
+
66
+
67
+ def chat(
68
+ message: str,
69
+ history: list[dict],
70
+ files: list[str] | None,
71
+ workspace_path: str,
72
+ scratch_path: str,
73
+ session_cost: float,
74
+ ):
75
+ if not MODEL:
76
+ yield (
77
+ "Error: LH_MODEL not set.",
78
+ workspace_path,
79
+ scratch_path,
80
+ session_cost,
81
+ )
82
+ return
83
+
84
+ if session_cost >= MAX_SESSION_COST:
85
+ yield (
86
+ f"Session cost limit reached (${session_cost:.2f} / "
87
+ f"${MAX_SESSION_COST:.2f}). Start a new session.",
88
+ workspace_path,
89
+ scratch_path,
90
+ session_cost,
91
+ )
92
+ return
93
+
94
+ # Set up workspace from uploaded files (first message only)
95
+ workspace = Path(workspace_path) if workspace_path else None
96
+ if files and not workspace:
97
+ workspace = save_uploaded_files(files)
98
+ workspace_path = str(workspace)
99
+
100
+ # Set up scratchpad (once per session)
101
+ if not scratch_path:
102
+ scratch_path = tempfile.mkdtemp(prefix="lh-scratch-")
103
+ scratch_dir = Path(scratch_path)
104
+
105
+ # Build messages from Gradio history
106
+ system_prompt = build_system_prompt(base_prompt="", workspace=workspace)
107
+ messages: list[Message] = [{"role": "system", "content": system_prompt}]
108
+ messages.extend({"role": e["role"], "content": e["content"]} for e in history)
109
+ messages.append({"role": "user", "content": message})
110
+
111
+ # Run agent loop with E2B sandbox
112
+ start = time.monotonic()
113
+ agent_run = run_agent_loop(
114
+ model=MODEL,
115
+ messages=messages,
116
+ tools=TOOL_DEFINITIONS,
117
+ completion=litellm.completion,
118
+ workspace=workspace,
119
+ scratch_dir=scratch_dir,
120
+ sandbox_fn=e2b_run_python,
121
+ )
122
+
123
+ tool_call_count = 0
124
+ try:
125
+ for event in agent_run:
126
+ if isinstance(event, ToolCallEvent):
127
+ tool_call_count += 1
128
+ status = f"*Exploring documents ({tool_call_count} tool calls)...*"
129
+ yield status, workspace_path, scratch_path, session_cost
130
+ elif isinstance(event, ToolResultEvent):
131
+ continue
132
+ else:
133
+ cost = agent_run.trace.cost or 0
134
+ session_cost += cost
135
+ except Exception as exc:
136
+ yield (
137
+ f"Error: {exc}",
138
+ workspace_path,
139
+ scratch_path,
140
+ session_cost,
141
+ )
142
+ return
143
+
144
+ trace = agent_run.trace
145
+ trace.wall_time_s = round(time.monotonic() - start, 2)
146
+ answer = trace.answer or "(no answer)"
147
+ stats = format_stats(trace)
148
+
149
+ yield (
150
+ f"{answer}\n\n---\n{stats}",
151
+ workspace_path,
152
+ scratch_path,
153
+ session_cost,
154
+ )
155
+
156
+
157
+ def build_app() -> gr.Blocks:
158
+ with gr.Blocks(title="Document Explorer", theme=gr.themes.Soft()) as demo:
159
+ gr.Markdown(
160
+ "# Document Explorer\n"
161
+ "Upload text or CSV files, then ask questions. "
162
+ "The model explores your documents by writing and running Python code."
163
+ )
164
+
165
+ workspace_state = gr.State("")
166
+ scratch_state = gr.State("")
167
+ cost_state = gr.State(0.0)
168
+
169
+ with gr.Accordion("Upload documents", open=True):
170
+ file_upload = gr.File(
171
+ label="Text, CSV, Markdown, or JSON files",
172
+ file_count="multiple",
173
+ file_types=[".txt", ".csv", ".md", ".json"],
174
+ )
175
+
176
+ chatbot = gr.Chatbot(height=500)
177
+ msg = gr.Textbox(
178
+ placeholder="Ask a question about your documents...",
179
+ label="",
180
+ show_label=False,
181
+ )
182
+
183
+ def respond(
184
+ message, history, files, workspace_path, scratch_path, session_cost
185
+ ):
186
+ history = history or []
187
+ history.append({"role": "user", "content": message})
188
+
189
+ for response, wp, sp, sc in chat(
190
+ message, history[:-1], files, workspace_path, scratch_path, session_cost
191
+ ):
192
+ history_with_response = [
193
+ *history,
194
+ {"role": "assistant", "content": response},
195
+ ]
196
+ yield history_with_response, "", wp, sp, sc
197
+
198
+ msg.submit(
199
+ respond,
200
+ inputs=[
201
+ msg,
202
+ chatbot,
203
+ file_upload,
204
+ workspace_state,
205
+ scratch_state,
206
+ cost_state,
207
+ ],
208
+ outputs=[
209
+ chatbot,
210
+ msg,
211
+ workspace_state,
212
+ scratch_state,
213
+ cost_state,
214
+ ],
215
+ )
216
+
217
+ return demo
218
+
219
+
220
+ if __name__ == "__main__":
221
+ if not ACCESS_TOKEN:
222
+ print("WARNING: LH_ACCESS_TOKEN not set — app is unprotected")
223
+
224
+ app = build_app()
225
+ app.launch(auth=authenticate if ACCESS_TOKEN else None)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ a-simple-llm-harness @ git+https://github.com/chuckfinca/a-simple-llm-harness.git
2
+ e2b-code-interpreter>=2.5
3
+ gradio>=5.0
4
+ python-dotenv
sandbox_e2b.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """E2B-based sandbox for cloud deployment without Docker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from e2b_code_interpreter import Sandbox
9
+
10
+ from llm_harness.sandbox import TIMEOUT_SECONDS, _truncate
11
+
12
+ # Reuse a sandbox across tool calls within a session.
13
+ # The caller manages the lifecycle via create/close.
14
+ _active_sandbox: Sandbox | None = None
15
+
16
+
17
+ def get_or_create_sandbox(
18
+ workspace: Path | None = None,
19
+ scratch_dir: Path | None = None,
20
+ ) -> Sandbox:
21
+ """Get the active sandbox, creating one if needed and uploading workspace files."""
22
+ global _active_sandbox
23
+ if _active_sandbox is not None:
24
+ return _active_sandbox
25
+
26
+ _active_sandbox = Sandbox.create(timeout=300)
27
+
28
+ # Create workspace and scratchpad directories in user-writable home
29
+ _active_sandbox.commands.run("mkdir -p /home/user/workspace /home/user/scratchpad")
30
+ # Symlink to expected paths
31
+ _active_sandbox.commands.run(
32
+ "ln -sf /home/user/workspace /workspace; "
33
+ "ln -sf /home/user/scratchpad /scratchpad",
34
+ user="root",
35
+ )
36
+
37
+ # Upload workspace files
38
+ if workspace is not None:
39
+ for file_path in workspace.iterdir():
40
+ if file_path.is_file():
41
+ _active_sandbox.files.write(
42
+ f"/home/user/workspace/{file_path.name}",
43
+ file_path.read_bytes(),
44
+ )
45
+
46
+ return _active_sandbox
47
+
48
+
49
+ def close_sandbox() -> None:
50
+ global _active_sandbox
51
+ if _active_sandbox is not None:
52
+ _active_sandbox.kill()
53
+ _active_sandbox = None
54
+
55
+
56
+ def run_python(
57
+ code: str,
58
+ *,
59
+ workspace: Path | None = None,
60
+ scratch_dir: Path | None = None,
61
+ timeout: int = TIMEOUT_SECONDS,
62
+ ) -> str:
63
+ """Execute Python code in an E2B sandbox. Same interface as sandbox.run_python."""
64
+ sandbox = get_or_create_sandbox(workspace, scratch_dir)
65
+
66
+ try:
67
+ execution = sandbox.run_code(code, timeout=timeout)
68
+
69
+ stdout = "\n".join(
70
+ line if isinstance(line, str) else line.text
71
+ for line in execution.logs.stdout
72
+ )
73
+ stderr = "\n".join(
74
+ line if isinstance(line, str) else line.text
75
+ for line in execution.logs.stderr
76
+ )
77
+
78
+ if execution.error:
79
+ stderr += f"\n{execution.error.name}: {execution.error.value}"
80
+ exit_code = 1
81
+ else:
82
+ exit_code = 0
83
+
84
+ return json.dumps(
85
+ {
86
+ "stdout": _truncate(stdout),
87
+ "stderr": _truncate(stderr),
88
+ "exit_code": exit_code,
89
+ "timed_out": False,
90
+ }
91
+ )
92
+ except TimeoutError:
93
+ return json.dumps(
94
+ {
95
+ "stdout": "",
96
+ "stderr": "Execution timed out.",
97
+ "exit_code": -1,
98
+ "timed_out": True,
99
+ }
100
+ )