Skip to content

Commit 4a5a995

Browse files
authored
Merge pull request #44 from XSpoonAi/fix/streaming-thinking-timeout
fix: forward thinking param in stream mode and harden stream timeout
2 parents b417b34 + fa0c912 commit 4a5a995

File tree

13 files changed

+688
-30
lines changed

13 files changed

+688
-30
lines changed

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ OPENROUTER_API_KEY=sk-or-your-openrouter-api-key-here
6262
# Examples: 128000 (128K), 200000 (200K), 1000000 (1M)
6363
# CONTEXT_WINDOW=128000
6464

65+
# ======= YOLO Mode (optional) =======
66+
#
67+
# When enabled, the agent operates directly in SPOON_BOT_WORKSPACE_PATH
68+
# without sandbox isolation — all shell commands and file operations run
69+
# against your real filesystem. Useful for local dev workflows.
70+
# SPOON_BOT_YOLO_MODE=false
71+
6572
# ======= Channel Bot Tokens (optional) =======
6673
# Set these to auto-enable channels without needing config.yaml.
6774
# If config.yaml also specifies a token, the YAML value takes priority.

Dockerfile

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ ENV SPOON_BOT_LOG_LEVEL=INFO
113113
# --- Workspace ---
114114
ENV SPOON_BOT_WORKSPACE_PATH=/data/workspace
115115

116+
# --- YOLO Mode ---
117+
# When true, the agent operates directly in the user-mounted path
118+
# instead of the sandboxed /data/workspace. Mount your host directory
119+
# to SPOON_BOT_WORKSPACE_PATH and set YOLO_MODE to activate.
120+
# docker run -v /home/user/project:/project \
121+
# -e SPOON_BOT_YOLO_MODE=true \
122+
# -e SPOON_BOT_WORKSPACE_PATH=/project ...
123+
ENV SPOON_BOT_YOLO_MODE=false
124+
116125
# Create workspace directory with correct ownership
117126
RUN mkdir -p /data/workspace/memory /data/workspace/skills \
118127
&& chown -R spoonbot:spoonbot /data /app

config.example.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ agent:
1616
workspace: "~/.spoon-bot/workspace"
1717
max_iterations: 20
1818
tool_profile: "core" # Options: core, coding, web3, research, full
19+
# yolo_mode: false # YOLO mode: work directly in `workspace` path
20+
# instead of a sandboxed directory. The agent
21+
# reads/writes/executes against your real filesystem.
22+
# Enable via config or env: SPOON_BOT_YOLO_MODE=true
1923
# enabled_tools: # Optional fine-grained override
2024
# - shell
2125
# - read_file

spoon_bot/agent/context.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,17 @@ class ContextBuilder:
2222
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
2323
SANDBOX_WORKSPACE_ROOT = "/workspace"
2424

25-
def __init__(self, workspace: Path):
25+
def __init__(self, workspace: Path, *, yolo_mode: bool = False):
2626
"""
2727
Initialize context builder.
2828
2929
Args:
3030
workspace: Path to the workspace directory.
31+
yolo_mode: When True, the agent operates directly in the user's
32+
filesystem path without sandbox isolation.
3133
"""
3234
self.workspace = Path(workspace).expanduser().resolve()
35+
self.yolo_mode = yolo_mode
3336
self._memory_context: str = ""
3437
self._skills_summary: str = ""
3538
self._skill_context: str = ""
@@ -98,12 +101,20 @@ def _to_posix(p: str) -> str:
98101
else:
99102
shell_path = display_path
100103

104+
yolo_banner = ""
105+
if self.yolo_mode:
106+
yolo_banner = (
107+
"\n**YOLO MODE ACTIVE** — You are operating directly on the "
108+
"user's filesystem. All file reads, writes, and shell commands "
109+
"execute against this real directory tree. Proceed with care.\n"
110+
)
111+
101112
return f"""# spoon-bot
102113
103114
You are spoon-bot, an AI agent that completes tasks by calling tools.
104115
Current time: {now}
105116
Workspace: {display_path}
106-
117+
{yolo_banner}
107118
## Core Behavior — ALWAYS USE TOOLS
108119
109120
You MUST use your tools to accomplish tasks. NEVER fabricate results, NEVER pretend to execute commands, NEVER invent output. If a tool exists for the job, call it.

spoon_bot/agent/loop.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ def __init__(
337337
auto_reload: bool = False,
338338
auto_reload_interval: float = 5.0,
339339
config_path: Path | str | None = None,
340+
yolo_mode: bool = False,
340341
) -> None:
341342
"""
342343
Initialize the agent loop.
@@ -362,6 +363,7 @@ def __init__(
362363
session_store_dsn: PostgreSQL DSN for 'postgres' backend.
363364
session_store_db_path: SQLite DB path for 'sqlite' backend.
364365
context_window: Override context window in tokens (auto-resolved from model if None).
366+
yolo_mode: Operate directly in user's path without sandbox isolation.
365367
"""
366368
# Validate parameters
367369
try:
@@ -374,13 +376,15 @@ def __init__(
374376
session_key=session_key,
375377
skill_paths=skill_paths,
376378
mcp_config=mcp_config,
379+
yolo_mode=yolo_mode,
377380
)
378381
except Exception as e:
379382
logger.error(f"Configuration validation failed: {e}")
380383
raise ValueError(f"Invalid AgentLoop configuration: {e}") from e
381384

382385
# Store config — callers must provide model/provider explicitly
383386
self.workspace = self._config.workspace
387+
self.yolo_mode = self._config.yolo_mode
384388
self.model = model
385389
self.provider = provider
386390
self.api_key = api_key
@@ -405,9 +409,12 @@ def __init__(
405409
self._mcp_tools: list[MCPTool] = []
406410

407411
# spoon-bot components
408-
self.context = ContextBuilder(self.workspace)
412+
self.context = ContextBuilder(self.workspace, yolo_mode=self.yolo_mode)
409413
self.tools = ToolRegistry()
410414

415+
if self.yolo_mode:
416+
logger.info(f"YOLO mode enabled — operating directly in: {self.workspace}")
417+
411418
# Session persistence — configurable backend
412419
_store_backend = session_store_backend or "file"
413420
_store_db_path = session_store_db_path
@@ -712,7 +719,12 @@ def _register_native_tools(self) -> None:
712719
# skill-managed data (e.g. ~/.agent-wallet, ~/.spoon-bot/skills) is
713720
# accessible. The PathValidator blocklist still blocks truly sensitive
714721
# paths (.ssh, .aws, etc.).
715-
_extra_read = [Path.home()]
722+
#
723+
# In YOLO mode the workspace IS the user's directory, so we add its
724+
# parents as extra read paths to let the agent navigate freely.
725+
_extra_read: list[Path] = [Path.home()]
726+
if self.yolo_mode:
727+
_extra_read.extend(p for p in self.workspace.parents if p != Path.home())
716728
self.tools.register(ReadFileTool(workspace=self.workspace, additional_read_paths=_extra_read, max_output=15000))
717729
self.tools.register(WriteFileTool(workspace=self.workspace))
718730
self.tools.register(EditFileTool(workspace=self.workspace))
@@ -2079,7 +2091,10 @@ async def stream(
20792091
async def _run_and_signal() -> None:
20802092
nonlocal run_result_text
20812093
try:
2082-
result = await self._agent.run()
2094+
run_kwargs: dict[str, Any] = {}
2095+
if thinking and self._callable_accepts_kwarg(self._agent.run, "thinking"):
2096+
run_kwargs["thinking"] = True
2097+
result = await self._agent.run(**run_kwargs)
20832098
if hasattr(result, "content"):
20842099
run_result_text = result.content or ""
20852100
elif isinstance(result, str):
@@ -2109,14 +2124,27 @@ async def _run_and_signal() -> None:
21092124
oq = self._agent.output_queue
21102125
td = self._agent.task_done
21112126
logger.debug(f"output_queue type={type(oq).__name__}, task_done type={type(td).__name__}")
2112-
stream_timeout = 120.0
2127+
stream_timeout = 600.0 if thinking else 300.0
21132128
deadline = asyncio.get_event_loop().time() + stream_timeout
21142129
chunk_count = 0
21152130

21162131
logger.debug(f"Entering stream loop: td={td.is_set()}, qempty={oq.empty()}, qsize={oq.qsize()}")
21172132
while not (td.is_set() and oq.empty()):
21182133
if asyncio.get_event_loop().time() > deadline:
2119-
logger.warning("Streaming deadline reached, stopping")
2134+
logger.warning(
2135+
f"Streaming deadline reached ({stream_timeout}s), "
2136+
f"stopping after {chunk_count} chunks"
2137+
)
2138+
yield {
2139+
"type": "error",
2140+
"delta": f"Stream timeout after {int(stream_timeout)}s",
2141+
"metadata": {
2142+
"error": "STREAM_TIMEOUT",
2143+
"error_code": "STREAM_TIMEOUT",
2144+
"timeout_seconds": stream_timeout,
2145+
"chunks_received": chunk_count,
2146+
},
2147+
}
21202148
break
21212149

21222150
try:
@@ -2815,6 +2843,7 @@ async def create_agent(
28152843
auto_reload: bool = False,
28162844
auto_reload_interval: float = 5.0,
28172845
config_path: Path | str | None = None,
2846+
yolo_mode: bool = False,
28182847
**kwargs: Any,
28192848
) -> AgentLoop:
28202849
"""
@@ -2839,6 +2868,7 @@ async def create_agent(
28392868
auto_commit: Whether to auto-commit workspace changes after each message.
28402869
enabled_tools: Explicit set of tool names to enable. None = core only.
28412870
tool_profile: Named profile ('core', 'coding', 'web3', 'research', 'full').
2871+
yolo_mode: Operate directly in user's path without sandbox isolation.
28422872
**kwargs: Additional arguments for AgentLoop.
28432873
28442874
Returns:
@@ -2851,9 +2881,8 @@ async def create_agent(
28512881
>>> # Load all tools
28522882
>>> agent = await create_agent(tool_profile="full")
28532883
2854-
>>> # Dynamically add a tool after creation
2855-
>>> agent = await create_agent()
2856-
>>> agent.add_tool("web_search")
2884+
>>> # YOLO mode — work in /home/user/project directly
2885+
>>> agent = await create_agent(yolo_mode=True, workspace="/home/user/project")
28572886
"""
28582887
agent = AgentLoop(
28592888
model=model,
@@ -2871,6 +2900,7 @@ async def create_agent(
28712900
auto_reload=auto_reload,
28722901
auto_reload_interval=auto_reload_interval,
28732902
config_path=config_path,
2903+
yolo_mode=yolo_mode,
28742904
**kwargs,
28752905
)
28762906

spoon_bot/channels/config.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ def load_agent_config(config_path: str | Path | None = None) -> dict[str, Any]:
535535
536536
Supported fields::
537537
538-
model, provider, api_key, base_url, workspace,
538+
model, provider, api_key, base_url, workspace, yolo_mode,
539539
max_iterations, tool_profile, enabled_tools, enable_skills,
540540
shell_timeout, max_output, context_window,
541541
session_store_backend, session_store_dsn, session_store_db_path,
@@ -683,19 +683,22 @@ def _resolve_env_deep(value: Any) -> Any:
683683
"workspace": ["SPOON_BOT_WORKSPACE_PATH"],
684684
"max_iterations": ["SPOON_BOT_MAX_ITERATIONS", "SPOON_MAX_STEPS"],
685685
"enable_skills": ["SPOON_BOT_ENABLE_SKILLS"],
686+
"yolo_mode": ["SPOON_BOT_YOLO_MODE"],
686687
"shell_timeout": ["SPOON_BOT_SHELL_TIMEOUT"],
687688
"max_output": ["SPOON_BOT_MAX_OUTPUT"],
688689
"context_window": ["CONTEXT_WINDOW"],
689690
}
691+
_bool_fields = {"enable_skills", "yolo_mode"}
692+
_int_fields = {"max_iterations", "shell_timeout", "max_output", "context_window"}
690693
for field, env_vars in agent_env_map.items():
691694
if not resolved.get(field):
692695
for var in env_vars:
693696
val = os.environ.get(var)
694697
if val:
695-
if field in {"max_iterations", "shell_timeout", "max_output", "context_window"}:
698+
if field in _int_fields:
696699
resolved[field] = int(val)
697-
elif field == "enable_skills":
698-
resolved[field] = val.lower() == "true"
700+
elif field in _bool_fields:
701+
resolved[field] = val.lower() in ("true", "1", "yes")
699702
else:
700703
resolved[field] = val
701704
logger.debug(f"Agent config: {field} from env var {var}")

spoon_bot/config.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,16 @@ class AgentLoopConfig(BaseModel):
362362
default_factory=lambda: Path.home() / ".spoon-bot" / "workspace",
363363
description="Workspace directory path"
364364
)
365+
366+
yolo_mode: bool = Field(
367+
default=False,
368+
description=(
369+
"YOLO mode: operate directly in the user's filesystem path "
370+
"instead of the sandboxed workspace. When enabled, the agent "
371+
"reads/writes files and runs commands in 'workspace' as a plain "
372+
"directory without sandbox isolation."
373+
),
374+
)
365375
model: str | None = Field(
366376
default=None,
367377
description="LLM model name (uses provider default if not specified)"
@@ -468,12 +478,21 @@ def coerce_skill_paths(cls, v: list[Path | str] | None) -> list[Path]:
468478

469479
@model_validator(mode="after")
470480
def validate_workspace_accessible(self) -> "AgentLoopConfig":
471-
"""Validate that workspace path can be created and is writable."""
481+
"""Validate that workspace path can be created and is writable.
482+
483+
In YOLO mode the directory must already exist; we skip the
484+
mkdir + write-test since the user is responsible for the path.
485+
"""
486+
if self.yolo_mode:
487+
if not self.workspace.is_dir():
488+
raise ValueError(
489+
f"YOLO mode workspace path does not exist: {self.workspace}"
490+
)
491+
return self
492+
472493
try:
473-
# Try to create the workspace directory
474494
self.workspace.mkdir(parents=True, exist_ok=True)
475495

476-
# Check if it's writable by creating a test file
477496
test_file = self.workspace / ".write_test"
478497
try:
479498
test_file.touch()
@@ -559,6 +578,10 @@ class SpoonBotSettings(BaseSettings):
559578
default_factory=lambda: Path.home() / ".spoon-bot" / "workspace",
560579
description="Default workspace directory"
561580
)
581+
yolo_mode: bool = Field(
582+
default=False,
583+
description="YOLO mode: operate in user's path instead of sandbox"
584+
)
562585

563586
# LLM settings
564587
default_provider: LLMProviderType = Field(
@@ -671,6 +694,7 @@ def validate_agent_loop_params(
671694
session_key: str = "default",
672695
skill_paths: list[Path | str] | None = None,
673696
mcp_config: dict[str, dict[str, Any]] | None = None,
697+
yolo_mode: bool = False,
674698
) -> AgentLoopConfig:
675699
"""
676700
Validate AgentLoop initialization parameters.
@@ -684,6 +708,7 @@ def validate_agent_loop_params(
684708
session_key: Session identifier.
685709
skill_paths: Additional skill search paths.
686710
mcp_config: MCP server configurations.
711+
yolo_mode: If True, operate directly in the user path without sandbox.
687712
688713
Returns:
689714
Validated AgentLoopConfig.
@@ -696,6 +721,10 @@ def validate_agent_loop_params(
696721
if mcp_config:
697722
mcp_servers = validate_mcp_configs(mcp_config)
698723

724+
# In YOLO mode, default workspace to CWD instead of ~/.spoon-bot/workspace
725+
if yolo_mode and workspace is None:
726+
workspace = Path.cwd()
727+
699728
return AgentLoopConfig(
700729
workspace=workspace, # type: ignore
701730
model=model,
@@ -705,4 +734,5 @@ def validate_agent_loop_params(
705734
session_key=session_key,
706735
skill_paths=skill_paths, # type: ignore
707736
mcp_servers=mcp_servers,
737+
yolo_mode=yolo_mode,
708738
)

spoon_bot/gateway/server.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ async def _lifespan(app: FastAPI):
9191
_ctx_env = os.environ.get("CONTEXT_WINDOW")
9292
context_window = int(_ctx_env) if _ctx_env else None
9393

94+
# YOLO mode: operate directly in user's path without sandbox
95+
yolo_mode = (
96+
agent_cfg.get("yolo_mode")
97+
or os.environ.get("SPOON_BOT_YOLO_MODE", "").lower() in ("1", "true", "yes")
98+
)
99+
if yolo_mode:
100+
logger.info("YOLO mode enabled — agent will work directly in user path")
101+
94102
create_kwargs: dict = dict(
95103
model=model,
96104
provider=provider,
@@ -103,6 +111,7 @@ async def _lifespan(app: FastAPI):
103111
session_store_dsn=session_store_dsn,
104112
session_store_db_path=session_store_db_path,
105113
context_window=context_window,
114+
yolo_mode=bool(yolo_mode),
106115
)
107116
if agent_cfg.get("mcp_config") is not None:
108117
create_kwargs["mcp_config"] = agent_cfg["mcp_config"]
@@ -179,6 +188,7 @@ async def _lifespan(app: FastAPI):
179188
logger.info(f" Provider : {agent_cfg.get('provider', '(not set)')}")
180189
logger.info(f" Model : {agent_cfg.get('model', '(not set)')}")
181190
logger.info(f" Workspace: {agent_cfg.get('workspace', '/data/workspace')}")
191+
logger.info(f" YOLO mode: {bool(yolo_mode)}")
182192
if channel_manager:
183193
logger.info(
184194
f" Channels : {channel_manager.running_channels_count} running "

spoon_bot/gateway/websocket/handler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,12 +433,13 @@ def __init__(self, connection_id: str, session_id: str | None = None):
433433
self._concurrent_tasks: set[asyncio.Task] = set()
434434
agent = get_agent()
435435
workspace = Path(getattr(agent, "workspace", Path.home() / ".spoon-bot" / "workspace"))
436+
yolo_mode = bool(getattr(agent, "yolo_mode", False))
436437
self._sandbox_id = _runtime_sandbox_id()
437438
self._workspace_watch_service = WorkspaceWatchService(
438439
workspace_root=workspace,
439440
emit_change=self._emit_workspace_change,
440441
)
441-
self._workspace_fs_service = WorkspaceFSService(workspace_root=workspace)
442+
self._workspace_fs_service = WorkspaceFSService(workspace_root=workspace, yolo_mode=yolo_mode)
442443
self._workspace_terminal_service = WorkspaceTerminalService(
443444
workspace_root=workspace,
444445
emit_stdout=self._emit_terminal_stdout,

0 commit comments

Comments
 (0)