Skip to content

Commit daf5329

Browse files
authored
Merge pull request #844 from ruska-ai/feat/843-sandbox-composer-position
Move sandbox status above chat composer
2 parents 98b2216 + b752965 commit daf5329

29 files changed

Lines changed: 1245 additions & 258 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Plan: Fix Sandbox "State" Selection Still Using Daytona
2+
3+
## Context
4+
5+
**Bug**: User switches sandbox from "Daytona" to "State" in the UI, but the backend still executes commands in Daytona (confirmed by `pwd` returning `/home/daytona`).
6+
7+
**Root cause**: `toSandboxPatchValue("state")` in `frontend/src/lib/config/sandbox.ts:48-50` converts the default value to `null` before sending to the backend. The backend stores `default_sandbox = None`. Then `resolve_sandbox_backend(None)` in `backend/src/agents/__init__.py:332-340` treats `None` as "auto" — try Daytona first, fall back to State. So selecting "State" effectively becomes "Auto" behavior.
8+
9+
**Resolution path**: The frontend `toSandboxPatchValue` function should always send the literal string value (`"state"` or `"daytona"`), never `null`. This ensures the backend receives and stores `"state"` explicitly, and `resolve_sandbox_backend("state")` correctly routes to StateBackend without trying Daytona.
10+
11+
## Changes
12+
13+
### 1. Frontend: `frontend/src/lib/config/sandbox.ts`
14+
15+
**`toSandboxPatchValue()`** — always return the literal value, never `null`:
16+
```typescript
17+
// BEFORE (buggy):
18+
export function toSandboxPatchValue(value: SandboxType): string | null {
19+
return value === DEFAULT_SANDBOX ? null : value;
20+
}
21+
22+
// AFTER (fixed):
23+
export function toSandboxPatchValue(value: SandboxType): string {
24+
return value;
25+
}
26+
```
27+
28+
### 2. Frontend: `frontend/src/components/status/ThreadSandboxStatus.test.tsx`
29+
30+
Update the test assertion that checks the PATCH payload:
31+
```typescript
32+
// BEFORE:
33+
expect(mockPatchDefaults).toHaveBeenCalledWith({ sandbox: null });
34+
35+
// AFTER:
36+
expect(mockPatchDefaults).toHaveBeenCalledWith({ sandbox: "state" });
37+
```
38+
39+
## Files to Modify
40+
41+
| File | Change |
42+
|------|--------|
43+
| `frontend/src/lib/config/sandbox.ts` | `toSandboxPatchValue` always returns the literal string |
44+
| `frontend/src/components/status/ThreadSandboxStatus.test.tsx` | Update expected PATCH payload from `null` to `"state"` |
45+
46+
## Verification
47+
48+
1. `cd frontend && npx vitest run src/components/status/ThreadSandboxStatus.test.tsx` — tests pass
49+
2. `cd frontend && npm run test` — all tests pass
50+
3. Manual: Set sandbox to "State" → run a command → `pwd` should NOT return `/home/daytona`
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Plan: Move Links Above ChatInput in AgentSection
2+
3+
## Context
4+
On the `/chat` page (initial landing view), the links row (Docs, Blog, Social, Slack) currently renders **below** the ChatInput. The user wants them moved **between the subtext and the ChatInput** — i.e., above the input area.
5+
6+
## File to Modify
7+
8+
### `frontend/src/components/sections/agent-section.tsx`
9+
10+
Current order (lines 22-90):
11+
1. `<p>` subtext (line 23)
12+
2. `<div>` ChatInput wrapper (lines 24-26)
13+
3. `<div>` links row (lines 29-90)
14+
15+
Target order:
16+
1. `<p>` subtext (line 23)
17+
2. `<div>` links row — moved here, update comment and change `mt-3``mb-3`
18+
3. `<div>` ChatInput wrapper
19+
20+
**Changes:**
21+
- Cut the links `<div>` block (lines 28-90) and paste it between line 23 (`<p>` subtext) and line 24 (`<div>` ChatInput wrapper)
22+
- Change `mt-3` to `mb-3` on the links container so spacing flows downward toward the input
23+
- Update the comment to reflect new position
24+
25+
No other files need changes.
26+
27+
## Verification
28+
1. `npx tsc --noEmit` from `frontend/` — no type errors
29+
2. `npm run test` — no test regressions
30+
3. Navigate to `/chat` — links row (Docs, Blog, Social, Slack) appears between subtext and ChatInput
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Fix CI Tests Not Running on Push
2+
3+
## Context
4+
CI tests stopped running on feature branch pushes after commit `9952aa01` (Feb 23) restricted the push trigger in `test.yml` to only `development` and `main` branches. The `pull_request` trigger was also commented out. This means no tests run for any feature branch work.
5+
6+
## Change
7+
**File:** `.github/workflows/test.yml`
8+
9+
1. **Line 5-7**: Change push branch filter from explicit list to wildcard:
10+
```yaml
11+
push:
12+
branches:
13+
- "*"
14+
```
15+
2. **Leave `pull_request` commented out** — not needed since push covers all branches now.
16+
17+
## Verification
18+
- Push to the current feature branch (`feat/843-sandbox-composer-position`)
19+
- Confirm the Test workflow appears in GitHub Actions
20+
- Verify both `test-frontend` and `test-backend` jobs run

.github/workflows/test.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ name: Test
33
on:
44
push:
55
branches:
6-
- "development"
7-
- "main"
6+
- "*"
87
paths-ignore:
98
- "Changelog.md"
109
- "docker/**"

backend/src/agents/__init__.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
# Conditional import for Daytona sandbox support
2323
try:
2424
from daytona import Daytona, DaytonaConfig
25+
from daytona.common.errors import DaytonaError
2526
from langchain_daytona import DaytonaSandbox
2627
except ImportError:
2728
Daytona = None # type: ignore[assignment,misc]
2829
DaytonaConfig = None # type: ignore[assignment,misc]
2930
DaytonaSandbox = None # type: ignore[assignment,misc]
31+
DaytonaError = None # type: ignore[assignment,misc]
3032

3133
from src.constants import APP_ENV, DAYTONA_API_KEY
3234
from src.contexts.service import ServiceContext
@@ -43,6 +45,13 @@
4345
from src.tools import default_tools
4446

4547

48+
def is_daytona_error(exc: Exception) -> bool:
49+
"""Check if an exception is a DaytonaError (safe when package not installed)."""
50+
if DaytonaError is None:
51+
return False
52+
return isinstance(exc, DaytonaError)
53+
54+
4655
CACHE_LLM = InMemoryCache()
4756

4857

@@ -306,7 +315,7 @@ def _create_state_backend(
306315
def resolve_sandbox_backend(
307316
runtime: ToolRuntime,
308317
sandbox_type: str | None = None,
309-
) -> tuple[CompositeBackend, Any]:
318+
) -> tuple[CompositeBackend, Any, str]:
310319
"""Resolve a sandbox backend based on *sandbox_type*.
311320
312321
Dispatch rules:
@@ -315,20 +324,23 @@ def resolve_sandbox_backend(
315324
* ``"daytona"`` — try Daytona, fall back to State if unavailable.
316325
* Any unknown value — treated as ``"auto"``.
317326
318-
Returns ``(backend, daytona_sandbox_or_None)``.
327+
Returns ``(backend, daytona_sandbox_or_None, effective_type)``
328+
where effective_type is ``"daytona"`` or ``"state"``.
319329
"""
320330
effective = sandbox_type if sandbox_type in _SANDBOX_FACTORIES else None
321331

322332
if effective == "state":
323-
return _create_state_backend(runtime)
333+
backend, sandbox = _create_state_backend(runtime)
334+
return backend, sandbox, "state"
324335

325336
# "daytona" or auto (None) — try Daytona first
326337
result = _create_daytona_backend_checked(runtime)
327338
if result is not None:
328-
return result
339+
return result[0], result[1], "daytona"
329340

330341
# Fallback: plain StateBackend (silent, no messages)
331-
return _create_state_backend(runtime)
342+
backend, sandbox = _create_state_backend(runtime)
343+
return backend, sandbox, "state"
332344

333345

334346
################################################################################

backend/src/controllers/llm.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
from src.agents import (
1212
construct_agent,
1313
init_config,
14+
is_daytona_error,
1415
prepare_memory_files,
1516
resolve_sandbox_backend,
17+
_create_state_backend,
1618
)
1719
from src.services.db import get_checkpoint_db
1820
from src.utils.stream import stream_generator
@@ -125,7 +127,7 @@ async def llm_invoke(self, params: LLMRequest):
125127

126128
async with get_checkpoint_db() as checkpointer:
127129
runtime = self._init_runtime(params)
128-
backend, _sandbox = resolve_sandbox_backend(runtime, sandbox_type=default_sandbox)
130+
backend, _sandbox, effective_type = resolve_sandbox_backend(runtime, sandbox_type=default_sandbox)
129131
agent: Orchestra = await construct_agent(
130132
instructions=params.instructions,
131133
system_prompt=params.system_prompt,
@@ -145,6 +147,42 @@ async def llm_invoke(self, params: LLMRequest):
145147
)
146148
return response
147149
except Exception as e:
150+
if is_daytona_error(e):
151+
if default_sandbox == "daytona":
152+
# Explicit daytona mode: surface the detailed error
153+
logger.error(f"Daytona sandbox error (daytona mode): {e}")
154+
if agent and config:
155+
await self._update_store(agent, config)
156+
raise
157+
elif default_sandbox in (None, "auto") and effective_type == "daytona":
158+
# Auto mode: fallback to local StateBackend and retry
159+
logger.warning(f"Daytona sandbox error in auto mode, falling back to local: {e}")
160+
try:
161+
fallback_backend, _ = _create_state_backend(runtime)
162+
agent = await construct_agent(
163+
instructions=params.instructions,
164+
system_prompt=params.system_prompt,
165+
model=params.model,
166+
tools=params.tools,
167+
subagents=params.subagents,
168+
checkpointer=checkpointer,
169+
backend=fallback_backend,
170+
service_context=self.service_context,
171+
api_key=api_key,
172+
memory=memory_sources,
173+
)
174+
response = await agent.invoke(
175+
params.input,
176+
config=config,
177+
context=self._init_context(params),
178+
)
179+
return response
180+
except Exception as fallback_err:
181+
logger.exception(f"Fallback also failed in llm_invoke: {fallback_err}")
182+
if agent and config:
183+
await self._update_store(agent, config)
184+
raise fallback_err
185+
148186
logger.exception(f"Error in llm_invoke: {e}")
149187
if agent and config:
150188
await self._update_store(agent, config)

backend/src/schemas/entities/settings.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
class SandboxType(str, Enum):
1010
"""Supported sandbox backend types."""
1111

12-
AUTO = "auto"
1312
DAYTONA = "daytona"
1413
STATE = "state"
1514

backend/src/utils/stream.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414
from src.contexts.service import ServiceContext
1515
from src.schemas.entities import LLMInput
1616
from src.constants import APP_LOG_LEVEL
17-
from src.agents import construct_agent, resolve_sandbox_backend, prepare_memory_files
17+
from src.agents import (
18+
construct_agent,
19+
resolve_sandbox_backend,
20+
prepare_memory_files,
21+
is_daytona_error,
22+
_create_state_backend,
23+
)
1824
from src.services.db import get_checkpoint_db
1925
from src.utils.messages import from_message_to_dict
2026
from langchain_core.messages import (
@@ -223,7 +229,7 @@ async def stream_generator(
223229
stream_writer=lambda _: None,
224230
config=config,
225231
)
226-
backend, _sandbox = resolve_sandbox_backend(runtime, sandbox_type=sandbox_type)
232+
backend, _sandbox, effective_type = resolve_sandbox_backend(runtime, sandbox_type=sandbox_type)
227233
agent = await construct_agent(
228234
instructions=instructions,
229235
system_prompt=system_prompt,
@@ -281,16 +287,62 @@ async def stream_generator(
281287
except PIIDetectionError as e:
282288
# Yield error as SSE if streaming fails
283289
logger.warning(f"Sensitive data detected in the query: {e}")
284-
# raise HTTPException(status_code=500, detail=str(e))
285290
error_msg = ujson.dumps(("error", str(e)))
286291
yield f"data: {error_msg}\n\n"
287292

288293
except Exception as e:
289-
# Yield error as SSE if streaming fails
290-
logger.exception("Error in stream_generator: %s", e)
291-
# raise HTTPException(status_code=500, detail=str(e))
292-
error_msg = ujson.dumps(("error", str(e)))
293-
yield f"data: {error_msg}\n\n"
294+
if is_daytona_error(e):
295+
if sandbox_type == "daytona":
296+
# Explicit daytona mode: surface the detailed error
297+
logger.error(f"Daytona sandbox error (daytona mode): {e}")
298+
error_msg = ujson.dumps(("error", f"Daytona sandbox error: {e}"))
299+
yield f"data: {error_msg}\n\n"
300+
elif sandbox_type in (None, "auto") and effective_type == "daytona":
301+
# Auto mode: fallback to local StateBackend and retry
302+
logger.warning(f"Daytona sandbox error in auto mode, falling back to local: {e}")
303+
try:
304+
fallback_backend, _ = _create_state_backend(runtime)
305+
agent = await construct_agent(
306+
instructions=instructions,
307+
system_prompt=system_prompt,
308+
model=model,
309+
tools=tools,
310+
subagents=subagents,
311+
checkpointer=checkpointer,
312+
backend=fallback_backend,
313+
service_context=service_context,
314+
api_key=api_key,
315+
memory=memory_sources,
316+
)
317+
async for chunk in agent.astream(
318+
input,
319+
**astream_kwargs,
320+
):
321+
stream_chunk = handle_multi_mode(chunk)
322+
if stream_chunk:
323+
stream_type = stream_chunk[0]
324+
chunk_data = stream_chunk[1]
325+
if stream_type == "values" and "files" in chunk_data:
326+
files_map = {**files_map, **chunk_data["files"]}
327+
if stream_type == "values" and "todos" in chunk_data:
328+
todos_list = chunk_data["todos"]
329+
data = ujson.dumps(stream_chunk)
330+
log_to_file(str(data), agent.model) and APP_LOG_LEVEL == "DEBUG"
331+
logger.debug(f"data: {str(data)}")
332+
yield f"data: {data}\n\n"
333+
except Exception as fallback_err:
334+
logger.exception("Fallback also failed in stream_generator: %s", fallback_err)
335+
error_msg = ujson.dumps(("error", str(fallback_err)))
336+
yield f"data: {error_msg}\n\n"
337+
else:
338+
logger.exception("Error in stream_generator: %s", e)
339+
error_msg = ujson.dumps(("error", str(e)))
340+
yield f"data: {error_msg}\n\n"
341+
else:
342+
# Non-Daytona error: original behavior
343+
logger.exception("Error in stream_generator: %s", e)
344+
error_msg = ujson.dumps(("error", str(e)))
345+
yield f"data: {error_msg}\n\n"
294346
finally:
295347
try:
296348
if service_context.user_id and checkpointer and agent:

0 commit comments

Comments
 (0)