diff --git a/docs/configuration/qa.md b/docs/configuration/qa.md index afc4ea05..aa4f4681 100644 --- a/docs/configuration/qa.md +++ b/docs/configuration/qa.md @@ -56,7 +56,7 @@ analysis: ``` - **model**: LLM configuration (see [Providers](providers.md#model-settings)). When unset, falls back to `qa.model`. -- **code_timeout**: Seconds a single `execute_code` call may spend reading documents (default: 60). The sandbox refuses further reads past this point. Code that computes without reading is bounded instead by the session budget of `code_timeout * max_executions`. +- **code_timeout**: Seconds a single `execute_code` call may spend reading documents (default: 60). The sandbox refuses further reads past this point. Code that computes without reading is killed by the worker watchdog at the same limit. `code_timeout * max_executions` is the cumulative ceiling across all calls in one question. - **max_output_chars**: Truncate code output after this many characters (default: 50000) - **max_executions**: Maximum `execute_code` calls per question before the capability is told to answer from what it has (default: 15) diff --git a/haiku_rag_slim/haiku/rag/capabilities/instructions/analysis.md b/haiku_rag_slim/haiku/rag/capabilities/instructions/analysis.md index 5dec8401..b16547e4 100644 --- a/haiku_rag_slim/haiku/rag/capabilities/instructions/analysis.md +++ b/haiku_rag_slim/haiku/rag/capabilities/instructions/analysis.md @@ -17,7 +17,7 @@ Inside the code, these functions are available (use `await`): - `await list_documents()` → list of dicts with keys: id, title, uri, created_at Available modules: `json`, `re`, `math`, `pathlib` -Not supported: class definitions, generators/yield, match statements, decorators, `collections`, iterating a file object (`for line in f`) +Not supported: class inheritance and metaclasses, generators/yield, match statements, decorators, `collections`, iterating a file object (`for line in f`) ### analysis_search Search the knowledge base directly (outside code execution). Each result has a `Type:` (paragraph, table, code, list_item, picture). When the Type is `picture`, the corresponding figure may also be attached to the tool response as an image alongside the text — use it directly to answer questions about figures, diagrams, charts, screenshots. diff --git a/haiku_rag_slim/haiku/rag/sandbox/sandbox.py b/haiku_rag_slim/haiku/rag/sandbox/sandbox.py index 9e1cbfac..f808ccf9 100644 --- a/haiku_rag_slim/haiku/rag/sandbox/sandbox.py +++ b/haiku_rag_slim/haiku/rag/sandbox/sandbox.py @@ -12,7 +12,6 @@ from pydantic_monty import ( AsyncMonty, AsyncMontySession, CallbackFile, - MemoryFile, OSAccess, ResourceLimits, ) @@ -28,10 +27,6 @@ if TYPE_CHECKING: from haiku.rag.client import HaikuRAG -_REQUEST_TIMEOUT_MARGIN_S = 30.0 -"""Grace above ``code_timeout`` before the pool watchdog kills the worker.""" - - @dataclass class SandboxResult: """Result of executing code in the sandbox.""" @@ -304,12 +299,12 @@ class Sandbox: """Build the virtual filesystem with document data. Mounts per-document directories with: - - metadata.json: MemoryFile (eager, small) + - metadata.json: CallbackFile (eager, small) - content.txt: CallbackFile (lazy, can be large) - items.jsonl: CallbackFile (lazy, bulk-cached) - toc.json: CallbackFile (lazy, bulk-cached) """ - files: list[MemoryFile | CallbackFile] = [] + files: list[CallbackFile] = [] def _deny_write(_path: "PurePosixPath", _content: str | bytes) -> None: raise PermissionError(f"Document files are read-only: {_path}") @@ -471,17 +466,6 @@ class Sandbox: return OSAccess(files) - def _request_timeout(self) -> float: - """Hard per-call deadline for the pool watchdog. - - The read deadline refuses the next read at ``code_timeout`` and keeps the - session, so it wins for code that reads. This watchdog is the fallback - for code that computes without reading: it kills the worker, which loses - the variables the session held. Leave room for one read that is already - in flight, so the graceful refusal wins the race. - """ - return self._config.analysis.code_timeout + _REQUEST_TIMEOUT_MARGIN_S - def _session_limits(self) -> ResourceLimits: """Resource limits for the worker session. @@ -500,7 +484,11 @@ class Sandbox: if self._vfs is None: self._vfs = await self._build_vfs() if self._pool is None: - pool = AsyncMonty(request_timeout=self._request_timeout()) + # The watchdog counts only time the worker spends running code, so a + # read that blocks the worker never trips it. That leaves the two + # limits disjoint: this one bounds a call that computes, and the read + # deadline bounds a call that reads. + pool = AsyncMonty(request_timeout=self._config.analysis.code_timeout) await pool.__aenter__() self._pool = pool if self._session is None: diff --git a/tests/sandbox/test_sandbox.py b/tests/sandbox/test_sandbox.py index 03d4f93f..21d148a6 100644 --- a/tests/sandbox/test_sandbox.py +++ b/tests/sandbox/test_sandbox.py @@ -826,16 +826,12 @@ class TestSandboxReadDeadline: assert sb._session_limits() == {"max_duration_secs": 15.0} @pytest.mark.asyncio - async def test_document_read_past_the_deadline_fails_the_execution( - self, temp_db_path - ): - """The overrun surfaces as a failed result, not a raised exception.""" + async def test_refused_read_fails_the_execution(self, temp_db_path, monkeypatch): + """The refusal surfaces as a failed result, not a raised exception.""" from docling_core.types.doc.document import DoclingDocument from docling_core.types.doc.labels import DocItemLabel config = AppConfig() - config.analysis.code_timeout = 0.0 - docling = DoclingDocument(name="d") docling.add_text(label=DocItemLabel.TEXT, text="Foxes and dogs.") @@ -852,6 +848,13 @@ class TestSandboxReadDeadline: uri="test://deadline", ) + def _past_deadline(*_args, **_kwargs): + raise TimeoutError( + "time limit exceeded: no further document reads after 60.0s" + ) + + monkeypatch.setattr(Sandbox, "_run_on_loop", _past_deadline) + sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) try: result = await sb.execute( @@ -906,25 +909,17 @@ class TestSandboxWorkerCrash: class TestSandboxRequestTimeout: """The pool watchdog bounds a call that never reads.""" - def test_request_timeout_sits_above_the_read_deadline(self, temp_db_path): - """A read refusal keeps the session, so it must win the race.""" - config = AppConfig() - config.analysis.code_timeout = 5.0 - sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) - - assert sb._request_timeout() > config.analysis.code_timeout - @pytest.mark.asyncio async def test_runaway_compute_is_killed_and_the_next_call_recovers( - self, temp_db_path, monkeypatch + self, temp_db_path ): """Code that never reads escapes the read deadline. The watchdog kills it.""" config = AppConfig() + config.analysis.code_timeout = 1.0 async with HaikuRAG(temp_db_path, create=True): pass sb = Sandbox(db_path=temp_db_path, config=config, context=AnalysisContext()) - monkeypatch.setattr(sb, "_request_timeout", lambda: 1.0) try: runaway = await sb.execute( "x = 0\nfor i in range(500000000):\n x += i\nprint(x)"