diff --git a/src/langbot/pkg/box/service.py b/src/langbot/pkg/box/service.py index 336971b65..ea117b0cc 100644 --- a/src/langbot/pkg/box/service.py +++ b/src/langbot/pkg/box/service.py @@ -1302,11 +1302,19 @@ class BoxService: def get_recent_errors(self) -> list[dict]: return list(self._recent_errors) - def get_system_guidance(self) -> str: + def get_system_guidance(self, query_id=None) -> str: """Return LLM system-prompt guidance for the exec tool. All execution-specific prompt text is kept here so that callers (e.g. LocalAgentRunner) stay free of box domain knowledge. + + ``query_id`` is the current turn's pipeline query id. When provided, + the guidance ALWAYS advertises the per-query outbox path so the agent + knows how to deliver generated files back to the user — even on turns + where the user sent no inbound attachment (e.g. "generate a QR code"), + which is exactly when the inbound-attachment note never fires. Outbound + collection in the wrapper runs on every turn regardless of inbound + files, so without this the file would be produced and silently dropped. """ guidance = ( 'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, ' @@ -1321,6 +1329,13 @@ class BoxService: 'modify local files in the working directory, use exec with /workspace paths directly; do not ask the ' 'user for directory parameters unless they explicitly need a different directory.' ) + if query_id is not None: + outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_id}' + guidance += ( + f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, ' + f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be ' + 'delivered to the user automatically; do not paste file contents or base64 into your reply.' + ) return guidance async def get_status(self) -> dict: diff --git a/src/langbot/pkg/provider/runners/localagent.py b/src/langbot/pkg/provider/runners/localagent.py index 338de2e59..482d03493 100644 --- a/src/langbot/pkg/provider/runners/localagent.py +++ b/src/langbot/pkg/provider/runners/localagent.py @@ -177,7 +177,7 @@ class LocalAgentRunner(runner.RequestRunner): req_messages.append( provider_message.Message( role='system', - content=self.ap.box_service.get_system_guidance(), + content=self.ap.box_service.get_system_guidance(query.query_id), ) ) diff --git a/tests/unit_tests/box/test_box_service.py b/tests/unit_tests/box/test_box_service.py index 88f1927ee..acf53264f 100644 --- a/tests/unit_tests/box/test_box_service.py +++ b/tests/unit_tests/box/test_box_service.py @@ -546,6 +546,41 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path): ) +class TestGetSystemGuidance: + """``get_system_guidance`` must ALWAYS advertise the per-query outbox path + when given a ``query_id`` — even with no inbound attachment — so files the + agent generates (QR codes, charts, rendered docs) are actually delivered. + + The wrapper collects the outbox on every turn regardless of inbound files; + before this, the agent was only told the outbox path inside the + inbound-attachment note, so pure-generation turns produced files that were + silently dropped. + """ + + def _service(self, logger=None): + logger = logger or Mock() + runtime = BoxRuntime(logger=logger, backends=[FakeBackend(logger)], session_ttl_sec=300) + return BoxService(make_app(logger), client=_InProcessBoxRuntimeClient(logger, runtime)) + + def test_guidance_includes_outbox_when_query_id_given(self): + service = self._service() + guidance = service.get_system_guidance(42) + assert f'{service.OUTBOX_MOUNT_DIR}/42' in guidance + assert 'delivered to the user automatically' in guidance + + def test_guidance_omits_outbox_without_query_id(self): + service = self._service() + guidance = service.get_system_guidance() + assert service.OUTBOX_MOUNT_DIR not in guidance + # core exec guidance is still present + assert 'exec tool' in guidance + + def test_guidance_outbox_independent_of_inbound_attachments(self): + # A bare query_id (the pure-generation case) still gets the outbox note. + service = self._service() + assert f'{service.OUTBOX_MOUNT_DIR}/0' in service.get_system_guidance(0) + + @pytest.mark.asyncio async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path): logger = Mock()