lvwerra HF Staff Claude Opus 4.6 commited on
Commit
4e3db9c
·
1 Parent(s): 0d3d041

Refactor backend: extract async stream helper, direct tool registry, nudge_for_result via call_llm

Browse files

- Add _stream_sync_generator helper in main.py, replacing 5 duplicated async queue patterns
- Add DIRECT_TOOL_REGISTRY in tools.py combining schema + execute in one dict
- Refactor command.py to use DIRECT_TOOL_REGISTRY instead of separate DIRECT_TOOLS set and if/elif dispatch
- Make nudge_for_result use call_llm for debug panel parity (agents.py, agent.py, code.py, image.py)
- Eliminate hardcoded frontend AGENT_REGISTRY, fetch from /api/agents at startup (script.js)
- Debug panel fixes: base64 image placeholders, call enumeration, layout, remove refresh button
- Add drag-and-drop file upload to files panel
- Rewrite README for release with extension/tool/theme documentation
- Reduce debug panel width from 600px to 450px

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

README.md CHANGED
@@ -10,7 +10,7 @@ header: mini
10
 
11
  # Agent UI
12
 
13
- A multi-tab AI assistant interface with support for chat, code execution, and research workflows.
14
 
15
  ## Quick Start
16
 
@@ -19,34 +19,255 @@ make install # Install dependencies
19
  make dev # Start server at http://localhost:8765
20
  ```
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  ## Testing
23
 
24
  ```bash
25
- make test # Run all tests (58 total)
26
  make test-backend # Backend API tests (pytest)
27
  make test-frontend # Frontend unit tests (vitest)
28
  make test-e2e # E2E browser tests (playwright)
29
  ```
30
 
31
- ## Tab Types
32
 
33
- - **Command Center**: Launch new notebooks and agent workflows
34
- - **Code**: Jupyter-style notebook with AI agents
35
- - **Research**: Deep research tasks with agents
36
- - **Chat**: Standard chat interface
37
-
38
- ## Project Structure
39
 
 
 
 
40
  ```
41
- ├── backend/ # FastAPI server
42
- │ └── main.py # API endpoints
43
- ├── frontend/ # Web UI
44
- │ ├── index.html # Entry point
45
- │ ├── script.js # Application logic
46
- │ └── style.css # Styles
47
- ├── tests/
48
- │ ├── backend/ # API tests (pytest)
49
- │ ├── frontend/ # Unit tests (vitest)
50
- │ └── e2e/ # Browser tests (playwright)
51
- └── dev/ # Mockups and planning docs
52
- ```
 
10
 
11
  # Agent UI
12
 
13
+ A multi-agent AI interface with code execution, web search, image generation, and deep research — all orchestrated from a single command center.
14
 
15
  ## Quick Start
16
 
 
19
  make dev # Start server at http://localhost:8765
20
  ```
21
 
22
+ ## Architecture
23
+
24
+ ```
25
+ backend/
26
+ ├── agents.py # Agent registry (single source of truth) + shared LLM utilities
27
+ ├── main.py # FastAPI routes, SSE streaming, file management
28
+ ├── command.py # Command center: tool routing, agent launching
29
+ ├── code.py # Code agent: E2B sandbox execution
30
+ ├── agent.py # Web agent: search + browse
31
+ ├── research.py # Research agent: multi-source deep analysis
32
+ ├── image.py # Image agent: generate/edit via HuggingFace
33
+ └── tools.py # Direct tools (execute_code, web_search, show_html, etc.)
34
+
35
+ frontend/
36
+ ├── index.html # Entry point
37
+ ├── script.js # Application logic, agent registry, settings, themes
38
+ ├── style.css # All styles (CSS custom properties for theming)
39
+ └── research-ui.js # Research-specific UI components
40
+ ```
41
+
42
+ ### How It Works
43
+
44
+ 1. The **command center** receives user messages and decides whether to answer directly or launch sub-agents
45
+ 2. Sub-agents (code, web, research, image) run in their own tabs with specialized tools
46
+ 3. All communication uses **SSE streaming** — agents yield JSON events with a `type` field
47
+ 4. Settings store providers, models, and agent-to-model assignments — any OpenAI-compatible API works
48
+
49
+ ## Extending Agent UI
50
+
51
+ ### Adding a New Agent
52
+
53
+ Two files need matching entries: `backend/agents.py` and `frontend/script.js`.
54
+
55
+ **1. Backend registry** — add to `AGENT_REGISTRY` in `backend/agents.py`:
56
+
57
+ ```python
58
+ "my_agent": {
59
+ "label": "MY AGENT",
60
+ "system_prompt": "You are a helpful assistant...",
61
+ "tool": {
62
+ "type": "function",
63
+ "function": {
64
+ "name": "launch_my_agent",
65
+ "description": "Launch my agent for X tasks.",
66
+ "parameters": {
67
+ "type": "object",
68
+ "properties": {
69
+ "task": {"type": "string", "description": "The task"},
70
+ "task_id": {"type": "string", "description": "2-3 word ID"}
71
+ },
72
+ "required": ["task", "task_id"]
73
+ }
74
+ }
75
+ },
76
+ "tool_arg": "task",
77
+ "has_counter": True,
78
+ "in_menu": True,
79
+ "in_launcher": True,
80
+ "placeholder": "Enter message...",
81
+ "capabilities": "Short description of what this agent can do.",
82
+ },
83
+ ```
84
+
85
+ **2. Backend streaming handler** — create `backend/my_agent.py`:
86
+
87
+ ```python
88
+ from .agents import call_llm
89
+
90
+ def stream_my_agent(client, model, messages, extra_params=None, abort_event=None):
91
+ """Generator yielding SSE event dicts."""
92
+ debug_call_number = 0
93
+
94
+ while not_done:
95
+ # call_llm handles retries and emits debug events
96
+ response = None
97
+ for event in call_llm(client, model, messages, tools=MY_TOOLS,
98
+ extra_params=extra_params, abort_event=abort_event,
99
+ call_number=debug_call_number):
100
+ if "_response" in event:
101
+ response = event["_response"]
102
+ debug_call_number = event["_call_number"]
103
+ else:
104
+ yield event
105
+ if event.get("type") in ("error", "aborted"):
106
+ return
107
+
108
+ # Process response, yield events...
109
+ yield {"type": "thinking", "content": "..."}
110
+ yield {"type": "result", "content": "Final answer"}
111
+
112
+ yield {"type": "done"}
113
+ ```
114
+
115
+ Required events: `done`, `error`. Common: `thinking`, `content`, `result`, `result_preview`.
116
+
117
+ **3. Wire the route** — in `backend/main.py`, add to the streaming handler dispatch (search for `agent_type`):
118
+
119
+ ```python
120
+ elif request.agent_type == "my_agent":
121
+ return StreamingResponse(stream_my_agent_handler(...), ...)
122
+ ```
123
+
124
+ **4. Frontend** — no changes needed. The frontend fetches the registry from `GET /api/agents` at startup.
125
+
126
+ ### Adding a Direct Tool
127
+
128
+ Direct tools execute synchronously in the command center (no sub-agent spawned).
129
+
130
+ **1. Define the tool** in `backend/tools.py`:
131
+
132
+ ```python
133
+ my_tool = {
134
+ "type": "function",
135
+ "function": {
136
+ "name": "my_tool",
137
+ "description": "Does something useful.",
138
+ "parameters": {
139
+ "type": "object",
140
+ "properties": {
141
+ "input": {"type": "string", "description": "The input"}
142
+ },
143
+ "required": ["input"]
144
+ }
145
+ }
146
+ }
147
+
148
+ def execute_my_tool(input: str) -> dict:
149
+ return {"content": "Result text for the LLM", "extra_data": "..."}
150
+ ```
151
+
152
+ **2. Register it** in `backend/command.py`:
153
+
154
+ ```python
155
+ from .tools import my_tool, execute_my_tool
156
+
157
+ TOOLS = get_tools() + [show_html_tool, my_tool]
158
+ DIRECT_TOOLS = {"show_html", "my_tool"}
159
+ ```
160
+
161
+ **3. Add execution handler** in the `stream_command_center` function (same file):
162
+
163
+ ```python
164
+ if function_name == "my_tool":
165
+ result = execute_my_tool(args.get("input"))
166
+ ```
167
+
168
+ ### Modifying System Prompts
169
+
170
+ All system prompts live in `backend/agents.py` inside `AGENT_REGISTRY`. Edit the `"system_prompt"` field for any agent.
171
+
172
+ The `get_system_prompt()` function adds dynamic context automatically:
173
+ - `{tools_section}` — replaced with available agent descriptions (command center only)
174
+ - Current date is appended to all prompts
175
+ - Project file tree is appended (in `main.py` wrapper)
176
+ - Theme/styling context is added for code agents
177
+
178
+ ### Adding a Model Provider
179
+
180
+ In the Settings panel, models are configured through **Providers** and **Models**:
181
+
182
+ 1. **Add a provider**: name + OpenAI-compatible endpoint URL + API token
183
+ 2. **Add a model**: name + provider + API model ID (e.g., `gpt-4o`, `claude-sonnet-4-20250514`)
184
+ 3. **Assign models**: pick which model each agent type uses
185
+
186
+ Any OpenAI-compatible API works (OpenAI, Anthropic via proxy, Ollama, vLLM, etc.).
187
+
188
+ Settings are stored in `workspace/settings.json` and managed via the Settings panel in the UI.
189
+
190
+ ### Creating a Theme
191
+
192
+ Themes are CSS custom property sets defined in `frontend/script.js`.
193
+
194
+ **Add to `themeColors` object** (search for `const themeColors`):
195
+
196
+ ```javascript
197
+ myTheme: {
198
+ border: '#8e24aa',
199
+ bg: '#f3e5f5',
200
+ hoverBg: '#e1bee7',
201
+ accent: '#6a1b9a',
202
+ accentRgb: '106, 27, 154',
203
+ ...lightSurface // Use for light themes
204
+ },
205
+ ```
206
+
207
+ For dark themes, override the surface colors instead of spreading `lightSurface`:
208
+
209
+ ```javascript
210
+ myDarkTheme: {
211
+ border: '#bb86fc',
212
+ bg: '#1e1e2e',
213
+ hoverBg: '#2a2a3e',
214
+ accent: '#bb86fc',
215
+ accentRgb: '187, 134, 252',
216
+ bgPrimary: '#121218',
217
+ bgSecondary: '#1e1e2e',
218
+ bgTertiary: '#0e0e14',
219
+ bgInput: '#0e0e14',
220
+ bgHover: '#2a2a3e',
221
+ bgCard: '#1e1e2e',
222
+ textPrimary: '#e0e0e0',
223
+ textSecondary: '#999999',
224
+ textMuted: '#666666',
225
+ borderPrimary: '#333344',
226
+ borderSubtle: '#222233'
227
+ },
228
+ ```
229
+
230
+ The theme automatically appears in the Settings theme picker — no other changes needed. The `applyTheme()` function reads all properties from the object and sets the corresponding CSS variables.
231
+
232
+ **Available CSS variables:** `--theme-accent`, `--theme-accent-rgb`, `--theme-bg`, `--theme-hover-bg`, `--theme-border`, `--bg-primary`, `--bg-secondary`, `--bg-tertiary`, `--bg-input`, `--bg-hover`, `--bg-card`, `--text-primary`, `--text-secondary`, `--text-muted`, `--border-primary`, `--border-subtle`.
233
+
234
+ ## SSE Event Protocol
235
+
236
+ All agents communicate via Server-Sent Events. Each event is a JSON object with a `type` field.
237
+
238
+ | Event | Description |
239
+ |-------|-------------|
240
+ | `done` | Stream complete (required) |
241
+ | `error` | `{content}` — error message (required) |
242
+ | `thinking` | `{content}` — reasoning text |
243
+ | `content` | `{content}` — streamed response tokens |
244
+ | `result` | `{content, figures?}` — final output for command center |
245
+ | `result_preview` | Same as result, shown inline |
246
+ | `retry` | `{attempt, max_attempts, delay, message}` — retrying |
247
+ | `debug_call_input` | `{call_number, messages}` — LLM input (debug panel) |
248
+ | `debug_call_output` | `{call_number, response}` — LLM output (debug panel) |
249
+ | `launch` | `{agent_type, initial_message, task_id}` — spawn sub-agent |
250
+ | `tool_start` | `{tool, args}` — direct tool started |
251
+ | `tool_result` | `{tool, result}` — direct tool completed |
252
+ | `code_start` | `{code}` — code execution started |
253
+ | `code` | `{output, error, images}` — code execution result |
254
+
255
  ## Testing
256
 
257
  ```bash
258
+ make test # Run all tests
259
  make test-backend # Backend API tests (pytest)
260
  make test-frontend # Frontend unit tests (vitest)
261
  make test-e2e # E2E browser tests (playwright)
262
  ```
263
 
264
+ ## Deployment
265
 
266
+ The app runs as a Docker container (designed for HuggingFace Spaces):
 
 
 
 
 
267
 
268
+ ```bash
269
+ docker build -t agent-ui .
270
+ docker run -p 7860:7860 agent-ui
271
  ```
272
+
273
+ Set API keys via environment variables: `OPENAI_API_KEY`, `E2B_API_KEY`, `SERPER_API_KEY`, `HF_TOKEN`.
 
 
 
 
 
 
 
 
 
 
backend/agent.py CHANGED
@@ -250,6 +250,6 @@ def stream_agent_execution(
250
  # If agent finished without a <result>, nudge it for one
251
  if not has_result:
252
  from .agents import nudge_for_result
253
- yield from nudge_for_result(client, model, messages, extra_params=extra_params)
254
 
255
  yield {"type": "done"}
 
250
  # If agent finished without a <result>, nudge it for one
251
  if not has_result:
252
  from .agents import nudge_for_result
253
+ yield from nudge_for_result(client, model, messages, extra_params=extra_params, call_number=debug_call_number)
254
 
255
  yield {"type": "done"}
backend/agents.py CHANGED
@@ -526,10 +526,11 @@ def call_llm(client, model, messages, tools=None, extra_params=None, abort_event
526
  yield {"type": "error", "content": f"LLM error after {MAX_RETRIES} attempts: {str(last_error)}"}
527
 
528
 
529
- def nudge_for_result(client, model, messages, extra_params=None, extra_result_data=None):
530
  """Nudge an agent that finished without <result> tags to produce one.
531
 
532
- This is a generator that yields SSE events (content, result_preview, result).
 
533
  Call it after an agent's tool loop when no <result> was found.
534
 
535
  Args:
@@ -539,6 +540,7 @@ def nudge_for_result(client, model, messages, extra_params=None, extra_result_da
539
  extra_params: Optional extra_body params for the LLM call
540
  extra_result_data: Optional dict of extra fields to include in result events
541
  (e.g. {"figures": {...}} or {"images": {...}})
 
542
  """
543
  import re
544
  import logging
@@ -548,29 +550,35 @@ def nudge_for_result(client, model, messages, extra_params=None, extra_result_da
548
  "role": "user",
549
  "content": "Please provide your final answer now. Wrap it in <result> tags."
550
  })
551
- try:
552
- call_params = {"messages": messages, "model": model}
553
- if extra_params:
554
- call_params["extra_body"] = extra_params
555
- response = client.chat.completions.create(**call_params)
556
- nudge_content = response.choices[0].message.content or ""
557
- result_match = re.search(r'<result>(.*?)</result>', nudge_content, re.DOTALL | re.IGNORECASE)
558
-
559
- extra = extra_result_data or {}
560
-
561
- if result_match:
562
- result_content = result_match.group(1).strip()
563
- thinking = re.sub(r'<result>.*?</result>', '', nudge_content, flags=re.DOTALL | re.IGNORECASE).strip()
564
- if thinking:
565
- yield {"type": "content", "content": thinking}
566
- yield {"type": "result_preview", "content": result_content, **extra}
567
- yield {"type": "result", "content": result_content, **extra}
568
- elif nudge_content.strip():
569
- # No result tags but got content — use it as the result
570
- yield {"type": "result_preview", "content": nudge_content.strip(), **extra}
571
- yield {"type": "result", "content": nudge_content.strip(), **extra}
572
- except Exception as e:
573
- _logger.warning(f"Result nudge failed: {e}")
 
 
 
 
 
 
574
 
575
 
576
  def get_tools() -> list:
 
526
  yield {"type": "error", "content": f"LLM error after {MAX_RETRIES} attempts: {str(last_error)}"}
527
 
528
 
529
+ def nudge_for_result(client, model, messages, extra_params=None, extra_result_data=None, call_number=0):
530
  """Nudge an agent that finished without <result> tags to produce one.
531
 
532
+ This is a generator that yields SSE events (content, result_preview, result,
533
+ plus debug_call_input/output from call_llm).
534
  Call it after an agent's tool loop when no <result> was found.
535
 
536
  Args:
 
540
  extra_params: Optional extra_body params for the LLM call
541
  extra_result_data: Optional dict of extra fields to include in result events
542
  (e.g. {"figures": {...}} or {"images": {...}})
543
+ call_number: Current debug call number for sequential numbering
544
  """
545
  import re
546
  import logging
 
550
  "role": "user",
551
  "content": "Please provide your final answer now. Wrap it in <result> tags."
552
  })
553
+
554
+ response = None
555
+ for event in call_llm(client, model, messages, extra_params=extra_params, call_number=call_number):
556
+ if "_response" in event:
557
+ response = event["_response"]
558
+ else:
559
+ yield event
560
+ if event.get("type") in ("error", "aborted"):
561
+ return
562
+
563
+ if not response:
564
+ return
565
+
566
+ nudge_content = response.choices[0].message.content or ""
567
+ result_match = re.search(r'<result>(.*?)</result>', nudge_content, re.DOTALL | re.IGNORECASE)
568
+
569
+ extra = extra_result_data or {}
570
+
571
+ if result_match:
572
+ result_content = result_match.group(1).strip()
573
+ thinking = re.sub(r'<result>.*?</result>', '', nudge_content, flags=re.DOTALL | re.IGNORECASE).strip()
574
+ if thinking:
575
+ yield {"type": "content", "content": thinking}
576
+ yield {"type": "result_preview", "content": result_content, **extra}
577
+ yield {"type": "result", "content": result_content, **extra}
578
+ elif nudge_content.strip():
579
+ # No result tags but got content — use it as the result
580
+ yield {"type": "result_preview", "content": nudge_content.strip(), **extra}
581
+ yield {"type": "result", "content": nudge_content.strip(), **extra}
582
 
583
 
584
  def get_tools() -> list:
backend/code.py CHANGED
@@ -526,7 +526,7 @@ def stream_code_execution(client, model: str, messages: List[Dict], sbx: Sandbox
526
  # If agent finished without a <result>, nudge it for one
527
  if not has_result:
528
  from .agents import nudge_for_result
529
- yield from nudge_for_result(client, model, messages, extra_params=extra_params, extra_result_data={"figures": figure_data})
530
 
531
  # Send done signal
532
  yield {"type": "done"}
 
526
  # If agent finished without a <result>, nudge it for one
527
  if not has_result:
528
  from .agents import nudge_for_result
529
+ yield from nudge_for_result(client, model, messages, extra_params=extra_params, extra_result_data={"figures": figure_data}, call_number=debug_call_number)
530
 
531
  # Send done signal
532
  yield {"type": "done"}
backend/command.py CHANGED
@@ -9,13 +9,10 @@ logger = logging.getLogger(__name__)
9
 
10
  # Tool definitions derived from agent registry
11
  from .agents import get_tools, get_agent_type_map, get_tool_arg
12
- from .tools import show_html as show_html_tool, execute_show_html
13
 
14
  # Combine agent-launch tools with direct tools
15
- TOOLS = get_tools() + [show_html_tool]
16
-
17
- # Direct tools that execute synchronously (not sub-agent launches)
18
- DIRECT_TOOLS = {"show_html"}
19
 
20
  MAX_TURNS = 10 # Limit conversation turns in command center
21
 
@@ -83,7 +80,7 @@ def stream_command_center(client, model: str, messages: List[Dict], extra_params
83
  return
84
 
85
  # --- Direct tools (execute synchronously) ---
86
- if function_name in DIRECT_TOOLS:
87
  # Emit tool_start for frontend
88
  yield {
89
  "type": "tool_start",
@@ -94,11 +91,9 @@ def stream_command_center(client, model: str, messages: List[Dict], extra_params
94
  "thinking": content,
95
  }
96
 
97
- # Execute the tool
98
- if function_name == "show_html":
99
- result = execute_show_html(args.get("source", ""), files_root=files_root)
100
- else:
101
- result = {"content": f"Unknown direct tool: {function_name}"}
102
 
103
  # Emit tool_result for frontend
104
  yield {
 
9
 
10
  # Tool definitions derived from agent registry
11
  from .agents import get_tools, get_agent_type_map, get_tool_arg
12
+ from .tools import DIRECT_TOOL_REGISTRY
13
 
14
  # Combine agent-launch tools with direct tools
15
+ TOOLS = get_tools() + [t["schema"] for t in DIRECT_TOOL_REGISTRY.values()]
 
 
 
16
 
17
  MAX_TURNS = 10 # Limit conversation turns in command center
18
 
 
80
  return
81
 
82
  # --- Direct tools (execute synchronously) ---
83
+ if function_name in DIRECT_TOOL_REGISTRY:
84
  # Emit tool_start for frontend
85
  yield {
86
  "type": "tool_start",
 
91
  "thinking": content,
92
  }
93
 
94
+ # Execute the tool via registry
95
+ tool_entry = DIRECT_TOOL_REGISTRY[function_name]
96
+ result = tool_entry["execute"](args, {"files_root": files_root})
 
 
97
 
98
  # Emit tool_result for frontend
99
  yield {
backend/image.py CHANGED
@@ -348,7 +348,7 @@ def stream_image_execution(
348
  if not result_sent and image_store:
349
  from .agents import nudge_for_result
350
  nudge_produced_result = False
351
- for event in nudge_for_result(client, model, messages, extra_params=extra_params, extra_result_data={"images": image_store}):
352
  yield event
353
  if event.get("type") == "result":
354
  nudge_produced_result = True
 
348
  if not result_sent and image_store:
349
  from .agents import nudge_for_result
350
  nudge_produced_result = False
351
+ for event in nudge_for_result(client, model, messages, extra_params=extra_params, extra_result_data={"images": image_store}, call_number=debug_call_number):
352
  yield event
353
  if event.get("type") == "result":
354
  nudge_produced_result = True
backend/main.py CHANGED
@@ -23,6 +23,31 @@ _executor = ThreadPoolExecutor(max_workers=10)
23
  # Flag to signal shutdown to running threads
24
  _shutdown_flag = False
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def signal_handler(signum, frame):
27
  """Handle Ctrl+C by setting shutdown flag and exiting"""
28
  global _shutdown_flag
@@ -366,48 +391,21 @@ async def _stream_code_agent_inner(messages, endpoint, token, model, e2b_key, se
366
  return
367
 
368
  try:
369
- # Create or reuse sandbox for this session
370
  if session_id not in SANDBOXES:
371
  os.environ["E2B_API_KEY"] = e2b_key
372
  SANDBOXES[session_id] = Sandbox.create(timeout=SANDBOX_TIMEOUT)
373
 
374
  sbx = SANDBOXES[session_id]
375
-
376
- # Create OpenAI client with user's endpoint
377
  client = OpenAI(base_url=endpoint, api_key=token)
378
-
379
- # Add system prompt for code agent (with file tree and styling context)
380
  system_prompt = get_system_prompt("code", frontend_context)
381
- full_messages = [
382
- {"role": "system", "content": system_prompt}
383
- ] + messages
384
-
385
-
386
-
387
-
388
- # Stream code execution in a thread to avoid blocking the event loop
389
- loop = asyncio.get_event_loop()
390
- queue = asyncio.Queue()
391
-
392
- def run_sync_generator():
393
- try:
394
- for update in stream_code_execution(client, model, full_messages, sbx, files_root=files_root or FILES_ROOT, extra_params=extra_params, abort_event=abort_event, multimodal=multimodal):
395
- loop.call_soon_threadsafe(queue.put_nowait, update)
396
- finally:
397
- loop.call_soon_threadsafe(queue.put_nowait, None) # Signal completion
398
-
399
- # Start the sync generator in a thread
400
- future = loop.run_in_executor(_executor, run_sync_generator)
401
-
402
- # Yield updates as they arrive
403
- while True:
404
- update = await queue.get()
405
- if update is None:
406
- break
407
- yield f"data: {json.dumps(update)}\n\n"
408
 
409
- # Wait for the thread to complete (handles exceptions)
410
- await asyncio.wrap_future(future)
 
 
 
 
411
 
412
  except Exception as e:
413
  import traceback
@@ -417,7 +415,6 @@ async def _stream_code_agent_inner(messages, endpoint, token, model, e2b_key, se
417
  # Check if this is a sandbox timeout error (502)
418
  error_str = str(e)
419
  if "502" in error_str or "sandbox was not found" in error_str.lower() or "timeout" in error_str.lower():
420
- # Remove the timed-out sandbox from cache
421
  if session_id in SANDBOXES:
422
  try:
423
  SANDBOXES[session_id].kill()
@@ -425,10 +422,8 @@ async def _stream_code_agent_inner(messages, endpoint, token, model, e2b_key, se
425
  pass
426
  del SANDBOXES[session_id]
427
 
428
- # Notify user about timeout and retry
429
  yield f"data: {json.dumps({'type': 'info', 'content': 'Sandbox timed out. Creating new sandbox and retrying...'})}\n\n"
430
 
431
- # Create a new sandbox and retry
432
  try:
433
  os.environ["E2B_API_KEY"] = e2b_key
434
  SANDBOXES[session_id] = Sandbox.create(timeout=SANDBOX_TIMEOUT)
@@ -436,25 +431,12 @@ async def _stream_code_agent_inner(messages, endpoint, token, model, e2b_key, se
436
 
437
  yield f"data: {json.dumps({'type': 'info', 'content': 'New sandbox created. Retrying execution...'})}\n\n"
438
 
439
- # Retry code execution with new sandbox (in thread)
440
- retry_queue = asyncio.Queue()
441
-
442
- def run_retry_generator():
443
- try:
444
- for update in stream_code_execution(client, model, full_messages, sbx, files_root=files_root or FILES_ROOT, extra_params=extra_params, abort_event=abort_event, multimodal=multimodal):
445
- loop.call_soon_threadsafe(retry_queue.put_nowait, update)
446
- finally:
447
- loop.call_soon_threadsafe(retry_queue.put_nowait, None)
448
-
449
- retry_future = loop.run_in_executor(_executor, run_retry_generator)
450
-
451
- while True:
452
- update = await retry_queue.get()
453
- if update is None:
454
- break
455
- yield f"data: {json.dumps(update)}\n\n"
456
-
457
- await asyncio.wrap_future(retry_future)
458
 
459
  except Exception as retry_error:
460
  yield f"data: {json.dumps({'type': 'error', 'content': f'Failed to retry after timeout: {str(retry_error)}'})}\n\n"
@@ -523,29 +505,14 @@ async def _stream_research_agent_inner(messages, endpoint, token, model, serper_
523
  # Use max websites if provided, otherwise default to 50
524
  max_sites = max_websites if max_websites else 50
525
 
526
- # Stream research in a thread to avoid blocking the event loop
527
- loop = asyncio.get_event_loop()
528
- queue = asyncio.Queue()
529
-
530
- def run_sync_generator():
531
- try:
532
- for update in stream_research(client, model, question, serper_key, max_websites=max_sites, system_prompt=system_prompt, sub_agent_model=analysis_model, parallel_workers=workers, sub_agent_client=sub_agent_client, extra_params=extra_params, sub_agent_extra_params=sub_agent_extra_params, abort_event=abort_event):
533
- loop.call_soon_threadsafe(queue.put_nowait, update)
534
- finally:
535
- loop.call_soon_threadsafe(queue.put_nowait, None) # Signal completion
536
-
537
- # Start the sync generator in a thread
538
- future = loop.run_in_executor(_executor, run_sync_generator)
539
-
540
- # Yield updates as they arrive
541
- while True:
542
- update = await queue.get()
543
- if update is None:
544
- break
545
- yield f"data: {json.dumps(update)}\n\n"
546
-
547
- # Wait for the thread to complete (handles exceptions)
548
- await asyncio.wrap_future(future)
549
 
550
  except Exception as e:
551
  import traceback
@@ -579,42 +546,16 @@ async def _stream_command_center_inner(messages, endpoint, token, model, tab_id,
579
  return
580
 
581
  try:
582
- # Create OpenAI client
583
  client = OpenAI(base_url=endpoint, api_key=token)
584
-
585
- # Add system prompt for command center (with file tree)
586
- # Frontend sends full conversation history, so just prepend system prompt
587
  system_prompt = get_system_prompt("command")
588
  full_messages = [{"role": "system", "content": system_prompt}] + messages
589
 
590
- logger.debug(f"tab_id={tab_id}, messages={len(messages)}, full_messages={len(full_messages)}")
591
-
592
-
593
-
594
-
595
- # Stream command center execution in a thread to avoid blocking the event loop
596
- loop = asyncio.get_event_loop()
597
- queue = asyncio.Queue()
598
-
599
- def run_sync_generator():
600
- try:
601
- for update in stream_command_center(client, model, full_messages, extra_params=extra_params, abort_event=abort_event, files_root=files_root or FILES_ROOT):
602
- loop.call_soon_threadsafe(queue.put_nowait, update)
603
- finally:
604
- loop.call_soon_threadsafe(queue.put_nowait, None) # Signal completion
605
-
606
- # Start the sync generator in a thread
607
- future = loop.run_in_executor(_executor, run_sync_generator)
608
-
609
- # Yield updates as they arrive
610
- while True:
611
- update = await queue.get()
612
- if update is None:
613
- break
614
- yield f"data: {json.dumps(update)}\n\n"
615
-
616
- # Wait for the thread to complete (handles exceptions)
617
- await asyncio.wrap_future(future)
618
 
619
  except Exception as e:
620
  import traceback
@@ -650,31 +591,14 @@ async def _stream_web_agent_inner(messages, endpoint, token, model, serper_key,
650
 
651
  try:
652
  client = OpenAI(base_url=endpoint, api_key=token)
653
-
654
  system_prompt = get_system_prompt("agent")
655
  full_messages = [{"role": "system", "content": system_prompt}] + messages
656
 
657
-
658
-
659
- loop = asyncio.get_event_loop()
660
- queue = asyncio.Queue()
661
-
662
- def run_sync_generator():
663
- try:
664
- for update in stream_agent_execution(client, model, full_messages, serper_key, extra_params=extra_params, abort_event=abort_event, multimodal=multimodal):
665
- loop.call_soon_threadsafe(queue.put_nowait, update)
666
- finally:
667
- loop.call_soon_threadsafe(queue.put_nowait, None)
668
-
669
- future = loop.run_in_executor(_executor, run_sync_generator)
670
-
671
- while True:
672
- update = await queue.get()
673
- if update is None:
674
- break
675
- yield f"data: {json.dumps(update)}\n\n"
676
-
677
- await asyncio.wrap_future(future)
678
 
679
  except Exception as e:
680
  import traceback
@@ -716,31 +640,16 @@ async def _stream_image_agent_inner(messages, endpoint, token, model, hf_token,
716
 
717
  try:
718
  client = OpenAI(base_url=endpoint, api_key=token)
719
-
720
  system_prompt = get_system_prompt("image")
721
  full_messages = [{"role": "system", "content": system_prompt}] + messages
722
 
723
-
724
-
725
- loop = asyncio.get_event_loop()
726
- queue = asyncio.Queue()
727
-
728
- def run_sync_generator():
729
- try:
730
- for update in stream_image_execution(client, model, full_messages, hf_token, image_gen_model=image_gen_model, image_edit_model=image_edit_model, extra_params=extra_params, abort_event=abort_event, files_root=files_root, multimodal=multimodal):
731
- loop.call_soon_threadsafe(queue.put_nowait, update)
732
- finally:
733
- loop.call_soon_threadsafe(queue.put_nowait, None)
734
-
735
- future = loop.run_in_executor(_executor, run_sync_generator)
736
-
737
- while True:
738
- update = await queue.get()
739
- if update is None:
740
- break
741
- yield f"data: {json.dumps(update)}\n\n"
742
-
743
- await asyncio.wrap_future(future)
744
 
745
  except Exception as e:
746
  import traceback
 
23
  # Flag to signal shutdown to running threads
24
  _shutdown_flag = False
25
 
26
+
27
+ async def _stream_sync_generator(sync_gen_func, *args, **kwargs):
28
+ """Run a sync generator in a thread, yielding SSE-formatted JSON lines.
29
+
30
+ This is the standard pattern for all agent handlers: wrap a blocking
31
+ sync generator so it doesn't block the async event loop.
32
+ """
33
+ loop = asyncio.get_event_loop()
34
+ queue = asyncio.Queue()
35
+
36
+ def run():
37
+ try:
38
+ for update in sync_gen_func(*args, **kwargs):
39
+ loop.call_soon_threadsafe(queue.put_nowait, update)
40
+ finally:
41
+ loop.call_soon_threadsafe(queue.put_nowait, None)
42
+
43
+ future = loop.run_in_executor(_executor, run)
44
+ while True:
45
+ update = await queue.get()
46
+ if update is None:
47
+ break
48
+ yield f"data: {json.dumps(update)}\n\n"
49
+ await asyncio.wrap_future(future)
50
+
51
  def signal_handler(signum, frame):
52
  """Handle Ctrl+C by setting shutdown flag and exiting"""
53
  global _shutdown_flag
 
391
  return
392
 
393
  try:
 
394
  if session_id not in SANDBOXES:
395
  os.environ["E2B_API_KEY"] = e2b_key
396
  SANDBOXES[session_id] = Sandbox.create(timeout=SANDBOX_TIMEOUT)
397
 
398
  sbx = SANDBOXES[session_id]
 
 
399
  client = OpenAI(base_url=endpoint, api_key=token)
 
 
400
  system_prompt = get_system_prompt("code", frontend_context)
401
+ full_messages = [{"role": "system", "content": system_prompt}] + messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
+ async for chunk in _stream_sync_generator(
404
+ stream_code_execution, client, model, full_messages, sbx,
405
+ files_root=files_root or FILES_ROOT, extra_params=extra_params,
406
+ abort_event=abort_event, multimodal=multimodal
407
+ ):
408
+ yield chunk
409
 
410
  except Exception as e:
411
  import traceback
 
415
  # Check if this is a sandbox timeout error (502)
416
  error_str = str(e)
417
  if "502" in error_str or "sandbox was not found" in error_str.lower() or "timeout" in error_str.lower():
 
418
  if session_id in SANDBOXES:
419
  try:
420
  SANDBOXES[session_id].kill()
 
422
  pass
423
  del SANDBOXES[session_id]
424
 
 
425
  yield f"data: {json.dumps({'type': 'info', 'content': 'Sandbox timed out. Creating new sandbox and retrying...'})}\n\n"
426
 
 
427
  try:
428
  os.environ["E2B_API_KEY"] = e2b_key
429
  SANDBOXES[session_id] = Sandbox.create(timeout=SANDBOX_TIMEOUT)
 
431
 
432
  yield f"data: {json.dumps({'type': 'info', 'content': 'New sandbox created. Retrying execution...'})}\n\n"
433
 
434
+ async for chunk in _stream_sync_generator(
435
+ stream_code_execution, client, model, full_messages, sbx,
436
+ files_root=files_root or FILES_ROOT, extra_params=extra_params,
437
+ abort_event=abort_event, multimodal=multimodal
438
+ ):
439
+ yield chunk
 
 
 
 
 
 
 
 
 
 
 
 
 
440
 
441
  except Exception as retry_error:
442
  yield f"data: {json.dumps({'type': 'error', 'content': f'Failed to retry after timeout: {str(retry_error)}'})}\n\n"
 
505
  # Use max websites if provided, otherwise default to 50
506
  max_sites = max_websites if max_websites else 50
507
 
508
+ async for chunk in _stream_sync_generator(
509
+ stream_research, client, model, question, serper_key,
510
+ max_websites=max_sites, system_prompt=system_prompt,
511
+ sub_agent_model=analysis_model, parallel_workers=workers,
512
+ sub_agent_client=sub_agent_client, extra_params=extra_params,
513
+ sub_agent_extra_params=sub_agent_extra_params, abort_event=abort_event
514
+ ):
515
+ yield chunk
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
 
517
  except Exception as e:
518
  import traceback
 
546
  return
547
 
548
  try:
 
549
  client = OpenAI(base_url=endpoint, api_key=token)
 
 
 
550
  system_prompt = get_system_prompt("command")
551
  full_messages = [{"role": "system", "content": system_prompt}] + messages
552
 
553
+ async for chunk in _stream_sync_generator(
554
+ stream_command_center, client, model, full_messages,
555
+ extra_params=extra_params, abort_event=abort_event,
556
+ files_root=files_root or FILES_ROOT
557
+ ):
558
+ yield chunk
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
 
560
  except Exception as e:
561
  import traceback
 
591
 
592
  try:
593
  client = OpenAI(base_url=endpoint, api_key=token)
 
594
  system_prompt = get_system_prompt("agent")
595
  full_messages = [{"role": "system", "content": system_prompt}] + messages
596
 
597
+ async for chunk in _stream_sync_generator(
598
+ stream_agent_execution, client, model, full_messages, serper_key,
599
+ extra_params=extra_params, abort_event=abort_event, multimodal=multimodal
600
+ ):
601
+ yield chunk
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
 
603
  except Exception as e:
604
  import traceback
 
640
 
641
  try:
642
  client = OpenAI(base_url=endpoint, api_key=token)
 
643
  system_prompt = get_system_prompt("image")
644
  full_messages = [{"role": "system", "content": system_prompt}] + messages
645
 
646
+ async for chunk in _stream_sync_generator(
647
+ stream_image_execution, client, model, full_messages, hf_token,
648
+ image_gen_model=image_gen_model, image_edit_model=image_edit_model,
649
+ extra_params=extra_params, abort_event=abort_event,
650
+ files_root=files_root, multimodal=multimodal
651
+ ):
652
+ yield chunk
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
 
654
  except Exception as e:
655
  import traceback
backend/tools.py CHANGED
@@ -626,3 +626,19 @@ def execute_show_html(source: str, files_root: str = None) -> dict:
626
  "content": f"Failed to load HTML from '{source}': {e}",
627
  "html": None,
628
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  "content": f"Failed to load HTML from '{source}': {e}",
627
  "html": None,
628
  }
629
+
630
+
631
+ # ============================================================
632
+ # Direct tool registry (used by command center)
633
+ # ============================================================
634
+ # Each entry combines the OpenAI tool schema with an execute function.
635
+ # The execute function receives (args_dict, context_dict).
636
+
637
+ DIRECT_TOOL_REGISTRY = {
638
+ "show_html": {
639
+ "schema": show_html,
640
+ "execute": lambda args, ctx: execute_show_html(
641
+ args.get("source", ""), files_root=ctx.get("files_root")
642
+ ),
643
+ },
644
+ }
frontend/index.html CHANGED
@@ -505,6 +505,6 @@
505
  </div>
506
 
507
  <script src="research-ui.js?v=23"></script>
508
- <script src="script.js?v=98"></script>
509
  </body>
510
  </html>
 
505
  </div>
506
 
507
  <script src="research-ui.js?v=23"></script>
508
+ <script src="script.js?v=99"></script>
509
  </body>
510
  </html>
frontend/script.js CHANGED
@@ -15,16 +15,10 @@ function sanitizeUsername(name) {
15
  }
16
 
17
  // ============================================================
18
- // Agent Type Registry — single source of truth for the frontend
19
- // To add a new agent type, add an entry here and in backend/agents.py
20
  // ============================================================
21
- const AGENT_REGISTRY = {
22
- command: { label: 'MAIN', hasCounter: false, inMenu: false, inLauncher: false, placeholder: 'Enter message...' },
23
- agent: { label: 'AGENT', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
24
- code: { label: 'CODE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
25
- research: { label: 'RESEARCH', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
26
- image: { label: 'IMAGE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Describe an image or paste a URL...' },
27
- };
28
  // Virtual types used only in timeline rendering (not real agents)
29
  const VIRTUAL_TYPE_LABELS = { search: 'SEARCH', browse: 'BROWSE' };
30
 
@@ -69,7 +63,7 @@ let settings = {
69
  // New provider/model structure
70
  providers: {}, // providerId -> {name, endpoint, token}
71
  models: {}, // modelId -> {name, providerId, modelId (API model string)}
72
- agents: Object.fromEntries(Object.keys(AGENT_REGISTRY).map(k => [k, ''])),
73
  // Service API keys
74
  e2bKey: '',
75
  serperKey: '',
@@ -737,6 +731,27 @@ document.addEventListener('DOMContentLoaded', async () => {
737
  }
738
  }
739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  await loadSettings();
741
  applyTheme(settings.themeColor || 'forest');
742
  initializeEventListeners();
 
15
  }
16
 
17
  // ============================================================
18
+ // Agent Type Registry — populated from backend /api/agents at startup
19
+ // To add a new agent type, add an entry in backend/agents.py (single source of truth)
20
  // ============================================================
21
+ let AGENT_REGISTRY = {};
 
 
 
 
 
 
22
  // Virtual types used only in timeline rendering (not real agents)
23
  const VIRTUAL_TYPE_LABELS = { search: 'SEARCH', browse: 'BROWSE' };
24
 
 
63
  // New provider/model structure
64
  providers: {}, // providerId -> {name, endpoint, token}
65
  models: {}, // modelId -> {name, providerId, modelId (API model string)}
66
+ agents: {}, // Populated after AGENT_REGISTRY is fetched
67
  // Service API keys
68
  e2bKey: '',
69
  serperKey: '',
 
731
  }
732
  }
733
 
734
+ // Fetch agent registry from backend (single source of truth)
735
+ try {
736
+ const agentsResp = await apiFetch('/api/agents');
737
+ const agentsData = await agentsResp.json();
738
+ for (const agent of agentsData.agents) {
739
+ AGENT_REGISTRY[agent.key] = agent;
740
+ }
741
+ } catch (e) {
742
+ console.error('Failed to fetch agent registry, using fallback');
743
+ // Minimal fallback so the app still works if backend is slow
744
+ AGENT_REGISTRY = {
745
+ command: { label: 'MAIN', hasCounter: false, inMenu: false, inLauncher: false, placeholder: 'Enter message...' },
746
+ agent: { label: 'AGENT', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
747
+ code: { label: 'CODE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
748
+ research: { label: 'RESEARCH', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' },
749
+ image: { label: 'IMAGE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Describe an image or paste a URL...' },
750
+ };
751
+ }
752
+ // Initialize settings.agents with registry keys (before loadSettings merges saved values)
753
+ settings.agents = Object.fromEntries(Object.keys(AGENT_REGISTRY).map(k => [k, '']));
754
+
755
  await loadSettings();
756
  applyTheme(settings.themeColor || 'forest');
757
  initializeEventListeners();