mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
* fix(box): retain outbox files when export fails * test(box): retain ownership evidence in CI logs * test(box): exercise LocalAgent export repair through real runtime and Docker Co-authored-by: multica-agent <[email protected]> * fix(box): complete bounded outbox recovery contract * feat(box): export explicitly selected files Co-authored-by: multica-agent <[email protected]> * test(box): exercise explicit export on real Docker Co-authored-by: multica-agent <[email protected]> * test(box): verify explicit exports with live model and Docker Co-authored-by: multica-agent <[email protected]> * fix(box): align explicit export SDK and integration contracts * docs(box): describe selected-file export contract --------- Co-authored-by: HaiYangBG1 <[email protected]> Co-authored-by: Chin Rock <[email protected]> Co-authored-by: multica-agent <[email protected]>
91 lines
4.6 KiB
Python
91 lines
4.6 KiB
Python
"""Opt-in live model -> Docker -> Core export acceptance test.
|
|
|
|
Requires BOX_LIVE_API_KEY and BOX_LIVE_MODEL; never records credentials.
|
|
"""
|
|
import base64
|
|
import json
|
|
import logging
|
|
import os
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from langbot.pkg.box.runner import RunnerBoxService, binding_for, exported_message
|
|
from langbot.pkg.box.service import BoxService
|
|
from langbot_plugin.box.runtime import BoxRuntime
|
|
from tests.integration_tests.box.test_outbox_export import _TestDockerBackend, _Client
|
|
from tests.unit_tests.box.test_box_service import make_app, make_query, _CONTEXT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_live_model_explicit_export(tmp_path):
|
|
key = os.environ.get('BOX_LIVE_API_KEY')
|
|
if not key:
|
|
pytest.skip('BOX_LIVE_API_KEY is required')
|
|
logger = logging.getLogger('test.box.live')
|
|
backend = _TestDockerBackend(logger)
|
|
assert await backend.is_available(), 'Docker required'
|
|
app = make_app(logger, allowed_mount_roots=[str(tmp_path)], host_root=str(tmp_path / 'box'))
|
|
app.instance_config.data['box']['backend'] = 'docker'
|
|
app.instance_config.data['box']['local']['image'] = 'python:3.12-alpine'
|
|
runtime = BoxRuntime(logger=logger, backends=[backend], max_sessions=1)
|
|
box = BoxService(app, client=_Client(logger, runtime))
|
|
api = RunnerBoxService(box)
|
|
await box.initialize()
|
|
tools = [
|
|
{'type': 'function', 'function': {'name': 'exec', 'description': 'Run shell in sandbox',
|
|
'parameters': {'type': 'object', 'properties': {'command': {'type': 'string'}}, 'required': ['command']}}},
|
|
{'type': 'function', 'function': {'name': 'export_box_files',
|
|
'description': 'Export selected absolute sandbox file paths. Success accepts files and removes only selected files. Failure preserves files; fix the problem and retry.',
|
|
'parameters': {'type': 'object', 'properties': {'paths': {'type': 'array', 'items': {'type': 'string'}}}, 'required': ['paths']}}},
|
|
]
|
|
try:
|
|
acquired = await api.acquire(_CONTEXT, {'reuse_key': 'live-export'})
|
|
query = make_query(0)
|
|
object.__setattr__(query, '_box_binding', None)
|
|
await api.bind(_CONTEXT, query, 'live-explicit-export', acquired['id'])
|
|
outbox = f'/workspace/outbox/{binding_for(query).io_scope}'
|
|
target = outbox + '/answer.txt'
|
|
keep = outbox + '/keep.txt'
|
|
messages = [{'role': 'user', 'content': (
|
|
f'Use tools to create {target} containing exactly LIVE_EXPORT_OK (no newline) and {keep} containing KEEP. '
|
|
'First deliberately attempt to export /workspace/does-not-exist.txt to test error feedback. '
|
|
f'After that fails, explicitly export only {target}. Do not export or delete {keep}. '
|
|
'Then finish. Prefer outbox for deliverables; file creation alone does not deliver them.'
|
|
)}]
|
|
errors = 0
|
|
items = []
|
|
async with httpx.AsyncClient(timeout=120) as client:
|
|
for _ in range(12):
|
|
response = await client.post(
|
|
os.environ.get('BOX_LIVE_BASE_URL', 'https://newapi.rockchin.top/v1') + '/chat/completions',
|
|
headers={'Authorization': 'Bearer ' + key},
|
|
json={'model': os.environ['BOX_LIVE_MODEL'], 'messages': messages, 'tools': tools},
|
|
)
|
|
assert response.status_code == 200, f'Gateway HTTP {response.status_code}'
|
|
message = response.json()['choices'][0]['message']
|
|
messages.append(message)
|
|
calls = message.get('tool_calls', [])
|
|
if not calls:
|
|
break
|
|
for call in calls:
|
|
args = json.loads(call['function']['arguments'])
|
|
try:
|
|
if call['function']['name'] == 'exec':
|
|
result = await box.execute_tool(args, query)
|
|
else:
|
|
result = await api.export_files(query, args['paths'])
|
|
items.extend(result['items'])
|
|
except Exception as exc:
|
|
errors += 1
|
|
result = {'error': str(exc)}
|
|
messages.append({'role': 'tool', 'tool_call_id': call['id'], 'content': json.dumps(result)})
|
|
assert errors >= 1, 'Model did not exercise the requested failing export'
|
|
assert len(items) == 1
|
|
message = exported_message(query, [items[0]['id']])
|
|
assert base64.b64decode(message.root[0].base64) == b'LIVE_EXPORT_OK'
|
|
result = await box.execute_tool({'command': f'test ! -e {target} && cat {keep}'}, query)
|
|
assert result['ok'] and result['stdout'] == 'KEEP'
|
|
finally:
|
|
await box.shutdown()
|