from __future__ import annotations

import asyncio
import base64
import contextlib
import errno
import heapq
import json
import os
import posixpath
import stat
import time
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import PurePosixPath

import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot_plugin.api.entities.events import pipeline_query
import regex

from .. import loader
from ..errors import ToolNotFoundError
from .availability import is_box_backend_available
from . import skill as skill_loader
from ....api.http.context import ExecutionContext
from ....utils.bounded_executor import run_blocking_atomic

EXEC_TOOL_NAME = 'exec'
READ_TOOL_NAME = 'read'
WRITE_TOOL_NAME = 'write'
EDIT_TOOL_NAME = 'edit'
GLOB_TOOL_NAME = 'glob'
GREP_TOOL_NAME = 'grep'

_ALL_TOOL_NAMES = {EXEC_TOOL_NAME, READ_TOOL_NAME, WRITE_TOOL_NAME, EDIT_TOOL_NAME, GLOB_TOOL_NAME, GREP_TOOL_NAME}

# Skip these dirs during grep walk to avoid noise
_SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', 'dist', 'build'}

_DEFAULT_READ_MAX_LINES = 2000
_MAX_READ_MAX_LINES = 10000
_DEFAULT_TOOL_RESULT_MAX_BYTES = 50 * 1024
_BOX_FILE_SCRIPT_MAX_BYTES = 2048
_MAX_HOST_EDIT_FILE_BYTES = 1024 * 1024
_GLOB_MAX_MATCHES = 100
_FILE_WALK_MAX_ENTRIES = 100_000
_DIRECTORY_MAX_ENTRIES = 10_000
_GREP_MAX_MATCHES = 200
_GREP_MAX_FILES = 5000
_GREP_MAX_LINE_CHARS = 500
_GREP_MAX_SCAN_LINE_CHARS = 1024 * 1024
_GREP_MAX_FILE_SCAN_CHARS = 10 * 1024 * 1024
_GREP_MAX_TOTAL_SCAN_CHARS = 50 * 1024 * 1024
_GREP_MAX_PATTERN_CHARS = 1024
_GREP_REGEX_TIMEOUT_SECONDS = 0.25

_DIRECTORY_OPEN_FLAGS = (
    os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
)
_FILE_OPEN_FLAGS = getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NONBLOCK', 0)
_SECURE_HOST_FILE_OPS_AVAILABLE = bool(
    getattr(os, 'O_NOFOLLOW', 0)
    and os.open in os.supports_dir_fd
    and os.stat in os.supports_dir_fd
    and os.mkdir in os.supports_dir_fd
    and os.listdir in os.supports_fd
    and os.scandir in os.supports_fd
)


@dataclass(frozen=True)
class _HostLocation:
    root: str
    relative_parts: tuple[str, ...]
    selected_skill: dict | None
    workspace_anchor: str | None = None


def _unsafe_host_path(path: str, exc: BaseException | None = None) -> ValueError:
    error = ValueError(f'Path escapes the workspace boundary or contains a symbolic link: {path}')
    if exc is not None:
        error.__cause__ = exc
    return error


def _relative_workspace_parts(path: str) -> tuple[str, ...]:
    normalized = posixpath.normpath(str(path or '/workspace').strip() or '/workspace')
    if normalized == '/workspace':
        return ()
    if not normalized.startswith('/workspace/'):
        raise ValueError('Path escapes the workspace boundary.')

    parts = tuple(part for part in normalized.removeprefix('/workspace/').split('/') if part)
    if any(part in {'.', '..'} or '\x00' in part for part in parts):
        raise ValueError('Path escapes the workspace boundary.')
    return parts


def _is_symlink_at(parent_fd: int, name: str) -> bool:
    try:
        return stat.S_ISLNK(os.stat(name, dir_fd=parent_fd, follow_symlinks=False).st_mode)
    except FileNotFoundError:
        return False


def _open_directory_at(parent_fd: int, name: str, *, create: bool) -> int:
    if create:
        try:
            os.mkdir(name, mode=0o777, dir_fd=parent_fd)
        except FileExistsError:
            pass

    try:
        directory_fd = os.open(name, _DIRECTORY_OPEN_FLAGS, dir_fd=parent_fd)
    except OSError as exc:
        if exc.errno == errno.ELOOP or _is_symlink_at(parent_fd, name):
            raise _unsafe_host_path(name, exc)
        raise
    if not stat.S_ISDIR(os.fstat(directory_fd).st_mode):
        os.close(directory_fd)
        raise NotADirectoryError(name)
    return directory_fd


def _open_directory_parts(root_fd: int, parts: tuple[str, ...], *, create: bool) -> int:
    current_fd = os.dup(root_fd)
    try:
        for part in parts:
            next_fd = _open_directory_at(current_fd, part, create=create)
            os.close(current_fd)
            current_fd = next_fd
        return current_fd
    except BaseException:
        os.close(current_fd)
        raise


@contextlib.contextmanager
def _open_host_root(location: _HostLocation, *, create: bool) -> Iterator[int]:
    """Open and pin the tenant root before resolving tenant-controlled names."""

    if location.workspace_anchor is None:
        root_path = os.path.realpath(location.root)
        try:
            root_fd = os.open(root_path, _DIRECTORY_OPEN_FLAGS)
        except OSError as exc:
            if exc.errno == errno.ELOOP:
                raise _unsafe_host_path(location.root, exc)
            raise
    else:
        anchor_path = os.path.abspath(location.workspace_anchor)
        root_path = os.path.abspath(location.root)
        try:
            if os.path.commonpath((anchor_path, root_path)) != anchor_path:
                raise _unsafe_host_path(location.root)
        except ValueError as exc:
            raise _unsafe_host_path(location.root, exc)

        anchor_real_path = os.path.realpath(anchor_path)
        try:
            anchor_fd = os.open(anchor_real_path, _DIRECTORY_OPEN_FLAGS)
        except OSError as exc:
            if exc.errno == errno.ELOOP:
                raise _unsafe_host_path(location.workspace_anchor, exc)
            raise
        try:
            root_relative = os.path.relpath(root_path, anchor_path)
            root_parts = () if root_relative == '.' else tuple(root_relative.split(os.sep))
            root_fd = _open_directory_parts(anchor_fd, root_parts, create=create)
        finally:
            os.close(anchor_fd)

    try:
        if not stat.S_ISDIR(os.fstat(root_fd).st_mode):
            raise _unsafe_host_path(location.root)
        yield root_fd
    finally:
        os.close(root_fd)


@contextlib.contextmanager
def _open_location_fd(
    root_fd: int,
    relative_parts: tuple[str, ...],
    flags: int,
    *,
    create_parents: bool = False,
    mode: int = 0o666,
) -> Iterator[int]:
    if not relative_parts:
        target_fd = os.dup(root_fd)
    else:
        parent_fd = _open_directory_parts(root_fd, relative_parts[:-1], create=create_parents)
        try:
            try:
                target_fd = os.open(relative_parts[-1], flags | _FILE_OPEN_FLAGS, mode, dir_fd=parent_fd)
            except OSError as exc:
                if exc.errno == errno.ELOOP or _is_symlink_at(parent_fd, relative_parts[-1]):
                    raise _unsafe_host_path(relative_parts[-1], exc)
                raise
        finally:
            os.close(parent_fd)

    try:
        yield target_fd
    finally:
        os.close(target_fd)


class NativeToolLoader(loader.ToolLoader):
    def __init__(self, ap):
        super().__init__(ap)
        self._tools: list[resource_tool.LLMTool] | None = None
        self._backend_available: bool | None = None

    async def initialize(self):
        """Check if backend is truly available at startup."""
        self._backend_available = await self._check_backend_available()
        if self._backend_available:
            self.ap.logger.info('Native sandbox tools (exec/read/write/edit/glob/grep) are available.')
        else:
            self.ap.logger.warning(
                'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
                'No sandbox backend (Docker/nsjail/E2B) is ready. '
                'Trusted local development may explicitly select box.backend=host. '
                'The LLM will not have access to code execution or file operation tools.'
            )

    async def _check_backend_available(self) -> bool:
        """Check if the box backend is truly available (not just the runtime)."""
        return await is_box_backend_available(self.ap)

    @staticmethod
    def _execution_context(query: pipeline_query.Query) -> ExecutionContext:
        attached_context = getattr(query, '_execution_context', None)
        if isinstance(attached_context, ExecutionContext):
            return attached_context
        return ExecutionContext(
            instance_uuid=str(getattr(query, 'instance_uuid', '') or ''),
            workspace_uuid=str(getattr(query, 'workspace_uuid', '') or ''),
            placement_generation=getattr(query, 'placement_generation', 0) or 0,
            bot_uuid=getattr(query, 'bot_uuid', None),
            pipeline_uuid=getattr(query, 'pipeline_uuid', None),
            query_uuid=getattr(query, 'query_uuid', None),
            entitlement_revision=getattr(query, 'entitlement_revision', 0),
        )

    async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
        if not await self._is_sandbox_available():
            return []
        if self._tools is None:
            self._tools = [
                self._build_exec_tool(),
                self._build_read_tool(),
                self._build_write_tool(),
                self._build_edit_tool(),
                self._build_glob_tool(),
                self._build_grep_tool(),
            ]
        return list(self._tools)

    async def has_tool(self, name: str) -> bool:
        return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()

    async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
        require_sandbox = getattr(
            getattr(self.ap, 'box_service', None),
            'require_workspace_sandbox',
            None,
        )
        if callable(require_sandbox):
            await require_sandbox(self._execution_context(query))
        if name == EXEC_TOOL_NAME:
            self.ap.logger.info(
                'exec tool invoked: '
                f'query_id={query.query_id} '
                f'parameters={json.dumps(self._summarize_parameters(parameters), ensure_ascii=False)}'
            )
            return await self._invoke_exec(parameters, query)
        if name == READ_TOOL_NAME:
            return await self._invoke_read(parameters, query)
        if name == WRITE_TOOL_NAME:
            return await self._invoke_write(parameters, query)
        if name == EDIT_TOOL_NAME:
            return await self._invoke_edit(parameters, query)
        if name == GLOB_TOOL_NAME:
            return await self._invoke_glob(parameters, query)
        if name == GREP_TOOL_NAME:
            return await self._invoke_grep(parameters, query)
        raise ToolNotFoundError(name)

    async def shutdown(self):
        pass

    async def _invoke_exec(self, parameters: dict, query: pipeline_query.Query) -> dict:
        command = str(parameters['command'])
        workdir = str(parameters.get('workdir', '/workspace') or '/workspace')
        selected_skill_name: str | None = None

        # Validate that skill references target activated skills.
        selected_skill, _ = skill_loader.resolve_virtual_skill_path(
            self.ap,
            query,
            workdir,
            include_visible=False,
            include_activated=True,
        )
        referenced_skill_names = skill_loader.find_referenced_skill_names(command)

        if selected_skill is None and referenced_skill_names:
            if len(referenced_skill_names) > 1:
                raise ValueError('exec can target at most one activated skill package per call.')
            selected_skill = skill_loader.get_activated_skill(query, referenced_skill_names[0])
            if selected_skill is None:
                raise ValueError(
                    f'Skill "{referenced_skill_names[0]}" must be activated before exec can run in its package.'
                )

        if selected_skill is not None:
            selected_skill_name = str(selected_skill.get('name', '') or '')
            if referenced_skill_names and any(name != selected_skill_name for name in referenced_skill_names):
                raise ValueError('exec can reference files from only one activated skill package per call.')

            package_root = str(selected_skill.get('package_root', '') or '').strip()
            if not package_root:
                raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.')

            # Pass only the logical name across the authenticated Core→Runtime
            # boundary. In Cloud mode the shared Box Runtime resolves the
            # Workspace-scoped package root and constructs the read-only mount;
            # Core host paths are never accepted as mount authority.
            # Wrap command with Python venv bootstrap if the skill has a Python project.
            # The venv is created inside the skill's mount path.
            skill_mount = f'/workspace/.skills/{selected_skill_name}'
            python_project = selected_skill.get('python_project') is True
            if 'python_project' not in selected_skill and bool(
                getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
            ):
                # Backward compatibility for a same-process OSS Runtime that
                # predates trusted Box metadata. Never probe a path reported by
                # an external Runtime from the Core filesystem.
                python_project = skill_loader.should_prepare_skill_python_env(package_root)
            if python_project:
                parameters = dict(parameters)
                parameters['command'] = skill_loader.wrap_skill_command_with_python_env(
                    command,
                    mount_path=skill_mount,
                    state_path=f'/workspace/.skill-envs/{selected_skill_name}',
                )

        # All exec calls (with or without skills) go through the same container
        # via execute_tool. Skills are mounted at /workspace/.skills/{name}/
        # via extra_mounts built by BoxService.
        result = await self.ap.box_service.execute_tool(
            parameters,
            query,
            skill_name=selected_skill_name,
        )
        result = self._normalize_exec_result(result)

        if selected_skill is not None:
            self._refresh_skill_from_disk(query, selected_skill)
        return result

    def _resolve_host_location(
        self,
        query: pipeline_query.Query,
        sandbox_path: str,
        *,
        include_visible: bool,
        include_activated: bool,
    ) -> _HostLocation:
        selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path(
            self.ap,
            query,
            sandbox_path,
            include_visible=include_visible,
            include_activated=include_activated,
        )

        box_service = self.ap.box_service
        if selected_skill is not None:
            if not self._can_interpret_skill_host_paths():
                raise ValueError(
                    'Skill package paths are owned by the Box Runtime; '
                    'this operation requires a Runtime skill-file API.'
                )
            host_root = selected_skill.get('package_root')
            workspace_anchor = None
        else:
            host_root = box_service._tenant_workspace(self._execution_context(query))
            workspace_anchor = getattr(box_service, 'default_workspace', None)
        if not host_root:
            raise ValueError('No host workspace configured for file operations.')

        return _HostLocation(
            root=str(host_root),
            relative_parts=_relative_workspace_parts(rewritten_path),
            selected_skill=selected_skill,
            workspace_anchor=str(workspace_anchor) if workspace_anchor else None,
        )

    def _resolve_skill_relative_path(
        self,
        query: pipeline_query.Query,
        sandbox_path: str,
        *,
        include_visible: bool,
        include_activated: bool,
    ) -> tuple[dict, str] | None:
        selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path(
            self.ap,
            query,
            sandbox_path,
            include_visible=include_visible,
            include_activated=include_activated,
        )
        if selected_skill is None:
            return None

        relative = '/'.join(_relative_workspace_parts(rewritten_path)) or '.'
        return selected_skill, relative

    def _can_interpret_skill_host_paths(self) -> bool:
        """Require an explicitly proven shared Core/Runtime filesystem view."""

        return _SECURE_HOST_FILE_OPS_AVAILABLE and bool(
            getattr(self.ap.box_service, 'shares_filesystem_with_box', False)
        )

    def _should_use_box_workspace_files(self, selected_skill: dict | None) -> bool:
        if selected_skill is not None:
            return False
        box_service = getattr(self.ap, 'box_service', None)
        if box_service is None or not hasattr(box_service, 'execute_tool'):
            return False
        if not _SECURE_HOST_FILE_OPS_AVAILABLE:
            # Preserve the OSS API on platforms without openat/O_NOFOLLOW by
            # running inside the tenant-scoped Box mount, never via a racy
            # host-path fallback.
            return True
        default_workspace = getattr(box_service, 'default_workspace', None)
        return bool(default_workspace and not os.path.isdir(os.path.realpath(default_workspace)))

    def _read_host_location(self, location: _HostLocation, parameters: dict) -> dict:
        with _open_host_root(location, create=False) as root_fd:
            with _open_location_fd(root_fd, location.relative_parts, os.O_RDONLY) as target_fd:
                metadata = os.fstat(target_fd)
                if stat.S_ISDIR(metadata.st_mode):
                    entries: list[str] = []
                    truncated = False
                    with os.scandir(target_fd) as iterator:
                        for entry in iterator:
                            if len(entries) >= _DIRECTORY_MAX_ENTRIES:
                                truncated = True
                                break
                            entries.append(entry.name)
                    return self._build_directory_result(
                        entries,
                        total=len(entries) + int(truncated),
                        force_truncated_by='entries' if truncated else None,
                    )
                if not stat.S_ISREG(metadata.st_mode):
                    raise ValueError('Path must reference a regular file or directory.')
                return self._read_text_file_preview(target_fd, parameters, metadata=metadata)

    def _write_host_location(self, location: _HostLocation, content: str, parameters: dict) -> None:
        if not location.relative_parts:
            raise ValueError('Path must reference a file under /workspace.')

        encoding, mode = self._write_options(parameters)
        if encoding == 'base64':
            try:
                payload = base64.b64decode(content, validate=True)
            except Exception as exc:
                raise ValueError(f'invalid base64 content: {exc}') from exc
        else:
            payload = content.encode('utf-8')

        flags = os.O_WRONLY | os.O_CREAT
        if mode == 'append':
            flags |= os.O_APPEND
        with _open_host_root(location, create=True) as root_fd:
            with _open_location_fd(
                root_fd,
                location.relative_parts,
                flags,
                create_parents=True,
            ) as target_fd:
                if not stat.S_ISREG(os.fstat(target_fd).st_mode):
                    raise ValueError('Path must reference a regular file.')
                if mode != 'append':
                    os.ftruncate(target_fd, 0)
                    os.lseek(target_fd, 0, os.SEEK_SET)
                self._write_all(target_fd, payload)

    def _edit_host_location(
        self,
        location: _HostLocation,
        old_string: str,
        new_string: str,
    ) -> tuple[bool, str | None]:
        if not location.relative_parts:
            raise ValueError('Path must reference a file under /workspace.')

        with _open_host_root(location, create=False) as root_fd:
            with _open_location_fd(root_fd, location.relative_parts, os.O_RDWR) as target_fd:
                metadata = os.fstat(target_fd)
                if not stat.S_ISREG(metadata.st_mode):
                    return False, 'File not found.'
                if metadata.st_size > _MAX_HOST_EDIT_FILE_BYTES:
                    return False, f'File exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte edit limit.'
                with os.fdopen(os.dup(target_fd), 'rb') as file_obj:
                    raw_content = file_obj.read(_MAX_HOST_EDIT_FILE_BYTES + 1)
                if len(raw_content) > _MAX_HOST_EDIT_FILE_BYTES:
                    return False, f'File exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte edit limit.'
                content = raw_content.decode('utf-8', errors='replace')
                count = content.count(old_string)
                if count == 0:
                    return False, 'old_string not found in file.'
                if count > 1:
                    return False, f'old_string matches {count} locations; provide a more unique string.'

                payload = content.replace(old_string, new_string, 1).encode('utf-8')
                if len(payload) > _MAX_HOST_EDIT_FILE_BYTES:
                    return False, f'Edited file exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte limit.'
                os.ftruncate(target_fd, 0)
                os.lseek(target_fd, 0, os.SEEK_SET)
                self._write_all(target_fd, payload)
                return True, None

    @staticmethod
    def _write_all(file_fd: int, payload: bytes) -> None:
        view = memoryview(payload)
        while view:
            written = os.write(file_fd, view)
            if written <= 0:
                raise OSError('Could not write the complete workspace file.')
            view = view[written:]

    @staticmethod
    def _rglob_matches(relative_path: str, pattern: str) -> bool:
        candidates = {pattern}
        pending = [pattern]
        while pending:
            candidate = pending.pop()
            marker = candidate.find('**/')
            while marker >= 0:
                without_recursive_segment = candidate[:marker] + candidate[marker + 3 :]
                if without_recursive_segment not in candidates:
                    candidates.add(without_recursive_segment)
                    pending.append(without_recursive_segment)
                marker = candidate.find('**/', marker + 3)
        return any(candidate and PurePosixPath(relative_path).match(candidate) for candidate in candidates)

    def _glob_host_location(self, location: _HostLocation, pattern: str, sandbox_base: str) -> dict:
        newest_hits: list[tuple[float, str]] = []
        total = 0
        entries_seen = 0
        scan_truncated = False

        def walk(directory_fd: int, prefix: str) -> bool:
            nonlocal entries_seen, scan_truncated, total
            with os.scandir(directory_fd) as entries:
                for entry in entries:
                    entries_seen += 1
                    if entries_seen > _FILE_WALK_MAX_ENTRIES:
                        scan_truncated = True
                        return True
                    name = entry.name
                    if name in _SKIP_DIRS:
                        continue
                    try:
                        child_fd = os.open(name, os.O_RDONLY | _FILE_OPEN_FLAGS, dir_fd=directory_fd)
                    except OSError:
                        continue
                    try:
                        metadata = os.fstat(child_fd)
                        relative = f'{prefix}/{name}' if prefix else name
                        if self._rglob_matches(relative, pattern):
                            total += 1
                            candidate = (metadata.st_mtime, relative)
                            if len(newest_hits) < _GLOB_MAX_MATCHES:
                                heapq.heappush(newest_hits, candidate)
                            elif candidate > newest_hits[0]:
                                heapq.heapreplace(newest_hits, candidate)
                        if stat.S_ISDIR(metadata.st_mode) and walk(child_fd, relative):
                            return True
                    finally:
                        os.close(child_fd)
            return False

        with _open_host_root(location, create=False) as root_fd:
            with _open_location_fd(root_fd, location.relative_parts, os.O_RDONLY) as target_fd:
                if not stat.S_ISDIR(os.fstat(target_fd).st_mode):
                    return {'ok': False, 'error': f'Path is not a directory: {sandbox_base}'}
                walk(target_fd, '')

        hits = sorted(newest_hits, reverse=True)
        sandbox_paths: list[str] = []
        output_bytes = 0
        truncated_by_bytes = False
        for _mtime, relative in hits:
            sandbox_path = self._sandbox_child_path(sandbox_base, relative)
            entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if sandbox_paths else 0)
            if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
                truncated_by_bytes = True
                break
            sandbox_paths.append(sandbox_path)
            output_bytes += entry_bytes

        return {
            'ok': True,
            'matches': sandbox_paths,
            'preview': '\n'.join(sandbox_paths),
            'total': total,
            'truncated': scan_truncated or total > len(sandbox_paths) or truncated_by_bytes,
            'truncated_by': (
                'scan'
                if scan_truncated
                else ('bytes' if truncated_by_bytes else ('matches' if total > len(sandbox_paths) else None))
            ),
        }

    def _grep_host_location(
        self,
        location: _HostLocation,
        pattern: str,
        include: str | None,
        sandbox_base: str,
    ) -> dict:
        try:
            compiled = regex.compile(pattern)
        except regex.error as exc:
            return {'ok': False, 'error': f'Invalid regex: {exc}'}

        matches: list[dict] = []
        output_bytes = 0
        truncated_by: str | None = None
        files_seen = 0
        entries_seen = 0
        total_chars_seen = 0
        deadline = time.monotonic() + _GREP_REGEX_TIMEOUT_SECONDS

        def grep_file(file_fd: int, sandbox_path: str) -> bool:
            nonlocal output_bytes, total_chars_seen, truncated_by
            file_chars_seen = 0
            with os.fdopen(os.dup(file_fd), 'r', encoding='utf-8', errors='ignore') as handle:
                lineno = 0
                while True:
                    line = handle.readline(_GREP_MAX_SCAN_LINE_CHARS + 1)
                    if not line:
                        break
                    lineno += 1
                    line_chars = len(line)
                    file_chars_seen += line_chars
                    total_chars_seen += line_chars
                    if file_chars_seen > _GREP_MAX_FILE_SCAN_CHARS or total_chars_seen > _GREP_MAX_TOTAL_SCAN_CHARS:
                        truncated_by = 'scan'
                        return True
                    if line_chars > _GREP_MAX_SCAN_LINE_CHARS and not line.endswith('\n'):
                        truncated_by = truncated_by or 'line'
                        return False
                    remaining = deadline - time.monotonic()
                    if remaining <= 0:
                        raise TimeoutError
                    if not compiled.search(line, timeout=remaining, concurrent=True):
                        continue
                    content, line_truncated = self._truncate_grep_line(line.rstrip())
                    entry = {'file': sandbox_path, 'line': lineno, 'content': content}
                    entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
                    if output_bytes + entry_bytes > _DEFAULT_TOOL_RESULT_MAX_BYTES:
                        truncated_by = 'bytes'
                        return True
                    if line_truncated and truncated_by is None:
                        truncated_by = 'line'
                    matches.append(entry)
                    output_bytes += entry_bytes
                    if len(matches) >= _GREP_MAX_MATCHES:
                        truncated_by = truncated_by or 'matches'
                        return True
            return False

        def walk(directory_fd: int, prefix: str) -> bool:
            nonlocal entries_seen, files_seen, truncated_by
            with os.scandir(directory_fd) as entries:
                for entry in entries:
                    entries_seen += 1
                    if entries_seen > _FILE_WALK_MAX_ENTRIES:
                        truncated_by = 'scan'
                        return True
                    name = entry.name
                    if name in _SKIP_DIRS:
                        continue
                    try:
                        child_fd = os.open(name, os.O_RDONLY | _FILE_OPEN_FLAGS, dir_fd=directory_fd)
                    except OSError:
                        continue
                    try:
                        metadata = os.fstat(child_fd)
                        relative = f'{prefix}/{name}' if prefix else name
                        if stat.S_ISDIR(metadata.st_mode):
                            if walk(child_fd, relative):
                                return True
                            continue
                        if not stat.S_ISREG(metadata.st_mode):
                            continue
                        if include and not self._rglob_matches(relative, include):
                            continue
                        files_seen += 1
                        if grep_file(child_fd, self._sandbox_child_path(sandbox_base, relative)):
                            return True
                        if files_seen >= _GREP_MAX_FILES:
                            return True
                    finally:
                        os.close(child_fd)
            return False

        with _open_host_root(location, create=False) as root_fd:
            with _open_location_fd(root_fd, location.relative_parts, os.O_RDONLY) as target_fd:
                try:
                    metadata = os.fstat(target_fd)
                    if stat.S_ISREG(metadata.st_mode):
                        grep_file(target_fd, sandbox_base)
                    elif stat.S_ISDIR(metadata.st_mode):
                        walk(target_fd, '')
                    else:
                        return {'ok': False, 'error': f'Path not found: {sandbox_base}'}
                except TimeoutError:
                    return {'ok': False, 'error': 'Regex search timed out'}

        return {
            'ok': True,
            'matches': matches,
            'total': len(matches),
            'truncated': truncated_by is not None,
            'truncated_by': truncated_by,
        }

    @staticmethod
    def _sandbox_child_path(base: str, relative: str) -> str:
        return f'{str(base).rstrip("/")}/{relative}'

    async def _run_workspace_file_script(self, script: str, query: pipeline_query.Query) -> dict:
        result = await self.ap.box_service.execute_tool(
            {
                'command': f"python - <<'PY'\n{script}\nPY",
                'timeout_sec': 30,
            },
            query,
        )
        if not result.get('ok'):
            return {'ok': False, 'error': result.get('stderr') or result.get('stdout') or 'Box execution failed'}
        stdout = str(result.get('stdout') or '').strip()
        try:
            return json.loads(stdout.splitlines()[-1])
        except Exception:
            return {'ok': False, 'error': stdout or 'Box file operation returned no result'}

    async def _read_workspace_via_box(self, path: str, parameters: dict, query: pipeline_query.Query) -> dict:
        offset = self._positive_int(parameters.get('offset'), default=1)
        byte_offset = self._non_negative_int(parameters.get('byte_offset'), default=0)
        max_lines = self._positive_int(
            parameters.get('limit'),
            default=_DEFAULT_READ_MAX_LINES,
            max_value=_MAX_READ_MAX_LINES,
        )
        # Box file fallback returns through exec stdout, which is already capped
        # by BoxService. Keep this payload small enough to remain valid JSON.
        max_bytes = min(
            self._positive_int(parameters.get('max_bytes'), default=_DEFAULT_TOOL_RESULT_MAX_BYTES),
            _BOX_FILE_SCRIPT_MAX_BYTES,
        )
        encoding = self._read_encoding(parameters)
        script = f"""
import base64, json, os
path = {json.dumps(path)}
offset = {offset}
byte_offset = {byte_offset}
max_lines = {max_lines}
max_bytes = {max_bytes}
encoding = {json.dumps(encoding)}
if not path.startswith('/workspace'):
    print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}}))
elif not os.path.exists(path):
    print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}}))
elif os.path.isdir(path):
    entries = []
    directory_truncated = False
    with os.scandir(path) as iterator:
        for entry in iterator:
            if len(entries) >= {_DIRECTORY_MAX_ENTRIES}:
                directory_truncated = True
                break
            entries.append(entry.name)
    entries.sort()
    content = '\\n'.join(entries)
    print(json.dumps({{
        'ok': True,
        'content': content,
        'is_directory': True,
        'total': len(entries) + int(directory_truncated),
        'truncated': directory_truncated,
        'truncated_by': 'entries' if directory_truncated else None,
    }}))
elif encoding == 'base64':
    size_bytes = os.path.getsize(path)
    with open(path, 'rb') as f:
        f.seek(byte_offset)
        data = f.read(max_bytes + 1)
    chunk = data[:max_bytes]
    has_more = len(data) > max_bytes
    print(json.dumps({{
        'ok': True,
        'content': base64.b64encode(chunk).decode('ascii'),
        'encoding': 'base64',
        'byte_offset': byte_offset,
        'length': len(chunk),
        'size_bytes': size_bytes,
        'has_more': has_more,
        'next_byte_offset': byte_offset + len(chunk) if has_more else None,
        'max_bytes': max_bytes,
    }}))
else:
    lines = []
    output_bytes = 0
    end_line = offset - 1
    truncated = False
    next_offset = None
    with open(path, 'r', encoding='utf-8', errors='replace') as f:
        for line_number, line in enumerate(f, 1):
            if line_number < offset:
                continue
            if len(lines) >= max_lines:
                truncated = True
                next_offset = line_number
                break
            line_bytes = len(line.encode('utf-8'))
            if output_bytes + line_bytes > max_bytes:
                truncated = True
                next_offset = line_number
                break
            lines.append(line.rstrip('\\n'))
            output_bytes += line_bytes
            end_line = line_number
    print(json.dumps({{
        'ok': True,
        'content': '\\n'.join(lines),
        'truncated': truncated,
        'start_line': offset,
        'end_line': end_line,
        'next_offset': next_offset,
        'max_lines': max_lines,
        'max_bytes': max_bytes,
    }}))
""".strip()
        return await self._run_workspace_file_script(script, query)

    async def _write_workspace_via_box(
        self,
        path: str,
        content: str,
        parameters: dict,
        query: pipeline_query.Query,
    ) -> dict:
        encoding, mode = self._write_options(parameters)
        script = f"""
import base64, json, os
path = {json.dumps(path)}
content = {json.dumps(content)}
encoding = {json.dumps(encoding)}
mode = {json.dumps(mode)}
if not path.startswith('/workspace'):
    print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}}))
else:
    os.makedirs(os.path.dirname(path) or '/workspace', exist_ok=True)
    if encoding == 'base64':
        try:
            data = base64.b64decode(content, validate=True)
        except Exception as exc:
            print(json.dumps({{'ok': False, 'error': f'invalid base64 content: {{exc}}'}}))
        else:
            with open(path, 'ab' if mode == 'append' else 'wb') as f:
                f.write(data)
            print(json.dumps({{'ok': True, 'path': path}}))
    else:
        with open(path, 'a' if mode == 'append' else 'w', encoding='utf-8') as f:
            f.write(content)
        print(json.dumps({{'ok': True, 'path': path}}))
""".strip()
        return await self._run_workspace_file_script(script, query)

    async def _edit_workspace_via_box(
        self,
        path: str,
        old_string: str,
        new_string: str,
        query: pipeline_query.Query,
    ) -> dict:
        script = f"""
import json, os
path = {json.dumps(path)}
old_string = {json.dumps(old_string)}
new_string = {json.dumps(new_string)}
if not path.startswith('/workspace'):
    print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}}))
elif not os.path.isfile(path):
    print(json.dumps({{'ok': False, 'error': f'File not found: {{path}}'}}))
elif os.path.getsize(path) > {_MAX_HOST_EDIT_FILE_BYTES}:
    print(json.dumps({{'ok': False, 'error': 'File exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte edit limit.'}}))
else:
    with open(path, 'rb') as f:
        raw_content = f.read({_MAX_HOST_EDIT_FILE_BYTES + 1})
    if len(raw_content) > {_MAX_HOST_EDIT_FILE_BYTES}:
        print(json.dumps({{'ok': False, 'error': 'File exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte edit limit.'}}))
        raise SystemExit(0)
    content = raw_content.decode('utf-8', errors='replace')
    count = content.count(old_string)
    if count == 0:
        print(json.dumps({{'ok': False, 'error': 'old_string not found in file.'}}))
    elif count > 1:
        print(json.dumps({{'ok': False, 'error': f'old_string matches {{count}} locations; provide a more unique string.'}}))
    else:
        new_content = content.replace(old_string, new_string, 1)
        if len(new_content.encode('utf-8')) > {_MAX_HOST_EDIT_FILE_BYTES}:
            print(json.dumps({{'ok': False, 'error': 'Edited file exceeds the {_MAX_HOST_EDIT_FILE_BYTES}-byte limit.'}}))
            raise SystemExit(0)
        with open(path, 'w', encoding='utf-8') as f:
            f.write(new_content)
        print(json.dumps({{'ok': True, 'path': path}}))
""".strip()
        return await self._run_workspace_file_script(script, query)

    async def _glob_workspace_via_box(self, path: str, pattern: str, query: pipeline_query.Query) -> dict:
        script = f"""
import heapq, json, os
from pathlib import Path
path = {json.dumps(path)}
pattern = {json.dumps(pattern)}
skip_dirs = {json.dumps(sorted(_SKIP_DIRS))}
if not path.startswith('/workspace'):
    print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}}))
elif not os.path.isdir(path):
    print(json.dumps({{'ok': False, 'error': f'Path is not a directory: {{path}}'}}))
else:
    base = Path(path)
    newest_hits = []
    total = 0
    entries_seen = 0
    scan_truncated = False
    for item in base.rglob(pattern):
        entries_seen += 1
        if entries_seen > {_FILE_WALK_MAX_ENTRIES}:
            scan_truncated = True
            break
        if any(part in skip_dirs for part in item.parts):
            continue
        total += 1
        try:
            mtime = item.stat().st_mtime
        except OSError:
            mtime = 0
        candidate = (mtime, str(item))
        if len(newest_hits) < {_GLOB_MAX_MATCHES}:
            heapq.heappush(newest_hits, candidate)
        elif candidate > newest_hits[0]:
            heapq.heapreplace(newest_hits, candidate)
    shown = [Path(item_path) for _mtime, item_path in sorted(newest_hits, reverse=True)]
    matches = []
    output_bytes = 0
    truncated_by_bytes = False
    for item in shown:
        rel = os.path.relpath(str(item), path)
        sandbox_path = os.path.join(path, rel).replace(os.sep, '/')
        entry_bytes = len(sandbox_path.encode('utf-8')) + (1 if matches else 0)
        if output_bytes + entry_bytes > {_DEFAULT_TOOL_RESULT_MAX_BYTES}:
            truncated_by_bytes = True
            break
        matches.append(sandbox_path)
        output_bytes += entry_bytes
    print(json.dumps({{
        'ok': True,
        'matches': matches,
        'preview': '\\n'.join(matches),
        'total': total,
        'truncated': scan_truncated or total > len(matches) or truncated_by_bytes,
        'truncated_by': (
            'scan' if scan_truncated
            else ('bytes' if truncated_by_bytes else ('matches' if total > len(matches) else None))
        ),
    }}))
""".strip()
        return await self._run_workspace_file_script(script, query)

    async def _grep_workspace_via_box(
        self,
        path: str,
        pattern: str,
        include: str | None,
        query: pipeline_query.Query,
    ) -> dict:
        script = f"""
import json, os, re, signal, time
from pathlib import Path
path = {json.dumps(path)}
pattern = {json.dumps(pattern)}
include = {json.dumps(include)}
skip_dirs = {json.dumps(sorted(_SKIP_DIRS))}
def regex_timeout(_signum, _frame):
    raise TimeoutError
signal.signal(signal.SIGALRM, regex_timeout)
try:
    regex = re.compile(pattern)
except re.error as exc:
    print(json.dumps({{'ok': False, 'error': f'Invalid regex: {{exc}}'}}))
else:
    if not path.startswith('/workspace'):
        print(json.dumps({{'ok': False, 'error': 'Path must be under /workspace.'}}))
    elif not os.path.exists(path):
        print(json.dumps({{'ok': False, 'error': f'Path not found: {{path}}'}}))
    else:
        regex_deadline = time.monotonic() + {_GREP_REGEX_TIMEOUT_SECONDS}
        def bounded_search(value):
            remaining = regex_deadline - time.monotonic()
            if remaining <= 0:
                raise TimeoutError
            signal.setitimer(signal.ITIMER_REAL, remaining)
            try:
                return regex.search(value)
            finally:
                signal.setitimer(signal.ITIMER_REAL, 0)

        base = Path(path)
        if base.is_file():
            files = [base]
        else:
            files = []
            for item in base.rglob(include or '*'):
                if any(part in skip_dirs for part in item.parts):
                    continue
                if item.is_file():
                    files.append(item)
                if len(files) >= {_GREP_MAX_FILES}:
                    break

        matches = []
        output_bytes = 0
        truncated_by = None
        total_chars_seen = 0
        for fp in files:
            try:
                handle = fp.open('r', encoding='utf-8', errors='ignore')
            except OSError:
                continue
            file_chars_seen = 0
            with handle:
                lineno = 0
                while True:
                    line = handle.readline({_GREP_MAX_SCAN_LINE_CHARS + 1})
                    if not line:
                        break
                    lineno += 1
                    file_chars_seen += len(line)
                    total_chars_seen += len(line)
                    if (
                        file_chars_seen > {_GREP_MAX_FILE_SCAN_CHARS}
                        or total_chars_seen > {_GREP_MAX_TOTAL_SCAN_CHARS}
                    ):
                        truncated_by = 'scan'
                        break
                    if len(line) > {_GREP_MAX_SCAN_LINE_CHARS} and not line.endswith('\\n'):
                        truncated_by = truncated_by or 'line'
                        break
                    try:
                        matched = bounded_search(line)
                    except TimeoutError:
                        print(json.dumps({{'ok': False, 'error': 'Regex search timed out'}}))
                        raise SystemExit(0)
                    if matched:
                        if base.is_file():
                            file_path = path
                        else:
                            rel = os.path.relpath(str(fp), path)
                            file_path = os.path.join(path, rel).replace(os.sep, '/')
                        content = line.rstrip()
                        line_truncated = False
                        if len(content) > {_GREP_MAX_LINE_CHARS}:
                            content = content[:{_GREP_MAX_LINE_CHARS}] + '... [truncated]'
                            line_truncated = True
                        entry = {{'file': file_path, 'line': lineno, 'content': content}}
                        entry_bytes = len(json.dumps(entry, ensure_ascii=False).encode('utf-8')) + 1
                        if output_bytes + entry_bytes > {_DEFAULT_TOOL_RESULT_MAX_BYTES}:
                            truncated_by = 'bytes'
                            break
                        if line_truncated and truncated_by is None:
                            truncated_by = 'line'
                        matches.append(entry)
                        output_bytes += entry_bytes
                        if len(matches) >= {_GREP_MAX_MATCHES}:
                            truncated_by = truncated_by or 'matches'
                            break
                if truncated_by in ('bytes', 'scan') or len(matches) >= {_GREP_MAX_MATCHES}:
                    break
            if truncated_by in ('bytes', 'scan') or len(matches) >= {_GREP_MAX_MATCHES}:
                break

        print(json.dumps({{
            'ok': True,
            'matches': matches,
            'total': len(matches),
            'truncated': truncated_by is not None,
            'truncated_by': truncated_by,
        }}))
""".strip()
        return await self._run_workspace_file_script(script, query)

    async def _invoke_read(self, parameters: dict, query: pipeline_query.Query) -> dict:
        path = parameters['path']
        self.ap.logger.info(f'read tool invoked: query_id={query.query_id} path={path}')
        skill_request = self._resolve_skill_relative_path(
            query,
            path,
            include_visible=True,
            include_activated=True,
        )
        if skill_request is not None and hasattr(self.ap.box_service, 'read_skill_file'):
            selected_skill, relative = skill_request
            if self._can_interpret_skill_host_paths():
                host_location = self._resolve_skill_host_location(selected_skill, relative)
            else:
                host_location = None
            if host_location is not None:
                try:
                    return await asyncio.to_thread(self._read_host_location, host_location, parameters)
                except FileNotFoundError:
                    pass

            try:
                result = await self.ap.box_service.read_skill_file(
                    self._execution_context(query),
                    selected_skill['name'],
                    relative,
                )
                return self._build_read_result_from_text(str(result.get('content', '')), parameters)
            except Exception:
                try:
                    result = await self.ap.box_service.list_skill_files(
                        self._execution_context(query),
                        selected_skill['name'],
                        relative,
                    )
                    entries = [entry['name'] for entry in result.get('entries', [])]
                    return self._build_directory_result(entries)
                except Exception as exc:
                    return {'ok': False, 'error': str(exc)}

        host_location = self._resolve_host_location(
            query,
            path,
            include_visible=True,
            include_activated=True,
        )
        if self._should_use_box_workspace_files(host_location.selected_skill):
            return await self._read_workspace_via_box(path, parameters, query)
        try:
            return await asyncio.to_thread(self._read_host_location, host_location, parameters)
        except (FileNotFoundError, NotADirectoryError):
            return {'ok': False, 'error': f'File not found: {path}'}

    async def _invoke_write(self, parameters: dict, query: pipeline_query.Query) -> dict:
        path = parameters['path']
        content = parameters['content']
        self.ap.logger.info(f'write tool invoked: query_id={query.query_id} path={path} length={len(content)}')
        encoding, _mode = self._write_options(parameters)
        skill_request = self._resolve_skill_relative_path(
            query,
            path,
            include_visible=False,
            include_activated=True,
        )
        if skill_request is not None and hasattr(self.ap.box_service, 'write_skill_file'):
            if encoding != 'text':
                return {'ok': False, 'error': 'base64 writes to skill packages are not supported.'}
            selected_skill, relative = skill_request
            execution_context = self._execution_context(query)
            await self.ap.box_service.write_skill_file(execution_context, selected_skill['name'], relative, content)
            await self.ap.skill_mgr.reload_skills(execution_context)
            return {'ok': True, 'path': path}

        host_location = self._resolve_host_location(
            query,
            path,
            include_visible=False,
            include_activated=True,
        )
        if self._should_use_box_workspace_files(host_location.selected_skill):
            return await self._write_workspace_via_box(path, content, parameters, query)
        try:
            await run_blocking_atomic(self._write_host_location, host_location, content, parameters)
        except ValueError as exc:
            return {'ok': False, 'error': str(exc)}
        self._refresh_skill_from_disk(query, host_location.selected_skill)
        return {'ok': True, 'path': path}

    async def _invoke_edit(self, parameters: dict, query: pipeline_query.Query) -> dict:
        path = parameters['path']
        old_string = parameters['old_string']
        new_string = parameters['new_string']
        self.ap.logger.info(
            f'edit tool invoked: query_id={query.query_id} path={path} '
            f'old_len={len(old_string)} new_len={len(new_string)}'
        )
        skill_request = self._resolve_skill_relative_path(
            query,
            path,
            include_visible=False,
            include_activated=True,
        )
        if (
            skill_request is not None
            and hasattr(self.ap.box_service, 'read_skill_file')
            and hasattr(self.ap.box_service, 'write_skill_file')
        ):
            selected_skill, relative = skill_request
            try:
                result = await self.ap.box_service.read_skill_file(
                    self._execution_context(query),
                    selected_skill['name'],
                    relative,
                )
            except Exception:
                return {'ok': False, 'error': f'File not found: {path}'}
            content = result.get('content', '')
            count = content.count(old_string)
            if count == 0:
                return {'ok': False, 'error': 'old_string not found in file.'}
            if count > 1:
                return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
            new_content = content.replace(old_string, new_string, 1)
            execution_context = self._execution_context(query)
            await self.ap.box_service.write_skill_file(
                execution_context,
                selected_skill['name'],
                relative,
                new_content,
            )
            await self.ap.skill_mgr.reload_skills(execution_context)
            return {'ok': True, 'path': path}

        host_location = self._resolve_host_location(
            query,
            path,
            include_visible=False,
            include_activated=True,
        )
        if self._should_use_box_workspace_files(host_location.selected_skill):
            return await self._edit_workspace_via_box(path, old_string, new_string, query)
        try:
            changed, error = await run_blocking_atomic(
                self._edit_host_location,
                host_location,
                old_string,
                new_string,
            )
        except (FileNotFoundError, NotADirectoryError):
            return {'ok': False, 'error': f'File not found: {path}'}
        if not changed:
            return {'ok': False, 'error': error or f'File not found: {path}'}
        self._refresh_skill_from_disk(query, host_location.selected_skill)
        return {'ok': True, 'path': path}

    def _refresh_skill_from_disk(self, query: pipeline_query.Query, selected_skill: dict | None) -> None:
        if selected_skill is None:
            return

        skill_mgr = getattr(self.ap, 'skill_mgr', None)
        if skill_mgr is None:
            return

        refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None)
        if callable(refresh_skill):
            refresh_skill(self._execution_context(query), selected_skill.get('name', ''))

    async def _is_sandbox_available(self) -> bool:
        """Refresh backend availability so Box reconnects restore tool exposure."""
        self._backend_available = await self._check_backend_available()
        return bool(self._backend_available)

    def _build_exec_tool(self) -> resource_tool.LLMTool:
        return resource_tool.LLMTool(
            name=EXEC_TOOL_NAME,
            human_desc='Execute a command in an isolated environment',
            description=(
                'Run shell commands in an isolated execution environment. '
                'Use this tool for bash commands, Python execution, and exact calculations over '
                'user-provided data. Activated skill packages are addressable under '
                '/workspace/.skills/<skill-name>; when running inside one, set workdir to that path. '
                'To create a new skill package, prepare it under /workspace first, then use register_skill.'
            ),
            parameters={
                'type': 'object',
                'properties': {
                    'command': {
                        'type': 'string',
                        'description': 'Shell command to execute.',
                    },
                    'workdir': {
                        'type': 'string',
                        'description': 'Working directory for the command. Defaults to /workspace.',
                        'default': '/workspace',
                    },
                    'timeout_sec': {
                        'type': 'integer',
                        'description': 'Execution timeout in seconds. Defaults to 30.',
                        'default': 30,
                        'minimum': 1,
                    },
                    'env': {
                        'type': 'object',
                        'description': 'Optional environment variables for the execution.',
                        'additionalProperties': {'type': 'string'},
                        'default': {},
                    },
                    'description': {
                        'type': 'string',
                        'description': 'Brief description of what this command does, for logging and audit.',
                    },
                },
                'required': ['command'],
                'additionalProperties': False,
            },
            func=lambda parameters: parameters,
        )

    def _build_read_tool(self) -> resource_tool.LLMTool:
        return resource_tool.LLMTool(
            name=READ_TOOL_NAME,
            human_desc='Read a file from the workspace',
            description=(
                'Read the contents of a file at the given path under /workspace. '
                'Visible skill packages can be inspected through /workspace/.skills/<skill-name>/... .'
            ),
            parameters={
                'type': 'object',
                'properties': {
                    'path': {
                        'type': 'string',
                        'description': 'Absolute path to the file (must be under /workspace).',
                    },
                    'offset': {
                        'type': 'integer',
                        'description': '1-indexed line number to start reading from. Defaults to 1.',
                        'default': 1,
                        'minimum': 1,
                    },
                    'limit': {
                        'type': 'integer',
                        'description': f'Maximum number of lines to return. Defaults to {_DEFAULT_READ_MAX_LINES}.',
                        'default': _DEFAULT_READ_MAX_LINES,
                        'minimum': 1,
                        'maximum': _MAX_READ_MAX_LINES,
                    },
                    'max_bytes': {
                        'type': 'integer',
                        'description': (
                            f'Maximum bytes of file content to return. Defaults to {_DEFAULT_TOOL_RESULT_MAX_BYTES}.'
                        ),
                        'default': _DEFAULT_TOOL_RESULT_MAX_BYTES,
                        'minimum': 1,
                        'maximum': _DEFAULT_TOOL_RESULT_MAX_BYTES,
                    },
                    'encoding': {
                        'type': 'string',
                        'description': 'Return text by default, or base64 for binary byte-range reads.',
                        'enum': ['text', 'base64'],
                        'default': 'text',
                    },
                    'byte_offset': {
                        'type': 'integer',
                        'description': '0-indexed byte offset used when encoding is base64. Defaults to 0.',
                        'default': 0,
                        'minimum': 0,
                    },
                },
                'required': ['path'],
                'additionalProperties': False,
            },
            func=lambda parameters: parameters,
        )

    def _build_write_tool(self) -> resource_tool.LLMTool:
        return resource_tool.LLMTool(
            name=WRITE_TOOL_NAME,
            human_desc='Write a file to the workspace',
            description=(
                'Create or overwrite a file at the given path under /workspace with the provided content. '
                'Activated skill packages can be modified through /workspace/.skills/<skill-name>/... . '
                'For new skills, write files under /workspace and then call register_skill.'
            ),
            parameters={
                'type': 'object',
                'properties': {
                    'path': {
                        'type': 'string',
                        'description': 'Absolute path to the file (must be under /workspace).',
                    },
                    'content': {
                        'type': 'string',
                        'description': 'Text content, or base64 content when encoding is base64.',
                    },
                    'encoding': {
                        'type': 'string',
                        'description': 'Write content as text by default, or decode it from base64 for binary files.',
                        'enum': ['text', 'base64'],
                        'default': 'text',
                    },
                    'mode': {
                        'type': 'string',
                        'description': 'Overwrite the file by default, or append to it.',
                        'enum': ['overwrite', 'append'],
                        'default': 'overwrite',
                    },
                },
                'required': ['path', 'content'],
                'additionalProperties': False,
            },
            func=lambda parameters: parameters,
        )

    def _build_edit_tool(self) -> resource_tool.LLMTool:
        return resource_tool.LLMTool(
            name=EDIT_TOOL_NAME,
            human_desc='Edit a file in the workspace',
            description=(
                'Perform an exact string replacement in a file under /workspace. '
                'The old_string must appear exactly once in the file. Activated skill packages '
                'can be edited through /workspace/.skills/<skill-name>/... . '
                'For new skills, edit files under /workspace and then call register_skill.'
            ),
            parameters={
                'type': 'object',
                'properties': {
                    'path': {
                        'type': 'string',
                        'description': 'Absolute path to the file (must be under /workspace).',
                    },
                    'old_string': {
                        'type': 'string',
                        'description': 'The exact string to find and replace.',
                    },
                    'new_string': {
                        'type': 'string',
                        'description': 'The replacement string.',
                    },
                },
                'required': ['path', 'old_string', 'new_string'],
                'additionalProperties': False,
            },
            func=lambda parameters: parameters,
        )

    def _build_glob_tool(self) -> resource_tool.LLMTool:
        return resource_tool.LLMTool(
            name=GLOB_TOOL_NAME,
            human_desc='Find files matching a glob pattern',
            description=(
                'Find files matching a glob pattern under /workspace. '
                'Supports ** for recursive matching (e.g. **/*.py). '
                'Results are sorted by modification time (newest first). '
                'Visible and activated skill packages can be searched through /workspace/.skills/<skill-name>/...'
            ),
            parameters={
                'type': 'object',
                'properties': {
                    'pattern': {
                        'type': 'string',
                        'description': 'Glob pattern, e.g. **/*.py or src/**/*.ts',
                    },
                    'path': {
                        'type': 'string',
                        'description': 'Directory to search in (must be under /workspace, default: /workspace)',
                        'default': '/workspace',
                    },
                },
                'required': ['pattern'],
                'additionalProperties': False,
            },
            func=lambda parameters: parameters,
        )

    def _build_grep_tool(self) -> resource_tool.LLMTool:
        return resource_tool.LLMTool(
            name=GREP_TOOL_NAME,
            human_desc='Search file contents with regex',
            description=(
                'Search file contents with regex pattern under /workspace. '
                'Returns matching lines with file path and line number. '
                'Visible and activated skill packages can be searched through /workspace/.skills/<skill-name>/...'
            ),
            parameters={
                'type': 'object',
                'properties': {
                    'pattern': {
                        'type': 'string',
                        'description': 'Regex pattern to search for',
                    },
                    'path': {
                        'type': 'string',
                        'description': 'File or directory to search (must be under /workspace, default: /workspace)',
                        'default': '/workspace',
                    },
                    'include': {
                        'type': 'string',
                        'description': 'Only search files matching this glob (e.g. *.py)',
                    },
                },
                'required': ['pattern'],
                'additionalProperties': False,
            },
            func=lambda parameters: parameters,
        )

    async def _invoke_glob(self, parameters: dict, query: pipeline_query.Query) -> dict:
        pattern = parameters['pattern']
        path = str(parameters.get('path', '/workspace') or '/workspace')
        self.ap.logger.info(f'glob tool invoked: query_id={query.query_id} pattern={pattern} path={path}')

        host_location = self._resolve_host_location(
            query,
            path,
            include_visible=True,
            include_activated=True,
        )
        if self._should_use_box_workspace_files(host_location.selected_skill):
            return await self._glob_workspace_via_box(path, pattern, query)
        try:
            return await asyncio.to_thread(self._glob_host_location, host_location, pattern, path)
        except (FileNotFoundError, NotADirectoryError):
            return {'ok': False, 'error': f'Path is not a directory: {path}'}

    async def _invoke_grep(self, parameters: dict, query: pipeline_query.Query) -> dict:
        pattern = parameters['pattern']
        path = str(parameters.get('path', '/workspace') or '/workspace')
        include = parameters.get('include')
        self.ap.logger.info(f'grep tool invoked: query_id={query.query_id} pattern={pattern} path={path}')

        if not isinstance(pattern, str) or len(pattern) > _GREP_MAX_PATTERN_CHARS:
            return {'ok': False, 'error': f'Regex patterns may contain at most {_GREP_MAX_PATTERN_CHARS} characters'}

        host_location = self._resolve_host_location(
            query,
            path,
            include_visible=True,
            include_activated=True,
        )
        if self._should_use_box_workspace_files(host_location.selected_skill):
            return await self._grep_workspace_via_box(path, pattern, include, query)
        try:
            return await asyncio.to_thread(
                self._grep_host_location,
                host_location,
                pattern,
                include,
                path,
            )
        except (FileNotFoundError, NotADirectoryError):
            return {'ok': False, 'error': f'Path not found: {path}'}

    @staticmethod
    def _resolve_skill_host_location(selected_skill: dict, relative: str) -> _HostLocation | None:
        package_root = str(selected_skill.get('package_root', '') or '').strip()
        if not package_root:
            return None
        relative_path = '/workspace' if relative in {'', '.'} else f'/workspace/{relative}'
        return _HostLocation(
            root=package_root,
            relative_parts=_relative_workspace_parts(relative_path),
            selected_skill=selected_skill,
        )

    def _normalize_exec_result(self, result: dict) -> dict:
        normalized = dict(result)
        stdout = str(normalized.get('stdout') or '')
        stderr = str(normalized.get('stderr') or '')
        stdout, stdout_capped = self._truncate_text_to_bytes_with_flag(stdout, _DEFAULT_TOOL_RESULT_MAX_BYTES)
        stderr, stderr_capped = self._truncate_text_to_bytes_with_flag(stderr, _DEFAULT_TOOL_RESULT_MAX_BYTES)
        normalized['stdout'] = stdout
        normalized['stderr'] = stderr
        normalized['stdout_truncated'] = bool(normalized.get('stdout_truncated') or stdout_capped)
        normalized['stderr_truncated'] = bool(normalized.get('stderr_truncated') or stderr_capped)

        if stdout and stderr:
            preview_raw = f'stdout:\n{stdout}\n\nstderr:\n{stderr}'
        else:
            preview_raw = stdout or stderr
        preview, preview_capped = self._truncate_text_to_bytes_with_flag(preview_raw, _DEFAULT_TOOL_RESULT_MAX_BYTES)
        normalized['preview'] = preview
        normalized['truncated'] = bool(
            normalized['stdout_truncated'] or normalized['stderr_truncated'] or preview_capped
        )
        if preview_capped and not normalized.get('truncated_by'):
            normalized['truncated_by'] = 'bytes'
        return normalized

    def _build_directory_result(
        self,
        entries: list[str],
        *,
        total: int | None = None,
        force_truncated_by: str | None = None,
    ) -> dict:
        sorted_entries = sorted(str(entry) for entry in entries)
        content = '\n'.join(sorted_entries)
        preview = self._truncate_text_to_bytes(content, _DEFAULT_TOOL_RESULT_MAX_BYTES)
        truncated_by = force_truncated_by or ('bytes' if preview != content else None)
        return {
            'ok': True,
            'content': preview,
            'is_directory': True,
            'total': len(sorted_entries) if total is None else total,
            'truncated': truncated_by is not None,
            'truncated_by': truncated_by,
        }

    def _read_text_file_preview(self, file_fd: int, parameters: dict, *, metadata: os.stat_result) -> dict:
        if self._read_encoding(parameters) == 'base64':
            return self._read_binary_file_chunk(file_fd, parameters, metadata=metadata)

        offset = self._positive_int(parameters.get('offset'), default=1)
        max_lines = self._positive_int(
            parameters.get('limit'),
            default=_DEFAULT_READ_MAX_LINES,
            max_value=_MAX_READ_MAX_LINES,
        )
        max_bytes = self._positive_int(
            parameters.get('max_bytes'),
            default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
            max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
        )
        lines: list[str] = []
        output_bytes = 0
        end_line = offset - 1
        truncated = False
        truncated_by: str | None = None
        next_offset: int | None = None

        with os.fdopen(os.dup(file_fd), 'r', encoding='utf-8', errors='replace') as f:
            for line_number, line in enumerate(f, 1):
                if line_number < offset:
                    continue
                if len(lines) >= max_lines:
                    truncated = True
                    truncated_by = 'lines'
                    next_offset = line_number
                    break

                line_bytes = len(line.encode('utf-8'))
                if output_bytes + line_bytes > max_bytes:
                    truncated = True
                    truncated_by = 'bytes'
                    next_offset = line_number
                    break

                lines.append(line.rstrip('\n'))
                output_bytes += line_bytes
                end_line = line_number

        if not lines and truncated_by == 'bytes':
            content = (
                f'[Line {next_offset or offset} exceeds the {self._format_size(max_bytes)} read limit. '
                'Use exec with a byte-range command for this line, or read a different offset.]'
            )
        else:
            content = '\n'.join(lines)

        return {
            'ok': True,
            'content': content,
            'truncated': truncated,
            'truncated_by': truncated_by,
            'start_line': offset,
            'end_line': end_line,
            'next_offset': next_offset,
            'max_lines': max_lines,
            'max_bytes': max_bytes,
        }

    def _read_binary_file_chunk(self, file_fd: int, parameters: dict, *, metadata: os.stat_result) -> dict:
        byte_offset = self._non_negative_int(parameters.get('byte_offset'), default=0)
        max_bytes = self._positive_int(
            parameters.get('max_bytes'),
            default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
            max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
        )
        size_bytes = metadata.st_size
        with os.fdopen(os.dup(file_fd), 'rb') as f:
            f.seek(byte_offset)
            data = f.read(max_bytes + 1)
        chunk = data[:max_bytes]
        has_more = len(data) > max_bytes
        return {
            'ok': True,
            'content': base64.b64encode(chunk).decode('ascii'),
            'encoding': 'base64',
            'byte_offset': byte_offset,
            'length': len(chunk),
            'size_bytes': size_bytes,
            'has_more': has_more,
            'next_byte_offset': byte_offset + len(chunk) if has_more else None,
            'max_bytes': max_bytes,
        }

    @staticmethod
    def _read_encoding(parameters: dict) -> str:
        return 'base64' if parameters.get('encoding') == 'base64' else 'text'

    @staticmethod
    def _write_options(parameters: dict) -> tuple[str, str]:
        encoding = 'base64' if parameters.get('encoding') == 'base64' else 'text'
        mode = 'append' if parameters.get('mode') == 'append' else 'overwrite'
        return encoding, mode

    def _build_read_result_from_text(self, content: str, parameters: dict) -> dict:
        offset = self._positive_int(parameters.get('offset'), default=1)
        max_lines = self._positive_int(
            parameters.get('limit'),
            default=_DEFAULT_READ_MAX_LINES,
            max_value=_MAX_READ_MAX_LINES,
        )
        max_bytes = self._positive_int(
            parameters.get('max_bytes'),
            default=_DEFAULT_TOOL_RESULT_MAX_BYTES,
            max_value=_DEFAULT_TOOL_RESULT_MAX_BYTES,
        )
        all_lines = content.splitlines()
        start_index = offset - 1
        if start_index >= len(all_lines) and all_lines:
            return {'ok': False, 'error': f'Offset {offset} is beyond end of file ({len(all_lines)} lines total)'}
        output_lines: list[str] = []
        output_bytes = 0
        truncated = False
        truncated_by: str | None = None
        next_offset: int | None = None
        for index, line in enumerate(all_lines[start_index:], start_index + 1):
            if len(output_lines) >= max_lines:
                truncated = True
                truncated_by = 'lines'
                next_offset = index
                break
            line_bytes = len(line.encode('utf-8')) + (1 if output_lines else 0)
            if output_bytes + line_bytes > max_bytes:
                truncated = True
                truncated_by = 'bytes'
                next_offset = index
                break
            output_lines.append(line)
            output_bytes += line_bytes

        end_line = offset + len(output_lines) - 1
        return {
            'ok': True,
            'content': '\n'.join(output_lines),
            'truncated': truncated,
            'truncated_by': truncated_by,
            'start_line': offset,
            'end_line': end_line,
            'next_offset': next_offset,
            'max_lines': max_lines,
            'max_bytes': max_bytes,
        }

    @staticmethod
    def _positive_int(value, *, default: int, max_value: int | None = None) -> int:
        try:
            parsed = int(value)
        except (TypeError, ValueError):
            parsed = default
        if parsed <= 0:
            parsed = default
        if max_value is not None:
            parsed = min(parsed, max_value)
        return parsed

    @staticmethod
    def _non_negative_int(value, *, default: int) -> int:
        try:
            parsed = int(value)
        except (TypeError, ValueError):
            parsed = default
        return parsed if parsed >= 0 else default

    @staticmethod
    def _truncate_grep_line(line: str) -> tuple[str, bool]:
        if len(line) <= _GREP_MAX_LINE_CHARS:
            return line, False
        return f'{line[:_GREP_MAX_LINE_CHARS]}... [truncated]', True

    @staticmethod
    def _truncate_text_to_bytes(text: str, max_bytes: int) -> str:
        return NativeToolLoader._truncate_text_to_bytes_with_flag(text, max_bytes)[0]

    @staticmethod
    def _truncate_text_to_bytes_with_flag(text: str, max_bytes: int) -> tuple[str, bool]:
        data = text.encode('utf-8')
        if len(data) <= max_bytes:
            return text, False
        truncated = data[:max_bytes]
        while truncated and (truncated[-1] & 0xC0) == 0x80:
            truncated = truncated[:-1]
        return truncated.decode('utf-8', errors='ignore'), True

    @staticmethod
    def _format_size(bytes_count: int) -> str:
        if bytes_count < 1024:
            return f'{bytes_count}B'
        return f'{bytes_count / 1024:.1f}KB'

    def _summarize_parameters(self, parameters: dict) -> dict:
        summary = dict(parameters)
        cmd = str(summary.get('command', '')).strip()
        if len(cmd) > 400:
            cmd = f'{cmd[:397]}...'
        summary['command'] = cmd

        env = summary.get('env')
        if isinstance(env, dict):
            summary['env_keys'] = sorted(str(key) for key in env.keys())
            del summary['env']

        return summary
