Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
972 changes: 966 additions & 6 deletions bot/tests/test_compile.py

Large diffs are not rendered by default.

213 changes: 213 additions & 0 deletions bot/tests/test_sandbox_file_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd.
# SPDX-License-Identifier: AGPL-3.0
"""Regression tests for bounded local and remote sandbox file access."""

import json
from pathlib import Path
from types import SimpleNamespace

import pytest
from vikingbot.sandbox.backends.aiosandbox import AioSandboxBackend
from vikingbot.sandbox.backends.direct import DirectBackend
from vikingbot.sandbox.backends.opensandbox import OpenSandboxBackend
from vikingbot.sandbox.base import SandboxFileInfo


def _direct_backend(workspace: Path) -> DirectBackend:
return DirectBackend(SimpleNamespace(restrict_workspaces={}), "test", workspace)


@pytest.mark.asyncio
async def test_local_workspace_listing_and_reads_are_bounded(tmp_path: Path):
backend = _direct_backend(tmp_path)
await backend.start()
(tmp_path / ".hidden").write_bytes(b"h")
(tmp_path / "nested").mkdir()
(tmp_path / "nested" / "artifact.bin").write_bytes(b"artifact")

assert await backend.list_files(max_entries=3) == [
SandboxFileInfo(path=".hidden", size=1),
SandboxFileInfo(path="nested/artifact.bin", size=8),
]
assert await backend.read_file_bytes("nested/artifact.bin", max_bytes=8) == b"artifact"
with pytest.raises(ValueError, match="7-byte read limit"):
await backend.read_file_bytes("nested/artifact.bin", max_bytes=7)
with pytest.raises(ValueError, match="inventory exceeds 2 entries"):
await backend.list_files(max_entries=2)


class _AioFileClient:
def __init__(self, *, truncated=False):
self.list_calls = []
self.glob_calls = []
self.download_calls = []
self.truncated = truncated
self.entries = {
"/home/gem": [
SimpleNamespace(name="artifact.bin", is_directory=False, size=6),
SimpleNamespace(name="nested", is_directory=True, size=0),
],
"/home/gem/nested": [
SimpleNamespace(name="page.md", is_directory=False, size=4),
],
}

async def list_path(self, **kwargs):
self.list_calls.append(kwargs)
return SimpleNamespace(data=SimpleNamespace(files=self.entries[kwargs["path"]]))

async def glob_files(self, **kwargs):
self.glob_calls.append(kwargs)
return SimpleNamespace(
data=SimpleNamespace(
files=[
SimpleNamespace(
path="/home/gem",
is_directory=True,
size=0,
),
SimpleNamespace(
path="/home/gem/artifact.bin",
is_directory=False,
size=6,
),
SimpleNamespace(
path="/home/gem/nested",
is_directory=True,
size=0,
),
SimpleNamespace(
path="/home/gem/nested/page.md",
is_directory=False,
size=4,
),
],
total_count=5 if self.truncated else 4,
truncated=self.truncated,
)
)

async def download_file(self, *, path):
self.download_calls.append(path)
yield b"abc"
yield b"def"


def _aio_backend(tmp_path: Path, file_client: _AioFileClient) -> AioSandboxBackend:
config = SimpleNamespace(
backends=SimpleNamespace(aiosandbox=SimpleNamespace(base_url="http://sandbox"))
)
backend = AioSandboxBackend(config, "test", tmp_path)
backend._client = SimpleNamespace(file=file_client)
return backend


@pytest.mark.asyncio
async def test_aiosandbox_lists_and_streams_from_remote_workspace(tmp_path: Path):
files = _AioFileClient()
backend = _aio_backend(tmp_path, files)

assert await backend.list_files(max_entries=3) == [
SandboxFileInfo(path="artifact.bin", size=6),
SandboxFileInfo(path="nested/page.md", size=4),
]
assert files.glob_calls == [
{
"path": "/home/gem",
"pattern": "**",
"include_hidden": True,
"files_only": False,
"include_metadata": True,
"max_results": 4,
"sort_by": "path",
},
]
assert await backend.read_file_bytes("artifact.bin", max_bytes=6) == b"abcdef"
with pytest.raises(ValueError, match="5-byte read limit"):
await backend.read_file_bytes("artifact.bin", max_bytes=5)
assert files.download_calls == ["/home/gem/artifact.bin", "/home/gem/artifact.bin"]


@pytest.mark.asyncio
async def test_aiosandbox_inventory_uses_the_remote_result_limit(tmp_path: Path):
files = _AioFileClient(truncated=True)
backend = _aio_backend(tmp_path, files)

with pytest.raises(ValueError, match="inventory exceeds 3 entries"):
await backend.list_files(max_entries=3)
assert files.glob_calls[0]["max_results"] == 4


class _OpenSandboxFiles:
def __init__(self):
self.read_calls = []

async def read_bytes_stream(self, path, *, range_header):
self.read_calls.append((path, range_header))
payload = b"abcdef"

async def chunks():
yield payload[:3]
yield payload[3:]

return chunks()


class _OpenSandboxCommands:
def __init__(self, *, overflow=False):
self.overflow = overflow
self.calls = []

async def run(self, command, *, opts):
self.calls.append((command, opts))
payload = {
"overflow": self.overflow,
"files": [["artifact.bin", 6], ["nested/page.md", 4]],
}
return SimpleNamespace(
error=None,
logs=SimpleNamespace(stdout=[SimpleNamespace(text=json.dumps(payload))]),
)


def _opensandbox_vke_backend(
tmp_path: Path,
file_client: _OpenSandboxFiles,
commands: _OpenSandboxCommands,
) -> OpenSandboxBackend:
backend = object.__new__(OpenSandboxBackend)
backend._workspace = tmp_path
backend._is_vke = True
backend._sandbox = SimpleNamespace(files=file_client, commands=commands)
return backend


@pytest.mark.asyncio
async def test_opensandbox_vke_uses_bounded_remote_inventory_and_range_reads(tmp_path: Path):
files = _OpenSandboxFiles()
commands = _OpenSandboxCommands()
backend = _opensandbox_vke_backend(tmp_path, files, commands)

assert await backend.list_files(max_entries=2) == [
SandboxFileInfo(path="artifact.bin", size=6),
SandboxFileInfo(path="nested/page.md", size=4),
]
assert "python3 -c" in commands.calls[0][0]
assert "limit = 2" in commands.calls[0][0]
assert await backend.read_file_bytes("artifact.bin", max_bytes=6) == b"abcdef"
with pytest.raises(ValueError, match="5-byte read limit"):
await backend.read_file_bytes("artifact.bin", max_bytes=5)
assert files.read_calls == [
("/workspace/artifact.bin", "bytes=0-6"),
("/workspace/artifact.bin", "bytes=0-5"),
]


@pytest.mark.asyncio
async def test_opensandbox_vke_inventory_stops_at_the_remote_limit(tmp_path: Path):
commands = _OpenSandboxCommands(overflow=True)
backend = _opensandbox_vke_backend(tmp_path, _OpenSandboxFiles(), commands)

with pytest.raises(ValueError, match="inventory exceeds 1 entries"):
await backend.list_files(max_entries=1)
assert "limit = 1" in commands.calls[0][0]
12 changes: 12 additions & 0 deletions bot/vikingbot/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ class _PlainTextFinal:
content: str | None = None


class AgentIterationLimitExceeded(RuntimeError):
"""A structured task used every available AgentLoop iteration without submitting."""

def __init__(self, max_iterations: int):
self.max_iterations = max_iterations
super().__init__(
f"Agent reached its {max_iterations}-iteration limit without submitting a valid bundle"
)


class AgentLoop:
"""
The agent loop is the core processing engine.
Expand Down Expand Up @@ -1335,6 +1345,8 @@ async def require_submission(context: _PlainTextContext) -> _PlainTextDelivered:
submit_tool = tool_registry.get("submit_wiki_bundle")
bundle = getattr(submit_tool, "bundle", None)
if bundle is None:
if iteration >= self.max_iterations:
raise AgentIterationLimitExceeded(self.max_iterations)
raise ValueError("AGENT_OUTPUT_INVALID: Agent did not submit a valid Wiki bundle")
return bundle, tools_used, token_usage, iteration

Expand Down
27 changes: 15 additions & 12 deletions bot/vikingbot/agent/tools/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,9 @@ def parameters(self) -> dict[str, Any]:
match_schema = link_def.get("properties", {}).get("match_text")
if isinstance(match_schema, dict):
match_schema["description"] = (
"Exact anchor text that must appear in the source page draft body outside "
"frontmatter, code, existing Markdown links, and Citations."
"Exact anchor text that must either appear in the source page draft body "
"outside frontmatter, code, existing Markdown links, and Citations, or "
"already be part of a Markdown link to the target page."
)
schema.pop("title", None)
return schema
Expand Down Expand Up @@ -474,6 +475,8 @@ async def _validate_bundle(
raise ValueError("page limit exceeded")
if len(bundle.files) > self.limits.output_files:
raise ValueError("file limit exceeded")
if len(bundle.pages) + len(bundle.files) > self.limits.output_operations:
raise ValueError("combined output operation limit exceeded")
if not bundle.pages and bundle.links:
raise ValueError("empty bundle must not contain links")
if target_type == "skill" and (bundle.pages or bundle.links):
Expand All @@ -490,6 +493,7 @@ async def _validate_bundle(
"using workspace_path instead of inline content"
)
page_ids: set[int] = set()
page_uris: dict[int, str] = {}
final_uris: set[str] = set()
total_bytes = 0
for page in bundle.pages:
Expand Down Expand Up @@ -532,6 +536,7 @@ async def _validate_bundle(
if final_uri in final_uris:
raise ValueError(f"duplicate final Wiki path: {final_uri}")
final_uris.add(final_uri)
page_uris[page.page_id] = final_uri
total_bytes += len(page.body_markdown.encode("utf-8"))

file_payloads: list[bytes | None] = []
Expand Down Expand Up @@ -600,18 +605,16 @@ async def _validate_bundle(
link_errors.append(f"{prefix} match_text is required")
continue
source_page = page_by_id[link.f]
if (
LinkRenderer._find_match_span(
source_page.body_markdown,
link.match_text,
LinkRenderer.protected_markdown_spans(source_page.body_markdown),
)
is None
if not LinkRenderer.can_render_link(
source_page.body_markdown,
link.match_text,
page_uris[link.f],
page_uris[link.t],
):
link_errors.append(
f"{prefix} from page {link.f} has non-linkable anchor "
f"{link.match_text!r}; remove the link or use exact unprotected "
"text from that page body"
f"{prefix} from page {link.f} has unsatisfied anchor "
f"{link.match_text!r}; use exact unprotected text or an existing "
f"Markdown link to page {link.t}"
)
if link_errors:
raise ValueError(f"{len(link_errors)} invalid link(s): " + "; ".join(link_errors))
Expand Down
27 changes: 20 additions & 7 deletions bot/vikingbot/compile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,17 @@ class CompileLimits(BaseModel):
tool_uri_count: int = 32
tool_result_bytes: int = 1024 * 1024
tool_total_result_bytes: int = 8 * 1024 * 1024
output_pages: int = 64
output_files: int = 64
output_pages: int = 128
output_files: int = 128
output_operations: int = 256
output_total_bytes: int = 4 * 1024 * 1024
concurrent_tasks: int = 2
accepted_tasks: int = 16
accepted_tasks_per_principal: int = 4
queue_wait_seconds: float = 5 * 60
task_runtime_seconds: float = 30 * 60
concurrent_tasks: int = 10
accepted_tasks: int = 40
accepted_tasks_per_principal: int = 10
queue_wait_seconds: float = 60 * 60
task_runtime_seconds: float = 40 * 60
salvage_grace_seconds: float = 120
Comment thread
yeshion23333 marked this conversation as resolved.
cleanup_grace_seconds: float = 40
terminal_task_retention_seconds: float = 24 * 60 * 60
terminal_task_records: int = 1000

Expand All @@ -53,6 +56,11 @@ class CompileRequest(BaseModel):
to: str = Field(min_length=1)
reason: str | None = None
skill: str = Field(min_length=1)
runtime_timeout_seconds: float | None = Field(
default=None,
gt=0,
allow_inf_nan=False,
)
openviking_connection: OpenVikingConnection | None = None
_principal_scope: str = PrivateAttr(default="local")

Expand All @@ -64,6 +72,11 @@ class SanitizedCompileRequest(BaseModel):
to: str
reason: str
skill: str
runtime_timeout_seconds: float | None = Field(
default=None,
gt=0,
allow_inf_nan=False,
)


class WikiPageDraft(BaseModel):
Expand Down
11 changes: 7 additions & 4 deletions bot/vikingbot/compile/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ def render(
raise ValueError("Wiki bundle exceeds the page limit")
if len(bundle.files) > self.limits.output_files:
raise ValueError("Wiki bundle exceeds the file limit")
if len(bundle.pages) + len(bundle.files) > self.limits.output_operations:
raise ValueError("Wiki bundle exceeds the combined output operation limit")
if not bundle.pages and bundle.links:
raise ValueError("an empty Wiki bundle cannot contain links")
target_type = context_type_for_uri(target_uri)
Expand Down Expand Up @@ -453,13 +455,14 @@ def render(
raise ValueError(f"WikiLink references an unknown page_id: f={link.f}, t={link.t}")
if not link.match_text:
raise ValueError("WikiLink match_text is required")
if LinkRenderer._find_match_span(
if not LinkRenderer.can_render_link(
source_page.body_markdown,
link.match_text,
LinkRenderer.protected_markdown_spans(source_page.body_markdown),
) is None:
page_uris[link.f][0],
page_uris[link.t][0],
):
raise ValueError(
f"WikiLink match_text is not a linkable body anchor: {link.match_text!r}"
f"WikiLink match_text is not a satisfiable body anchor: {link.match_text!r}"
)

resolved_links = resolve_wiki_links(bundle.links, page_uris, strict=True)
Expand Down
Loading