mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 17:35:19 +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]>
124 lines
4.7 KiB
Python
124 lines
4.7 KiB
Python
"""Bounded export diagnostics preserved by the SDK's text-only error relay."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
|
|
from langbot_plugin.api.entities.builtin.runner.errors import AgentAPIError
|
|
from langbot_plugin.box.errors import BoxValidationError
|
|
|
|
|
|
EXPORT_ERROR_PREFIX = 'LANGBOT_EXPORT_V1:'
|
|
SCRIPT_ERROR_PREFIX = 'LANGBOT_OUTBOX_LIMIT:'
|
|
SCRIPT_ERROR_EXIT_CODE = 73
|
|
LIMIT_LABELS = {
|
|
'file_bytes': 'per-file byte',
|
|
'collection_bytes': 'total-byte',
|
|
'file_count': 'file-count',
|
|
'depth': 'directory-depth',
|
|
'entries': 'directory-entry',
|
|
'directories': 'directory',
|
|
'run_files': 'per-run file-count',
|
|
'run_bytes': 'per-run byte',
|
|
}
|
|
|
|
|
|
class BoxExportError(BoxValidationError):
|
|
def __init__(self, error: AgentAPIError):
|
|
self.error = error
|
|
super().__init__(EXPORT_ERROR_PREFIX + error.model_dump_json())
|
|
|
|
|
|
def export_error(
|
|
kind: str,
|
|
*,
|
|
limit: int | None = None,
|
|
actual: int | None = None,
|
|
path: str = '',
|
|
lower_bound: bool = False,
|
|
remaining: int | None = None,
|
|
absolute_path: bool = False,
|
|
) -> BoxExportError:
|
|
if kind in LIMIT_LABELS:
|
|
code = 'box.export.limit_exceeded'
|
|
message = f'Attachment export failed: Sandbox outbox exceeds the {LIMIT_LABELS[kind]} limit'
|
|
action = 'Reduce or split the requested output within the indicated limit, then retry export.'
|
|
if kind in {'depth', 'entries', 'directories'}:
|
|
action = 'Flatten the outbox or remove unnecessary entries, preserving the requested deliverables.'
|
|
elif kind == 'transport_bytes':
|
|
code = 'box.export.transport_truncated'
|
|
message = 'Attachment export failed: the SDK explicitly reported truncated export output'
|
|
action = 'Reduce or split attachments so their base64/JSON response fits the transport limit.'
|
|
elif kind == 'invalid_response':
|
|
code = 'box.export.invalid_response'
|
|
message = 'Attachment export failed: Sandbox outbox returned invalid attachment data'
|
|
action = 'Inspect the sandbox/runtime response; do not assume a size limit caused this failure.'
|
|
else:
|
|
raise ValueError(f'Unknown export failure kind: {kind}')
|
|
return BoxExportError(
|
|
AgentAPIError(
|
|
code=code,
|
|
message=message,
|
|
retryable=kind != 'invalid_response',
|
|
details={
|
|
'version': 1,
|
|
'phase': 'export',
|
|
'limit_type': kind,
|
|
'limit': limit,
|
|
'actual': actual,
|
|
'actual_is_lower_bound': lower_bound,
|
|
'remaining': remaining,
|
|
'relative_paths': [path] if path and not absolute_path else [],
|
|
'sandbox_paths': [path] if path and absolute_path else [],
|
|
'files_retained': True,
|
|
'suggested_action': action,
|
|
},
|
|
)
|
|
)
|
|
|
|
|
|
def script_export_error(
|
|
stderr: str, exit_code: int, limits: dict[str, int], *, selected_paths: list[str] | None = None
|
|
) -> BoxExportError | None:
|
|
if exit_code != SCRIPT_ERROR_EXIT_CODE or len(stderr) > 8192:
|
|
return None
|
|
line = stderr.strip().splitlines()[-1] if stderr.strip() else ''
|
|
if not line.startswith(SCRIPT_ERROR_PREFIX):
|
|
return None
|
|
try:
|
|
value = json.loads(line.removeprefix(SCRIPT_ERROR_PREFIX))
|
|
kind, limit, actual, path = (value[k] for k in ('kind', 'limit', 'actual', 'path'))
|
|
if (
|
|
kind not in limits
|
|
or type(limit) is not int
|
|
or limit != limits[kind]
|
|
or type(actual) is not int
|
|
or actual <= limit
|
|
or not isinstance(path, str)
|
|
or len(path) > 2400
|
|
or (selected_paths is None and path.startswith('/'))
|
|
or (selected_paths is not None and path not in selected_paths)
|
|
or '\\' in path
|
|
or '\x00' in path
|
|
or '..' in path.split('/')
|
|
):
|
|
return None
|
|
return export_error(
|
|
kind, limit=limit, actual=actual, path=path, lower_bound=True, absolute_path=selected_paths is not None
|
|
)
|
|
except (ValueError, TypeError, KeyError):
|
|
return None
|
|
|
|
|
|
def transport_export_error(stdout: str) -> BoxExportError | None:
|
|
clipped = re.search(r'\n\.\.\. \[raw output clipped at (\d+) bytes, (\d+) bytes discarded\]$', stdout)
|
|
if clipped:
|
|
limit, discarded = map(int, clipped.groups())
|
|
if limit > 0 and discarded > 0:
|
|
return export_error('transport_bytes', limit=limit, actual=limit + discarded)
|
|
clipped = re.search(r'\n\.\.\. \[output clipped at (\d+) bytes\]$', stdout)
|
|
if clipped and int(clipped[1]) > 0:
|
|
return export_error('transport_bytes', limit=int(clipped[1]))
|
|
return None
|