From 67bc97a10b0a97a686ffce7482f79e1b44f5aae5 Mon Sep 17 00:00:00 2001 From: Wings-Of-Disaster <1327040545@qq.com> Date: Tue, 20 Oct 2026 11:53:14 +0800 Subject: [PATCH 1/2] feat(envs): add a host-served world and sandbox profiles Two problems, one commit, because the second exists to make the first safe to hand out. An agentic run needs tasks whose answers the policy cannot invent. A task that says "report the failure rate in the ops database" is only a real task if the database exists and the policy has to read it; otherwise the policy writes plausible numbers and a check that compares strings passes either way. Measured on one run's 256 generated tasks: every one of them verified with os.path.isfile(), and 27% parsed a SQLite file the model had itself just written -- so a high score meant the answer agreed with its own premise. And every environment the run handed out was the same environment: the same tools, an empty workspace, and a topic that had no bearing on either. A task about querying an operations database and a task about office-chair ergonomics then differ only in their prompt, and the policy learns that the fixture is something it can invent -- because for most topics it was. world.py -- HostWorld is a read-only SQLite database, a document set and a frozen search corpus, served from the training host and exposed as seven tools (world_sql, world_schema, world_doc, web_search, fetch_page, db_query, db_schema). Host-side is the point, not an implementation detail: a tool call already round-trips through Env.step_batch, so the host sees every call before and after the sandbox does and needs no network path into it. That keeps a networkless environment honestly networkless, adds no listening port to defend, and keeps the data out of the sandbox where it could be edited or left behind in a snapshot. Every retrieval answer is recorded as a receipt, with the urls taken from the tool's own output rather than its arguments -- web_search is asked a query and decides which urls to return, so what was actually served is only knowable from the result. A fetch that failed records the call with no url, so "I fetched this" has to be earned. This is what makes a citation gradeable rather than assertable. WorldToolsMixin exposes it on any Env. A mixin rather than a change to AgentEnv, so it applies to LocalEnv and OpenEnv too and the host half is testable without a sandbox host -- which is how all 19 of its tests run. It strips the {server}---{tool} suffix a harness adds, and tracks step_batch positions explicitly so an observation cannot be paired with the wrong call. Tools are advertised only when their backing store is present: offering one that always answers "not configured" costs the policy a turn, and a difficulty measurement built on that episode counts the wasted turn as task work. profiles.py -- SandboxProfile is an immutable manifest: capabilities, task forms, allowed tools, which verifier kinds can grade it, and what the workspace must contain before the episode starts. Validated at construction rather than at use; a profile granting raw_network is refused outright, because serving the world from the host is what keeps the sandbox networkless and a profile could otherwise undo that quietly. Each profile and the registry carry a content digest, so a run can record which profile set it used and a reader can tell whether two runs are comparable. Thirteen ship in default_profile_registry(). ProfiledEnvMixin narrows tools() to the profile and refuses anything else in step()/step_batch(), because advertising is not enforcement -- a policy can name a tool it was never shown. switch_profile runs the profile's setup manifest, and a step that does not resolve, or a canary that fails, raises rather than being recorded: a slot missing its fixture produces tasks that read as hard, not as broken. Order matters and is documented at both classes: ProfiledEnvMixin has to precede WorldToolsMixin, so the world contributes its tools first and the profile then decides which survive. Reversed, a profile could not restrict the world at all. seeds.py -- the programs those manifests name. A seed is what makes a task checkable against data the policy did not author: seed-sql-analytics builds a shop.db with customers, products and orders; seed-csv-dirty writes a CSV with a BOM, duplicate rows and empty cells; seed-broken-repo lays down a repository whose tests fail; seed-log-corpus writes two logs that disagree; seed-docs-corpus plants a document set with decoys in it. Programs rather than data files, so the fixture is reproducible from the profile alone and a run ships no bytes. Each seed is a template holding one {workspace!r} placeholder, substituted by exact str.replace and never by str.format: the programs are real python and contain set and dict comprehensions whose braces format() would read as replacement fields. The live search backend and the per-episode database role provisioning are deployment-specific, so both name their module directory explicitly (TWINKLE_WORLD_SEARCH_MODULE, TWINKLE_WORLD_DB_MODULE) and raise WorldUnavailable when it is absent. Neither provider is named anywhere here. psycopg2 and the search plug-in are imported lazily, so a corpus-only run needs no database driver and no search dependency. Only envs/__init__.py is touched among existing files, to export the new names. No environment's behaviour changes unless a caller mixes one of these in. --- src/twinkle_agentic/envs/__init__.py | 10 +- src/twinkle_agentic/envs/profiles.py | 657 ++++++++++++++ src/twinkle_agentic/envs/seeds.py | 293 ++++++ src/twinkle_agentic/envs/world.py | 988 +++++++++++++++++++++ tests/twinkle_agentic/test_env_profiles.py | 367 ++++++++ tests/twinkle_agentic/test_world_tools.py | 297 +++++++ 6 files changed, 2610 insertions(+), 2 deletions(-) create mode 100644 src/twinkle_agentic/envs/profiles.py create mode 100644 src/twinkle_agentic/envs/seeds.py create mode 100644 src/twinkle_agentic/envs/world.py create mode 100644 tests/twinkle_agentic/test_env_profiles.py create mode 100644 tests/twinkle_agentic/test_world_tools.py diff --git a/src/twinkle_agentic/envs/__init__.py b/src/twinkle_agentic/envs/__init__.py index 9bd3e5732..1aaed964d 100644 --- a/src/twinkle_agentic/envs/__init__.py +++ b/src/twinkle_agentic/envs/__init__.py @@ -4,8 +4,14 @@ from .env_tool import EnvTool from .localenv import LocalEnv from .openenv import EnvPool, EnvPoolAdapter, OpenEnv, OpenEnvClient +from .profiles import (TOOL_ALIASES, ProfiledEnvMixin, ProfileRegistry, ProfileResolution, SandboxProfile, + default_profile_registry, env_names_for) +from .seeds import CLEAR_WORKSPACE, LIBRARY_CANARIES, SEED_PROGRAMS +from .world import HostWorld, WorldToolsMixin, WorldUnavailable __all__ = [ - 'AgentEnv', 'Env', 'EnvLeases', 'EnvPool', 'EnvPoolAdapter', 'EnvTool', 'LocalEnv', 'OpenEnv', 'OpenEnvClient', - 'StepResult' + 'AgentEnv', 'CLEAR_WORKSPACE', 'Env', 'EnvLeases', 'EnvPool', 'EnvPoolAdapter', 'EnvTool', 'HostWorld', + 'LIBRARY_CANARIES', 'LocalEnv', 'OpenEnv', 'OpenEnvClient', 'ProfileRegistry', 'ProfileResolution', + 'ProfiledEnvMixin', 'SEED_PROGRAMS', 'SandboxProfile', 'StepResult', 'TOOL_ALIASES', 'WorldToolsMixin', + 'WorldUnavailable', 'default_profile_registry', 'env_names_for' ] diff --git a/src/twinkle_agentic/envs/profiles.py b/src/twinkle_agentic/envs/profiles.py new file mode 100644 index 000000000..a0d8ac418 --- /dev/null +++ b/src/twinkle_agentic/envs/profiles.py @@ -0,0 +1,657 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Immutable sandbox profile manifests and least-privilege resolution.""" +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from typing import Any, Iterable, Sequence + +from .base import StepResult +from .seeds import CLEAR_WORKSPACE, LIBRARY_CANARIES, SEED_PROGRAMS + +__all__ = [ + 'SandboxProfile', + 'ProfileResolution', + 'ProfileRegistry', + 'default_profile_registry', +] + +_PROFILE_NAME = re.compile(r'^[a-z][a-z0-9-]*$') +_SESSION_KINDS = frozenset({'no_tool', 'agentenv', 'lean', 'broker', 'legacy'}) +_FORBIDDEN_CAPABILITIES = frozenset({ + 'raw_network', + 'arbitrary_command', + 'custom_image', + 'secret_access', + 'physical_action', +}) + + +def _unique(values: tuple[str, ...], field: str) -> None: + if len(values) != len(set(values)): + raise ValueError(f'{field} must not contain duplicates') + if any(not isinstance(value, str) or not value for value in values): + raise ValueError(f'{field} must contain non-empty strings') + + +def _digest(value: object) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('utf-8') + return hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class SandboxProfile: + name: str + version: int + session_kind: str + capabilities: tuple[str, ...] + task_forms: tuple[str, ...] + allowed_tools: tuple[str, ...] + verifier_kinds: tuple[str, ...] + input_kinds: tuple[str, ...] + output_kinds: tuple[str, ...] + setup_manifest: tuple[str, ...] = () + runtime_ref: str = '' + available: bool = True + unavailable_reason: str = '' + enabled_for_new_tasks: bool = True + + def __post_init__(self) -> None: + if not _PROFILE_NAME.fullmatch(self.name): + raise ValueError(f'invalid profile name {self.name!r}') + if not isinstance(self.version, int) or self.version < 1: + raise ValueError('profile version must be a positive integer') + if self.session_kind not in _SESSION_KINDS: + raise ValueError(f'unknown session kind {self.session_kind!r}') + for field in ('capabilities', 'task_forms', 'allowed_tools', 'verifier_kinds', 'input_kinds', 'output_kinds', + 'setup_manifest'): + _unique(getattr(self, field), field) + if not self.capabilities or not self.task_forms or not self.verifier_kinds: + raise ValueError('profile capabilities, task forms, and verifiers must be non-empty') + forbidden = set(self.capabilities) & _FORBIDDEN_CAPABILITIES + if forbidden: + raise ValueError(f'profiles cannot grant forbidden capabilities: {sorted(forbidden)}') + if self.session_kind == 'no_tool' and self.allowed_tools: + raise ValueError('no-tool profiles cannot advertise tools') + if self.available and self.unavailable_reason: + raise ValueError('available profiles cannot have an unavailable reason') + if not self.available and not self.unavailable_reason: + raise ValueError('unavailable profiles require a reason') + + @property + def profile_id(self) -> str: + return f'{self.name}@{self.version}' + + def manifest(self) -> dict[str, object]: + return { + 'profile_id': self.profile_id, + 'session_kind': self.session_kind, + 'capabilities': sorted(self.capabilities), + 'task_forms': sorted(self.task_forms), + 'allowed_tools': list(self.allowed_tools), + 'verifier_kinds': sorted(self.verifier_kinds), + 'input_kinds': sorted(self.input_kinds), + 'output_kinds': sorted(self.output_kinds), + 'setup_manifest': list(self.setup_manifest), + 'runtime_ref': self.runtime_ref, + 'enabled_for_new_tasks': self.enabled_for_new_tasks, + } + + @property + def digest(self) -> str: + return _digest(self.manifest()) + + +@dataclass(frozen=True) +class ProfileResolution: + requested_profile_id: str + profile: SandboxProfile + registry_digest: str + router_version: str + reason: str + + +class ProfileRegistry: + + def __init__(self, router_version: str = 'sandbox-router-v1') -> None: + if not router_version: + raise ValueError('router_version must not be empty') + self.router_version = router_version + self._profiles: dict[str, SandboxProfile] = {} + + def register(self, profile: SandboxProfile) -> None: + if profile.profile_id in self._profiles: + raise ValueError(f'profile {profile.profile_id!r} is already registered') + self._profiles[profile.profile_id] = profile + + def get(self, profile_id: str) -> SandboxProfile: + try: + return self._profiles[profile_id] + except KeyError: + raise ValueError(f'profile {profile_id!r} is not registered') from None + + def profiles(self) -> tuple[SandboxProfile, ...]: + return tuple(self._profiles[key] for key in sorted(self._profiles)) + + @property + def digest(self) -> str: + return _digest({ + 'router_version': self.router_version, + 'profiles': [profile.manifest() for profile in self.profiles()], + }) + + @property + def capabilities(self) -> frozenset[str]: + return frozenset(capability for profile in self._profiles.values() for capability in profile.capabilities) + + def resolve(self, capabilities: Iterable[str], task_form: str, requested_profile_id: str = '') -> ProfileResolution: + required = frozenset(capabilities) + if not required: + raise ValueError('an intent must request at least one capability') + forbidden = required & _FORBIDDEN_CAPABILITIES + if forbidden: + raise ValueError(f'forbidden capabilities requested: {sorted(forbidden)}') + unknown = required - self.capabilities + if unknown: + raise ValueError(f'unknown capabilities requested: {sorted(unknown)}') + + requested: SandboxProfile | None = None + if requested_profile_id: + requested = self.get(requested_profile_id) + if not required.issubset(requested.capabilities) or task_form not in requested.task_forms: + raise ValueError(f'intent would require escalation beyond requested profile ' + f'{requested_profile_id!r}') + + candidates = [ + profile for profile in self._profiles.values() if profile.available and profile.enabled_for_new_tasks + and required.issubset(profile.capabilities) and task_form in profile.task_forms + ] + if not candidates: + unavailable = [ + profile.profile_id for profile in self._profiles.values() + if required.issubset(profile.capabilities) and task_form in profile.task_forms and not profile.available + ] + if unavailable: + raise ValueError(f'sandbox unavailable for intent: {sorted(unavailable)}') + raise ValueError(f'no profile supports task form {task_form!r} with capabilities ' + f'{sorted(required)}') + + def priority(profile: SandboxProfile) -> tuple: + requested_penalty = int(requested is not None and profile != requested) + return (len(set(profile.capabilities) - required), len(profile.allowed_tools), requested_penalty, + profile.profile_id) + + resolved = min(candidates, key=priority) + if requested is None: + reason = 'least-privilege profile satisfying the declared task form and capabilities' + elif resolved == requested: + reason = 'requested profile is the least-privilege valid profile' + else: + reason = f'downgraded from {requested.profile_id} to the least-privilege valid profile' + return ProfileResolution( + requested_profile_id=requested_profile_id, + profile=resolved, + registry_digest=self.digest, + router_version=self.router_version, + reason=reason, + ) + + +def default_profile_registry() -> ProfileRegistry: + registry = ProfileRegistry() + registry.register( + SandboxProfile( + name='knowledge-answer', + version=1, + session_kind='no_tool', + capabilities=('objective_answer', ), + task_forms=('knowledge_answer', ), + allowed_tools=(), + verifier_kinds=('answer_equals', 'answer_one_of', 'answer_number'), + input_kinds=('given_text', ), + output_kinds=('answer', ), + runtime_ref='host-no-tool-v1', + )) + registry.register( + SandboxProfile( + name='python-compute', + version=1, + session_kind='agentenv', + capabilities=('numeric_compute', 'symbolic_compute', 'data_compute'), + task_forms=('numeric_problem', 'symbolic_problem', 'given_data_analysis'), + allowed_tools=('python_executor', ), + verifier_kinds=('answer_equals', 'answer_one_of', 'answer_number', 'file_json', 'python'), + input_kinds=('given_text', 'data_fixture'), + output_kinds=('answer', 'workspace'), + setup_manifest=('clear_workspace', 'python-numpy-sympy-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + registry.register( + SandboxProfile( + # agentenv, not a new `lean` session kind: the compiler runs inside the + # sandbox through the same shell the other profiles use, so there is + # nothing for a new kind to enable. `session_kind='lean'` was rejected by + # open_profile_session outright, which made the profile unreachable + # regardless of whether Lean itself worked. + name='lean-proof', + version=1, + session_kind='agentenv', + capabilities=('formal_proof', ), + task_forms=('formal_proof', ), + # No `lean_check` tool. Compiling is `lake env lean` and the verifier runs + # it directly, so a separate tool would be a second way to do the same + # thing -- and a tool the model can call is a tool the model can call + # instead of thinking. `shell_executor` is what the profile needs. + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'edit_file', 'glob', 'grep'), + verifier_kinds=('lean', 'file_contains', 'file_exists'), + input_kinds=('given_text', ), + output_kinds=('proof', 'workspace'), + setup_manifest=('clear_workspace', 'python-numpy-sympy-canary'), + runtime_ref='lean4-mathlib-pinned', + )) + registry.register( + SandboxProfile( + name='code-python', + version=1, + session_kind='agentenv', + capabilities=('python_code', 'workspace_files'), + task_forms=('python_implementation', ), + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'edit_file', 'glob', 'grep'), + verifier_kinds=('file_exists', 'file_absent', 'file_contains', 'file_equals', 'file_json', 'python', + 'hidden_python'), + input_kinds=('given_text', 'repository_fixture'), + output_kinds=('workspace', ), + setup_manifest=('clear_workspace', 'python-test-runner-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + # ------------------------------------------------------------------ + # Seeded profiles. Each one starts the agent from a real state instead of an + # empty directory, so the task can require reading before acting. The setup + # manifest names the seed program that builds it (seeds.py) and then + # the canary that proves the state is usable. + # ------------------------------------------------------------------ + registry.register( + SandboxProfile( + name='sql-analytics', + version=1, + session_kind='agentenv', + capabilities=('database_query', 'data_compute'), + task_forms=('database_analysis', ), + # No db_query tool: the sandbox already has sqlite3 through python_executor + # and the shell, and adding a bespoke tool would need ms-agent changes for + # no capability the agent lacks. The seed and the sql_query VERIFIER are + # what change here -- the tool surface deliberately does not. + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep'), + verifier_kinds=('sql_query', 'file_exists', 'file_json', 'file_contains'), + input_kinds=('given_text', 'data_fixture'), + output_kinds=('answer', 'workspace'), + setup_manifest=('clear_workspace', 'seed-sql-analytics', 'python-numpy-sympy-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + registry.register( + SandboxProfile( + name='data-wrangling', + version=1, + session_kind='agentenv', + capabilities=('data_compute', 'text_processing'), + task_forms=('data_cleaning', ), + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep'), + verifier_kinds=('file_json', 'file_contains', 'file_equals', 'python'), + input_kinds=('given_text', 'data_fixture'), + output_kinds=('answer', 'workspace'), + setup_manifest=('clear_workspace', 'seed-csv-dirty', 'python-numpy-sympy-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + registry.register( + SandboxProfile( + name='docs-retrieval', + version=1, + session_kind='agentenv', + capabilities=('document_retrieval', 'citation'), + task_forms=('retrieval_qa', ), + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep'), + verifier_kinds=('answer_contains', 'answer_equals', 'answer_regex', 'file_contains', 'file_json'), + input_kinds=('given_text', 'data_fixture'), + output_kinds=('answer', 'citations'), + setup_manifest=('clear_workspace', 'seed-docs-corpus', 'python-test-runner-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + registry.register( + SandboxProfile( + name='repo-patch', + version=1, + session_kind='agentenv', + capabilities=('python_code', 'workspace_files', 'test_execution'), + task_forms=('bug_fix', ), + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'edit_file', 'glob', 'grep'), + verifier_kinds=('shell', 'python', 'hidden_python', 'file_absent', 'file_contains', 'file_exists'), + input_kinds=('given_text', 'repository_fixture'), + output_kinds=('workspace', ), + setup_manifest=('clear_workspace', 'seed-broken-repo', 'python-test-runner-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + registry.register( + SandboxProfile( + name='log-forensics', + version=1, + session_kind='agentenv', + capabilities=('log_analysis', 'shell_forensics'), + task_forms=('incident_analysis', ), + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep'), + verifier_kinds=('file_json', 'file_contains', 'answer_equals', 'answer_number', 'shell'), + input_kinds=('given_text', 'data_fixture'), + output_kinds=('answer', 'workspace'), + setup_manifest=('clear_workspace', 'seed-log-corpus', 'python-numpy-sympy-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + registry.register( + SandboxProfile( + name='world-forensics', + version=1, + session_kind='agentenv', + # The seeded profile that also reads the SHARED world. The distinction + # matters: sql-analytics copies a database into the workspace, so the model + # reads a private file. This one reads a dataset it never receives -- the + # call is answered on the training host, so nothing is copied in and the + # sandbox still has no network. + # + # That is what makes the answer unfakeable. A seeded file can be opened, + # edited, and reasoned about as local material; a shared one can only be + # queried, and what comes back is what the world holds. + capabilities=('database_query', 'document_retrieval', 'log_analysis'), + task_forms=('incident_analysis', 'cross_source_reconciliation'), + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep', 'world_sql', + 'world_schema', 'world_doc'), + verifier_kinds=('file_json', 'file_contains', 'answer_equals', 'answer_contains', 'answer_number', 'shell'), + input_kinds=('given_text', ), + output_kinds=('answer', 'workspace'), + # No seed: the environment IS the shared world, and copying it into the + # workspace would defeat the point. clear_workspace stays so the episode + # starts from a known-empty directory either way. + setup_manifest=('clear_workspace', 'python-numpy-sympy-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + registry.register( + SandboxProfile( + name='db-analyst', + version=1, + session_kind='agentenv', + # Reads a REAL PostgreSQL service over the network, with an account the + # service issues to this episode alone. Different from world-forensics, + # which reads a SQLite file that ships with the run: here the model has to + # connect to infrastructure and stay inside the grant it was handed, and + # the grant is enforced by PostgreSQL rather than by the sandbox. + capabilities=('database_query', 'database_schema'), + task_forms=('database_analysis', 'grant_aware_query'), + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep', 'db_query', + 'db_schema'), + verifier_kinds=('sql_query', 'file_json', 'file_contains', 'answer_number', 'answer_equals', 'shell'), + input_kinds=('given_text', ), + output_kinds=('answer', 'workspace'), + # No seed: the environment IS the database service, and copying it in + # would defeat the point of it being shared. + setup_manifest=('clear_workspace', 'python-numpy-sympy-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + registry.register( + SandboxProfile( + name='web-research', + version=1, + session_kind='agentenv', + # Now agentenv rather than broker: the search tools are answered on the + # training host, exactly like the world tools, so there is no separate + # session kind to enable and no sandbox network to open. The broker kind + # was never implemented (`open_profile_session` rejects it outright), so + # the profile used to be unavailable for a reason that had nothing to do + # with search providers -- it was unreachable by construction. + capabilities=('web_search', 'web_fetch', 'citation'), + task_forms=('web_research', 'source_verification'), + # Both search tools, not just `web_search`. An earlier design served + # them from inside the sandbox, where only one existed, so `fetch_page` + # was dropped from this list and no profile could reach it. They are + # answered on the training host now (world.SEARCH_TOOL_SCHEMAS), so + # both are advertised and both are allowed. + # + # `fetch_page` is the half that makes `web_receipt` worth scoring: a + # search result carries an excerpt, and a policy can quote an excerpt + # it was shown without ever reading the page. Requiring the source to + # have been fetched is what separates "cited what it read" from + # "cited what it was handed". + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep', 'web_search', + 'fetch_page'), + # web_receipt is the one that matters here: it checks a cited url against + # what the search tools actually served, which is what `task_contract` + # already demands of this profile and what nothing implemented until now. + verifier_kinds=('answer_equals', 'answer_contains', 'answer_number', 'file_json', 'file_contains', 'python', + 'web_receipt'), + input_kinds=('given_text', ), + output_kinds=('answer', 'citations', 'workspace'), + # No quota or domain policy steps: those belonged to the broker design. + # The index is frozen and read-only, which is a stronger guarantee than a + # domain allowlist -- there is nothing else to reach. + setup_manifest=('clear_workspace', 'python-numpy-sympy-canary'), + runtime_ref='twinkle-rsi-msagent', + )) + registry.register( + SandboxProfile( + name='legacy-agentic', + version=1, + session_kind='legacy', + capabilities=('legacy_agentic', ), + task_forms=('legacy_agentic', ), + allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'edit_file', 'glob', 'grep', + 'todo'), + verifier_kinds=('legacy_check_script', ), + input_kinds=('legacy_task', ), + output_kinds=('workspace', 'answer'), + # No manifest steps. This profile describes the environment every task + # ran in before profiles existed, and resetting it is what the env's + # own clear() already does -- naming a `legacy-reset` step here would + # be a step nothing resolves, which clear() refuses at switch time. + setup_manifest=(), + runtime_ref='twinkle-rsi-msagent', + enabled_for_new_tasks=False, + )) + return registry + + +# A profile names tools in the vocabulary of the agent runtime that served them, +# which is finer than what an Env declares: `shell_executor`, `python_executor`, +# `glob` and `grep` are four names there and one `run_command` here. The table is +# data rather than logic so the coarseness is visible in one place instead of +# being spread across conditionals. +# +# The consequence is worth stating plainly: on an Env whose only command tool is +# `run_command`, a profile that allows `glob` allows a shell. Least privilege at +# the granularity these profiles were written for needs an environment that +# declares the finer tools; what this mixin guarantees on a coarse one is that +# nothing outside the profile is reachable at all. +# The one placeholder a seed program may contain. Kept as a literal so the +# substitution below is exact and leaves every other brace alone. +_WORKSPACE_PLACEHOLDER = '{workspace!r}' + +TOOL_ALIASES: dict[str, tuple[str, ...]] = { + 'shell_executor': ('run_command', ), + 'python_executor': ('run_command', ), + 'glob': ('run_command', ), + 'grep': ('run_command', ), + 'edit_file': ('write_file', ), + 'read_file': ('read_file', ), + 'write_file': ('write_file', ), + 'run_command': ('run_command', ), +} + + +def env_names_for(profile: SandboxProfile) -> frozenset: + """The environment tool names a profile permits, after aliasing.""" + resolved = set() + for name in profile.allowed_tools: + resolved.update(TOOL_ALIASES.get(name, (name, ))) + return frozenset(resolved) + + +class ProfiledEnvMixin: + """Restrict an environment to what a profile permits, and seed its workspace. + + Mix in front of any :class:`~twinkle_agentic.envs.base.Env`, and ahead of + :class:`~twinkle_agentic.envs.world.WorldToolsMixin` when both are used, so + that the world's tools are filtered by the profile too:: + + class Slot(ProfiledEnvMixin, WorldToolsMixin, LocalEnv): + pass + + Three things it does: + + * :meth:`tools` advertises only what the active profile allows, so a policy + is never offered a tool it would be refused. + * :meth:`step` and :meth:`step_batch` refuse anything else, because + advertising is not enforcement -- a policy can name a tool it was not + shown. + * :meth:`switch_profile` moves the environment onto another profile and runs + that profile's setup manifest, which is what puts the fixture in the + workspace before the episode starts. + + With no profile set the environment behaves exactly as it did before: every + declared tool is advertised and nothing is refused. That is what makes this + safe to mix into an environment whose caller has no profiles. + """ + + def __init__(self, + *args: Any, + profile: SandboxProfile | None = None, + profiles: ProfileRegistry | None = None, + **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._profiles = profiles if profiles is not None else default_profile_registry() + self._profile = profile + self._manifest_results: list[tuple[str, int, str]] = [] + + @property + def profile(self) -> SandboxProfile | None: + """The active profile, or None for an unrestricted environment.""" + return self._profile + + @property + def profile_id(self) -> str: + return self._profile.profile_id if self._profile is not None else '' + + @property + def manifest_results(self) -> list[tuple[str, int, str]]: + """``(step, exit_code, output)`` for the last manifest that ran.""" + return list(self._manifest_results) + + def switch_profile(self, profile_id: str) -> SandboxProfile: + """Move onto ``profile_id`` and run its setup manifest. + + Raises rather than degrading: an environment left on the wrong profile + produces tasks whose fixture is missing, and every one of them reads as a + hard task rather than as a broken slot. + """ + profile = self._profiles.get(profile_id) + if not profile.available: + raise ValueError(f'profile {profile_id!r} is not available: {profile.unavailable_reason}') + if not profile.enabled_for_new_tasks: + raise ValueError(f'profile {profile_id!r} is retired and cannot take new tasks') + self._profile = profile + self.clear() + return profile + + def _allowed(self) -> frozenset | None: + if self._profile is None: + return None + return env_names_for(self._profile) + + def _refusal(self, tool_name: str) -> str | None: + allowed = self._allowed() + if allowed is None: + return None + short = (tool_name or '').rsplit('---', 1)[-1] + if short in allowed: + return None + return (f'Error: {tool_name!r} is not available in this environment. ' + f'Allowed tools: {sorted(allowed)}.') + + def tools(self) -> list[dict[str, Any]]: + declared = list(super().tools()) + allowed = self._allowed() + if allowed is None: + return declared + return [t for t in declared if (t.get('function') or {}).get('name') in allowed] + + def step(self, tool_name: str, arguments: dict[str, Any] = None) -> StepResult: + refusal = self._refusal(tool_name) + if refusal is not None: + return StepResult(observation=refusal, reward=0.0, done=False) + return super().step(tool_name, arguments) + + def step_batch(self, calls: Sequence[tuple[str, dict[str, Any]]]) -> list[StepResult]: + allowed = self._allowed() + if allowed is None: + return super().step_batch(calls) + # Refusals are answered here and only the permitted calls are forwarded, + # positionally reassembled, so one bad call in a turn does not cost the + # whole batch a round trip. + kept_at: list[int] = [] + out: list[StepResult | None] = [None] * len(calls) + for index, (name, _) in enumerate(calls): + refusal = self._refusal(name) + if refusal is None: + kept_at.append(index) + else: + out[index] = StepResult(observation=refusal, reward=0.0, done=False) + if kept_at: + forwarded = super().step_batch([calls[i] for i in kept_at]) + if len(forwarded) != len(kept_at): + raise RuntimeError(f'step_batch returned {len(forwarded)} results for ' + f'{len(kept_at)} forwarded call(s)') + for index, result in zip(kept_at, forwarded): + out[index] = result + return [r if r is not None else StepResult(observation='Error: no result') for r in out] + + def clear(self) -> None: + """Empty the workspace, then run the active profile's setup manifest. + + Order matters and is the reason this overrides rather than supplements: + a seed written before the clear is a seed deleted by it. + """ + super().clear() + if self._profile is None: + return + workspace = self.workspace + if not workspace: + raise RuntimeError(f'profile {self._profile.profile_id} has a setup manifest but this ' + 'environment has no workspace to seed into') + self._manifest_results = [] + for step in self._profile.setup_manifest: + source = self._manifest_step(step) + if source is None: + # Raise rather than record and carry on: a manifest step that + # does not resolve is a typo in the profile, and continuing + # would leave the environment missing the fixture every task + # built on it assumes. Each of those then reads as an unsolvable + # task instead of as a broken profile. + raise RuntimeError(f'profile {self._profile.profile_id} names an unknown setup ' + f'step {step!r}; known steps are clear_workspace, ' + f'{sorted(SEED_PROGRAMS)} and {sorted(LIBRARY_CANARIES)}') + # The seed programs are templates, not source: each names the + # workspace it is to build in. Substituted by exact replacement + # rather than str.format, because the programs are real python and + # contain set and dict comprehensions whose braces format() would + # read as replacement fields. + exit_code, output = self.run_script(source.replace(_WORKSPACE_PLACEHOLDER, repr(workspace)), 'python') + self._manifest_results.append((step, exit_code, output)) + if exit_code != 0: + raise RuntimeError(f'profile {self._profile.profile_id} setup step {step!r} ' + f'failed with exit {exit_code}: {output[-300:]}') + + @staticmethod + def _manifest_step(step: str) -> str | None: + if step == 'clear_workspace': + return CLEAR_WORKSPACE + if step in SEED_PROGRAMS: + return SEED_PROGRAMS[step] + if step in LIBRARY_CANARIES: + return LIBRARY_CANARIES[step] + return None diff --git a/src/twinkle_agentic/envs/seeds.py b/src/twinkle_agentic/envs/seeds.py new file mode 100644 index 000000000..506f2be72 --- /dev/null +++ b/src/twinkle_agentic/envs/seeds.py @@ -0,0 +1,293 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Initial state for sandbox profiles -- the programs that seed a workspace. + +Why this exists +--------------- +Every task this loop used to generate started from an EMPTY directory. The solver +prompt says so out loud ("Your working directory starts COMPLETELY EMPTY. Every +file the task mentions -- including files it describes as inputs -- does not exist +yet"), which means an empty-workspace task asks the model to invent its own inputs +and then compute over them. Grading that measures self-consistency as much as +capability: the numbers were the model's own choice, so being right about them is +a weaker claim than being right about data someone else fixed. + +These programs give a profile a real starting state instead. They run through the +sandbox's own python tool after ``clear_workspace`` and before the agent sees +anything, so the agent must READ a schema, a file, or a repo before it can act. + +Two rules every seed here follows +--------------------------------- +1. **Byte-deterministic.** The same seed must produce identical bytes, because two + rollouts of one task are graded against the same input. No ``random``, no clock, + no locale-dependent formatting, no dict-iteration-order dependence. +2. **Idempotent.** Re-running replaces rather than appends, so a resumed or retried + episode cannot inherit half of a previous seed. + +Neither of these is the same as "easy". ``seed-sql-analytics`` plants a NULL region +and a duplicate order; ``seed-csv-dirty`` writes a BOM and a quoted comma; +``seed-docs-corpus`` includes a document that states the *superseded* answer. The +difficulty is in the data, not in a missing tool. +""" + +__all__ = ['CLEAR_WORKSPACE', 'LIBRARY_CANARIES', 'SEED_PROGRAMS'] + +# Prove a library a profile depends on is actually present in the image. Separate +# from the seeds: a seed BUILDS state, a canary only asserts something exists, and +# the two fail for different reasons -- a canary failing means the image is wrong, +# a seed failing usually means the seed is. +LIBRARY_CANARIES = { + 'python-numpy-sympy-canary': 'import numpy\nimport sympy\nprint("profile canary ok")', + 'python-test-runner-canary': 'import unittest\nprint("profile canary ok")', +} + +# Emptied through the python tool rather than `rm -rf`: ms-agent's safety policy +# rejects `rm -rf`, and it rejects globs in write operations, which rules out +# `find -delete`. The assert is what makes a future policy change surface as a +# failed reset instead of one episode inheriting another's workspace. +# +# Lives here, not in sandbox.py, because sandbox.py already imports from episode.py +# and episode.py needs this constant -- keeping it beside the seeds is what stops +# that pair from becoming a cycle. +CLEAR_WORKSPACE = ''' +import os, shutil +root = {workspace!r} +os.makedirs(root, exist_ok=True) +for name in os.listdir(root): + path = os.path.join(root, name) + if os.path.isdir(path) and not os.path.islink(path): + shutil.rmtree(path, ignore_errors=True) + else: + os.remove(path) +leftover = os.listdir(root) +assert not leftover, 'workspace not empty after clear: %r' % (leftover,) +''' + +# The workspace path is substituted by sandbox.py, not by this module: the seed +# only knows the directory it is given. +SEED_SQL_ANALYTICS = r''' +import os, sqlite3 +root = {workspace!r} +os.makedirs(root, exist_ok=True) +path = os.path.join(root, 'shop.db') +if os.path.exists(path): + os.remove(path) +conn = sqlite3.connect(path) +conn.executescript(""" +CREATE TABLE customers ( + customer_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + city TEXT NOT NULL, + region TEXT, + signup_date TEXT NOT NULL +); +CREATE TABLE products ( + product_id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL, + unit_price REAL NOT NULL +); +CREATE TABLE orders ( + order_id INTEGER PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES customers(customer_id), + product_id INTEGER NOT NULL REFERENCES products(product_id), + quantity INTEGER NOT NULL, + order_date TEXT NOT NULL, + status TEXT NOT NULL +); +CREATE INDEX idx_orders_date ON orders(order_date); +CREATE INDEX idx_orders_customer ON orders(customer_id); +""") +conn.executemany('INSERT INTO customers VALUES (?,?,?,?,?)', [ + (1,'Ada Lovelace','London','EU','2019-03-02'), + (2,'Grace Hopper','Arlington','NA','2019-07-14'), + (3,'Edsger Dijkstra','Rotterdam','EU','2020-01-05'), + (4,'Barbara Liskov','Boston','NA','2020-06-30'), + (5,'Alan Turing','London','EU','2021-02-11'), + (6,'Radia Perlman','Seattle','NA','2021-09-23'), + (7,'Leslie Lamport','New York','NA','2022-04-17'), + (8,'Karen Sparck Jones','Cambridge',None,'2022-11-08'), +]) +conn.executemany('INSERT INTO products VALUES (?,?,?,?)', [ + (10,'Blue Widget','widgets',12.50), + (11,'Red Widget','widgets',12.50), + (12,'Green Gadget','gadgets',89.99), + (13,'Blue Gadget','gadgets',94.00), + (14,'Service Plan','services',200.00), +]) +conn.executemany('INSERT INTO orders VALUES (?,?,?,?,?,?)', [ + (1001,1,10,3,'2023-01-15','shipped'), + (1002,1,12,1,'2023-01-22','shipped'), + (1003,2,10,10,'2023-02-03','shipped'), + (1004,3,11,2,'2023-02-14','cancelled'), + (1005,3,12,1,'2023-03-01','shipped'), + (1006,4,14,2,'2023-03-12','shipped'), + (1007,5,13,1,'2023-04-02','pending'), + (1008,6,10,7,'2023-04-19','shipped'), + (1009,7,12,3,'2023-05-05','shipped'), + (1010,8,11,1,'2023-05-21','shipped'), + (1011,1,10,3,'2023-01-15','shipped'), +]) +conn.commit() +tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} +assert tables == {'customers','products','orders'}, tables +assert conn.execute('SELECT COUNT(*) FROM orders').fetchone()[0] == 11 +conn.close() +print('seed-sql-analytics ok') +''' + +SEED_CSV_DIRTY = r''' +import csv, io, os +root = {workspace!r} +os.makedirs(root, exist_ok=True) +rows = [ + ['reading_id','station','timestamp','temperature_c','humidity_pct'], + ['1','Alpha','2023-01-01T00:00:00Z','3.5','81'], + ['2','Alpha','2023-01-01T01:00:00Z','3.1','83'], + ['3','Bravo','2023-01-01T00:00:00Z','-2.0','90'], + ['4','Bravo, North','2023-01-01T01:00:00Z','-2.4',''], + ['5','Charlie','2023-01-01T00:00:00Z','11.25','64'], + ['5','Charlie','2023-01-01T00:00:00Z','11.25','64'], + ['6','alpha','2023-01-01T02:00:00Z','2.9','85'], +] +buf = io.StringIO() +writer = csv.writer(buf, lineterminator='\n') +for row in rows: + writer.writerow(row) +# utf-8-sig writes a BOM, which a plain utf-8 read leaves on the first header +# cell ('\ufeffreading_id'). That is the defect most real exports arrive with. +with open(os.path.join(root, 'readings.csv'), 'w', encoding='utf-8-sig', newline='') as fh: + fh.write(buf.getvalue()) +with open(os.path.join(root, 'stations.txt'), 'w', encoding='utf-8', newline='') as fh: + fh.write('Alpha\nBravo, North\nCharlie\n') +with open(os.path.join(root, 'readings.csv'), 'rb') as fh: + head = fh.read(3) +assert head == b'\xef\xbb\xbf', head +print('seed-csv-dirty ok') +''' + +SEED_DOCS_CORPUS = r''' +import json, os +root = {workspace!r} +docs = os.path.join(root, 'corpus') +os.makedirs(docs, exist_ok=True) +files = { + 'handbook.md': ( + '# Operations Handbook\n\n' + '## Retry policy\n\n' + 'The ingest worker retries a failed batch three times. The delay before the\n' + 'first retry is 5 seconds, and each subsequent delay doubles it.\n\n' + '## Escalation\n\n' + 'After the third failure the batch is parked and an alert is raised.\n'), + 'release-notes.md': ( + '# Release notes\n\n' + '## 4.2.0\n\n' + 'The ingest worker retry limit was raised from two to three. This is the only\n' + 'change to retry behaviour in this release.\n'), + 'decoy-faq.md': ( + '# FAQ\n\n' + '## How many times does the ingest worker retry?\n\n' + 'Historically this was four, and older runbooks still say so. Check the\n' + 'release notes for the current value.\n'), +} +for name in sorted(files): + with open(os.path.join(docs, name), 'w', encoding='utf-8', newline='') as fh: + fh.write(files[name]) +index = { + 'question': ('How many times does the ingest worker retry a failed batch, and ' + 'what is the delay before the first retry?'), + 'answer_document': 'handbook.md', + 'superseded_document': 'decoy-faq.md', + 'note': 'decoy-faq.md states a superseded value; a correct answer cites handbook.md', +} +with open(os.path.join(root, 'question.json'), 'w', encoding='utf-8', newline='') as fh: + json.dump(index, fh, indent=2, sort_keys=True) + fh.write('\n') +assert sorted(os.listdir(docs)) == ['decoy-faq.md', 'handbook.md', 'release-notes.md'] +print('seed-docs-corpus ok') +''' + +SEED_BROKEN_REPO = r''' +import os, textwrap +root = {workspace!r} +src = os.path.join(root, 'src') +tests = os.path.join(root, 'tests') +os.makedirs(src, exist_ok=True) +os.makedirs(tests, exist_ok=True) +for pkg in (src, tests): + open(os.path.join(pkg, '__init__.py'), 'w').close() +module = textwrap.dedent(""" + def merge_ranges(ranges): + # Merge overlapping [start, end) intervals, sorted ascending. + # Touching intervals (end == next start) merge. + if not ranges: + return [] + ordered = sorted(ranges) + out = [list(ordered[0])] + for start, end in ordered[1:]: + if start < out[-1][1]: + out[-1][1] = max(out[-1][1], end) + else: + out.append([start, end]) + return [tuple(pair) for pair in out] +""").lstrip() +with open(os.path.join(src, 'ranges.py'), 'w', encoding='utf-8', newline='') as fh: + fh.write(module) +test = textwrap.dedent(""" + import unittest + from src.ranges import merge_ranges + + class TestMergeRanges(unittest.TestCase): + def test_empty(self): + self.assertEqual(merge_ranges([]), []) + def test_single(self): + self.assertEqual(merge_ranges([(1, 5)]), [(1, 5)]) + def test_touching_merges(self): + self.assertEqual(merge_ranges([(1, 5), (5, 9)]), [(1, 9)]) + def test_disjoint_kept(self): + self.assertEqual(merge_ranges([(1, 5), (7, 9)]), [(1, 5), (7, 9)]) + def test_unsorted_input(self): + self.assertEqual(merge_ranges([(10, 12), (1, 3)]), [(1, 3), (10, 12)]) + def test_contained(self): + self.assertEqual(merge_ranges([(1, 10), (2, 3)]), [(1, 10)]) + + if __name__ == '__main__': + unittest.main() +""").lstrip() +with open(os.path.join(tests, 'test_ranges.py'), 'w', encoding='utf-8', newline='') as fh: + fh.write(test) +print('seed-broken-repo ok') +''' + +SEED_LOG_CORPUS = r''' +import os +root = {workspace!r} +os.makedirs(root, exist_ok=True) +app = [ + '2023-06-01T10:00:00Z INFO req=7f2a started route=/ingest', + '2023-06-01T10:00:01Z INFO req=7f2a wrote 120 rows', + '2023-06-01T10:00:02Z ERROR req=7f2a failed stage=commit error="disk quota exceeded"', + '2023-06-01T10:00:03Z INFO req=91bc started route=/ingest', + '2023-06-01T10:00:09Z INFO req=91bc wrote 90 rows', + '2023-06-01T10:00:10Z INFO req=91bc finished ok', +] +proxy = [ + '2023-06-01T10:00:00Z 200 POST /ingest req=7f2a 1204ms', + '2023-06-01T10:00:03Z 200 POST /ingest req=91bc 890ms', + '2023-06-01T10:00:04Z 500 POST /ingest req=4cd1', +] +with open(os.path.join(root, 'app.log'), 'w', encoding='utf-8', newline='') as fh: + fh.write('\n'.join(app) + '\n') +with open(os.path.join(root, 'proxy.log'), 'w', encoding='utf-8', newline='') as fh: + # The final line is truncated mid-timestamp, the way a log looks when the + # writer died. A parser that assumes well-formed lines will raise on it. + fh.write('\n'.join(proxy) + '\n2023-06-01T10:00:') +print('seed-log-corpus ok') +''' + +SEED_PROGRAMS = { + 'seed-sql-analytics': SEED_SQL_ANALYTICS, + 'seed-csv-dirty': SEED_CSV_DIRTY, + 'seed-docs-corpus': SEED_DOCS_CORPUS, + 'seed-broken-repo': SEED_BROKEN_REPO, + 'seed-log-corpus': SEED_LOG_CORPUS, +} diff --git a/src/twinkle_agentic/envs/world.py b/src/twinkle_agentic/envs/world.py new file mode 100644 index 000000000..fb4dca963 --- /dev/null +++ b/src/twinkle_agentic/envs/world.py @@ -0,0 +1,988 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""A read-only world served from the training host, not from the sandbox. + +An agentic RSI run needs tasks whose answers the policy cannot invent. A task +that says "report the failure rate in the ops database" is only a real task if +the database exists and the policy has to read it; otherwise the policy writes +plausible numbers and a check that compares strings passes either way. + +This module is that world: a SQLite database, a document set, and a search +corpus, exposed as tools the model calls exactly like the sandbox's own. + +Why host-side +------------- +A tool call already round-trips. :meth:`Env.step_batch` hands the call to the +environment and brings the answer back, so the training host sees every call +before and after the sandbox does. A tool that lives on the host therefore needs +no network path *into* the sandbox, which is what makes this the right place: + +* no egress from the sandbox. An environment configured with no network keeps + that property: a model that could reach the host's network could scan it, a + model that can only call a named tool cannot; +* no reverse tunnel and no new listening port to defend; +* the data never enters the sandbox, so it cannot be edited, copied out of + scope, or left behind in a snapshot. + +The cost is one extra hop, and it is not on the hot path: a tool call is already +a sandbox round trip, and this one answers from a local read-only handle. + +What the model sees +------------------- +Seven tools, in three groups, advertised like any other: + +``world_sql`` / ``world_schema`` / ``world_doc`` + read-only SQL over ``ops.db``, its schema, and the document set. +``web_search`` / ``fetch_page`` + retrieval over a frozen corpus, or a live engine if one is configured. +``db_query`` / ``db_schema`` + a real SQL server, on a per-episode role rather than a shared login. + +Every retrieval answer is recorded as a *receipt* (:attr:`HostWorld.receipts`). +That is what makes a citation checkable: ``web_receipt`` in +:mod:`twinkle_agentic.verifier.result_check` can ask "was this url actually +served to this episode?" and get an answer from what happened rather than from +what the policy wrote down. + +Configuration is by environment variable, because a library does not guess where +its data lives: + +=============================== ============================================ +``TWINKLE_WORLD_ROOT`` directory holding ``manifest.json``, ``ops.db`` + and ``docs/``. Required; without it + :attr:`HostWorld.available` is False. +``TWINKLE_WORLD_SEARCH_ROOT`` directory holding ``corpus.json`` and its + ``manifest.json``. Optional; without it the two + search tools report themselves unavailable. +``TWINKLE_WORLD_SEARCH_BACKEND````corpus`` (default) or ``live``. +``TWINKLE_WORLD_SEARCH_MODULE`` directory holding ``search_backend.py``. + Required when the backend is ``live``. +``TWINKLE_WORLD_DB_MODULE`` directory holding the ``provision`` module that + mints and drops per-episode database roles. + Required only when ``db_enabled``. +``TWINKLE_WORLD_DB_HOST`` database host, default ``127.0.0.1``. +``TWINKLE_WORLD_DB_PORT`` database port, default ``5432``. +``TWINKLE_WORLD_DB_LEASE`` role lease seconds, default ``21600``. +=============================== ============================================ + +A live search backend is a plug-in rather than a dependency: the world imports +``search_backend`` from ``TWINKLE_WORLD_SEARCH_MODULE`` and calls +``Backend().web_search(query, num_results, freshness)`` and +``Backend().fetch_page(url, query)``, both returning the observation string the +tool shows the model. Which provider sits behind that class is the deployment's +choice, and nothing in this repository names one. +""" +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import sys +from pathlib import Path +from typing import Any, Sequence + +from twinkle.data_format.message import Tool as ToolInfo +from .base import StepResult + +MAX_ROWS = 5000 + +# Deliberately the same crude overlap the corpus builder uses. It is a stand-in +# for a ranker, not a ranker: a good one would hide exactly the failures the +# corpus exists to reproduce. +_WORD = re.compile(r'[a-z0-9]+') +_STOP = { + 'the', 'a', 'an', 'of', 'in', 'is', 'are', 'and', 'or', 'to', 'for', 'on', 'by', 'with', 'as', 'at', 'it', 'its', + 'that', 'this', 'be', 'from' +} + + +def _terms(text: str) -> set: + return {word for word in _WORD.findall((text or '').lower()) if word not in _STOP and len(word) > 2} + + +# The schemas are the contract the model is shown. Written here rather than read +# from the world service because the host side must be able to advertise a tool +# before the service is up -- and a tool that disappears from the prompt when the +# service is down would make an outage look like a different task. +TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + 'type': 'function', + 'function': { + 'name': + 'world_sql', + 'description': ('Run a read-only SQL query against the shared operations database. ' + 'Tables: regions, sites, machines, incidents, procedures. The ' + 'database is shared and read-only; writes are rejected. Use this to ' + 'obtain the real values instead of assuming them.'), + 'parameters': { + 'type': 'object', + 'properties': { + 'query': { + 'type': 'string', + 'description': 'A single SELECT statement.' + }, + 'params': { + 'type': 'array', + 'description': 'Optional bound parameters for ? placeholders.', + 'items': {} + }, + }, + 'required': ['query'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': + 'world_schema', + 'description': ('Describe the shared database: call with no name to list the tables, ' + 'or with a table name to get its columns and types. Cheaper and more ' + 'reliable than guessing column names.'), + 'parameters': { + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + 'description': 'Optional table name.' + } + }, + 'required': [], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': + 'world_doc', + 'description': ('Read one document from the shared procedures corpus. Call with no ' + 'name to list what is available. The documents state the rules the ' + 'database alone does not, such as how an incident should have been ' + 'classified.'), + 'parameters': { + 'type': 'object', + 'properties': { + 'name': { + 'type': 'string', + 'description': 'Document name, e.g. disk.md.' + } + }, + 'required': [], + }, + }, + }, +] + +# Search tools. Named and shaped after ms-agent's own web_search/fetch_page so +# swapping the frozen corpus for a live engine is a change of one implementation +# and nothing else -- no task, no profile, no schema moves. +# +# The ranking is not trustworthy and the description says so. An engine that +# returned only good results would train a policy that believes the first hit; +# a task can then be written to require verification, and the policy meets the +# unreliability where it belongs -- in the episode, not in production. +SEARCH_TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + 'type': 'function', + 'function': { + 'name': + 'web_search', + 'description': ('Search the shared document index. Returns ranked results with a url, ' + 'title, publication date and short quoted excerpts. The ranking is NOT ' + 'reliable: a result can be on-topic and out of date, and the top hit is ' + 'sometimes superseded. Fetch the url before trusting its content.'), + 'parameters': { + 'type': 'object', + 'properties': { + 'query': { + 'type': 'string', + 'description': 'Search query.' + }, + 'num_results': { + 'type': 'integer', + 'description': 'How many results to return (1-25).' + }, + 'freshness': { + 'type': 'string', + 'description': "Optional 'YYYY-MM-DD'; results on or after this date first." + }, + }, + 'required': ['query'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': + 'fetch_page', + 'description': ('Fetch the full text of a url returned by web_search. Use this on every ' + 'result whose content matters: search excerpts are shortened and are not ' + 'the document. A long page is returned in part, not whole; pass `query` ' + 'to get the part that bears on what you are looking for.'), + 'parameters': { + 'type': 'object', + 'properties': { + 'url': { + 'type': 'string', + 'description': 'A url from web_search.' + }, + # Optional and not required: a policy that does not know what + # it is looking for should still be able to read the page. + 'query': { + 'type': + 'string', + 'description': + 'Optional. What you are looking for on ' + 'the page; the returned text is the part ' + 'that mentions it.' + }, + }, + 'required': ['url'], + }, + }, + }, +] + +# The shared PostgreSQL service. Separate from the world_* tools above: those +# read a SQLite file that ships with the run, these talk to a real server over +# TCP with a per-sandbox account. Both exist because they are different exercises +# -- reading a file you were handed, versus connecting to infrastructure and +# working within the grant you were given. +DB_TOOL_SCHEMAS: list[dict[str, Any]] = [ + { + 'type': 'function', + 'function': { + 'name': + 'db_query', + 'description': ('Run a read-only SQL query against the shared operations database ' + '(PostgreSQL). Your account can SELECT and create temporary tables; ' + 'it cannot change anything. Tables: regions, sites, machines, ' + 'incidents, procedures, incident_notes.'), + 'parameters': { + 'type': 'object', + 'properties': { + 'query': { + 'type': 'string', + 'description': 'One SQL statement (SELECT).' + }, + 'params': { + 'type': 'array', + 'description': 'Optional bound parameters for %s placeholders.', + 'items': {} + }, + }, + 'required': ['query'], + }, + }, + }, + { + 'type': 'function', + 'function': { + 'name': + 'db_schema', + 'description': ('List the tables in the shared database, or describe one table. ' + 'Cheaper and more reliable than guessing column names.'), + 'parameters': { + 'type': 'object', + 'properties': { + 'table': { + 'type': 'string', + 'description': 'Optional table name.' + } + }, + 'required': [], + }, + }, + }, +] + +TOOL_NAMES = tuple(schema['function']['name'] for schema in TOOL_SCHEMAS + SEARCH_TOOL_SCHEMAS + DB_TOOL_SCHEMAS) + + +def _world_name(tool_name: str | None) -> str | None: + """The world tool this call names, or None if it is not one of ours. + + Strips the ``---`` suffix a harness adds to disambiguate same-named + tools, so a suffixed call still resolves. + """ + if not tool_name: + return None + short = tool_name.rsplit('---', 1)[-1] + return short if short in TOOL_NAMES else None + + +class WorldUnavailable(RuntimeError): + """The world is not configured for this run.""" + + +class HostWorld: + """Read-only access to the built world, straight off the filesystem. + + Deliberately does NOT go through world_service.py. The service exists for the + case where something other than this process needs to read the world; the host + already has the files, so routing an in-process call through HTTP would add a + port to secure and a failure mode to handle for nothing. Both read the same + database with the same read-only handle, so a query behaves identically either + way. + """ + + def __init__(self, root: str | None = None): + # A library does not guess where its data lives. Without an explicit root + # the world reports itself unavailable rather than reading whatever + # happens to sit at a path baked in at import time. + configured = root or os.environ.get('TWINKLE_WORLD_ROOT', '') + self.root: Path | None = Path(configured) if configured else None + self.manifest: dict[str, Any] = {} + self.db_path = self.root / 'ops.db' if self.root is not None else None + self.docs: dict[str, Path] = {} + # The search corpus is optional: a run that only uses world_* tools should + # not fail because the search index has not been built. + self.search_root: Path | None = None + self.search_documents: list[dict[str, Any]] = [] + # The episode's own database account, created on first use and dropped on + # close. `db_enabled` is set from the profile's tool list, so a run that + # never names db_* never touches the service at all. + self.db_enabled = False + self._db_creds: dict[str, Any] | None = None + # Which engine answers web_search/fetch_page. The corpus is the default + # and stays the default: it is deterministic, so a run built on it + # replays, and most task development happens against it. A live engine + # is opted into per run because its answers change by the hour. + self.backend = 'corpus' + self.live = None + # The retrieval calls this world actually served, in order -- `web_search` + # and `fetch_page` only, which is what `_receipt` records. The point is + # that a citation check can ask "did you really retrieve this?" and get an + # answer from what happened rather than from what the policy wrote down. A + # policy that invents a url has nothing here to match against. The SQL and + # document tools leave no receipt: they return values, not sources, so + # there is no citation to check them against. + self.receipts: list[dict[str, Any]] = [] + + def _load_search(self) -> None: + # Only the explicit env var is honoured. Walking up from __file__ to find + # a corpus was tried and dropped: how many parents to count depends on + # where a run checked the tree out, and getting it wrong silently reads + # somebody else's index. + self.backend = (os.environ.get('TWINKLE_WORLD_SEARCH_BACKEND') or 'corpus').strip().lower() + if self.backend != 'corpus': + self._load_live_search() + return + # No corpus root means no search tools, which is a supported + # configuration: a run that only reads the database should not have to + # build a search index it will never query. + search_root = os.environ.get('TWINKLE_WORLD_SEARCH_ROOT', '') + if not search_root: + return + root = Path(search_root) + corpus = root / 'corpus.json' + manifest = root / 'manifest.json' + if not corpus.is_file() or not manifest.is_file(): + return + expected = json.loads(manifest.read_text(encoding='utf-8'))['files']['corpus.json'] + import hashlib + actual = hashlib.sha256(corpus.read_bytes()).hexdigest() + if actual != expected: + raise WorldUnavailable(f'search corpus does not match its manifest: expected {expected}, ' + f'got {actual}. Rebuild the corpus and its manifest together -- ' + 'a drifted corpus would silently change what every search task ' + 'retrieves, which is the same run producing a different score.') + self.search_documents = json.loads(corpus.read_text(encoding='utf-8'))['documents'] + self.search_root = root + + def _load_live_search(self) -> None: + """Attach a live engine. + + Imported lazily so a corpus-only run does not depend on the plug-in + existing, and so a broken live backend cannot take down a run that never + asked for it. The contract is two methods returning observation strings; + see the module docstring. + """ + module_dir = os.environ.get('TWINKLE_WORLD_SEARCH_MODULE', '') + if not module_dir: + raise WorldUnavailable('TWINKLE_WORLD_SEARCH_BACKEND is set to ' + f'{self.backend!r} but TWINKLE_WORLD_SEARCH_MODULE is not: a live ' + 'engine is a deployment choice, so the directory holding its ' + 'backend module has to be named rather than assumed.') + if module_dir not in sys.path: + sys.path.insert(0, module_dir) + import search_backend + self.live = search_backend.Backend() + + @property + def available(self) -> bool: + if self.root is None or self.db_path is None: + return False + return self.db_path.is_file() and bool(self.docs or (self.root / 'docs').is_dir()) + + def schemas(self) -> list[ToolInfo]: + """The tools this world can actually answer right now. + + Advertising a tool that always answers "not configured" is worse than not + advertising it: the policy spends a turn discovering that, and a + difficulty measurement built on the episode counts the wasted turn as + part of the task. So each group is offered only when its backing store + is present. + """ + out: list[ToolInfo] = [] + if self.available: + out.extend(TOOL_SCHEMAS) + if self.search_documents or self.live is not None: + out.extend(SEARCH_TOOL_SCHEMAS) + if self.db_enabled: + out.extend(DB_TOOL_SCHEMAS) + return out + + def load(self) -> None: + """Read the manifest and verify the files still match it. + + Checked once per process at startup. The world is read-only, so a match + now is a match for the run; hashing a 100 KB database per query would be + paid on every tool call for no added safety. + """ + if self.root is None: + raise WorldUnavailable('no world root configured: pass root=... to HostWorld or set ' + 'TWINKLE_WORLD_ROOT to the directory holding manifest.json, ops.db ' + 'and docs/') + manifest_path = self.root / 'manifest.json' + if not manifest_path.is_file(): + raise WorldUnavailable(f'no world manifest at {manifest_path}') + import hashlib + self.manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + for name, expected in sorted(self.manifest.get('files', {}).items()): + path = self.root / name + if not path.is_file(): + raise WorldUnavailable(f'world file missing: {path}') + actual = hashlib.sha256(path.read_bytes()).hexdigest() + if actual != expected: + raise WorldUnavailable(f'{name} does not match the manifest\n expected {expected}\n' + f' actual {actual}\nRebuild with build_world.py. Serving a world ' + 'that drifted would silently change every task built on it.') + docs_dir = self.root / 'docs' + if docs_dir.is_dir(): + self.docs = {path.name: path for path in sorted(docs_dir.glob('*.md'))} + self._load_search() + + def _connect(self) -> sqlite3.Connection: + # mode=ro is the enforcement, exactly as in world_service.py: a property of + # the handle rather than a filter over SQL text, so there is no keyword list + # to get clever around. + return sqlite3.connect(f'file:{self.db_path}?mode=ro', uri=True) + + def sql(self, query: str, params: Any) -> str: + if not isinstance(query, str) or not query.strip(): + return 'world_sql: `query` must be a non-empty string.' + if not isinstance(params, (list, tuple)): + return 'world_sql: `params` must be a list.' + head = query.strip().lstrip('(').split(None, 1)[0].upper() + # Refused as a class. Measured: `PRAGMA journal_mode=WAL` and + # `PRAGMA user_version=99` are rejected by the read-only handle, but + # `PRAGMA journal_mode=DELETE` returns a row and reads like success -- a + # no-op only because the file is already in delete mode, which is a + # coincidence. None of them are needed to answer a question about the data. + if head == 'PRAGMA': + return 'world_sql: PRAGMA is not available on the shared world.' + try: + conn = self._connect() + except sqlite3.Error as error: + return f'world_sql error: database unavailable: {error}' + try: + cursor = conn.execute(query, list(params)) + if cursor.description is None: + return 'world_sql: statement produced no result set.' + columns = [c[0] for c in cursor.description] + rows = cursor.fetchmany(MAX_ROWS + 1) + truncated = len(rows) > MAX_ROWS + rows = rows[:MAX_ROWS] + except sqlite3.Error as error: + return f'world_sql error: {type(error).__name__}: {error}' + finally: + conn.close() + lines = [' | '.join(str(c) for c in columns), '-+-'.join('-' * len(str(c)) for c in columns)] + for row in rows: + lines.append(' | '.join('NULL' if v is None else str(v) for v in row)) + if truncated: + lines.append(f'... truncated at {MAX_ROWS} rows') + if not rows: + lines.append('(no rows)') + return '\n'.join(lines) + + def schema(self, name: str) -> str: + name = (name or '').strip() + try: + conn = self._connect() + except sqlite3.Error as error: + return f'world_schema error: database unavailable: {error}' + try: + tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")] + if name and name not in tables: + return f"world_schema error: unknown table {name!r}. Tables: {', '.join(tables)}" + if not name: + return 'Tables: ' + ', '.join(tables) + columns = [(r[1], r[2], bool(r[3]), bool(r[5])) for r in conn.execute(f'PRAGMA table_info({name})')] + except sqlite3.Error as error: + return f'world_schema error: {type(error).__name__}: {error}' + finally: + conn.close() + lines = [f'table {name}:'] + for column, kind, notnull, pk in columns: + flags = [] + if pk: + flags.append('primary key') + if notnull: + flags.append('not null') + lines.append(f' {column} {kind}' + (f" ({', '.join(flags)})" if flags else '')) + return '\n'.join(lines) + + def doc(self, name: str) -> str: + name = (name or '').strip() + if not name: + return ('Available documents: ' + ', '.join(sorted(self.docs)) + '. Call again with one name.') + path = self.docs.get(name) + if path is None: + return (f'world_doc error: unknown document {name!r}. ' + f'Available: {", ".join(sorted(self.docs))}') + text = path.read_text(encoding='utf-8') + return text if len(text) <= 20000 else text[:20000] + '\n... truncated' + + def _rank(self, query: str) -> list[tuple[float, dict[str, Any]]]: + want = _terms(query) + if not want: + return [] + scored = [] + for doc in self.search_documents: + have = _terms(doc['title'] + ' ' + doc['body']) + score = len(want & have) + 2.0 * len(want & _terms(doc['title'])) + if score > 0: + scored.append((score, doc)) + scored.sort(key=lambda pair: -pair[0]) + return scored + + def web_search(self, query: str, num_results: Any = 5, freshness: str = '') -> str: + """Ranked results, rendered the way a tool observation is. + + The output is JSON rather than prose so the fields a policy has to reason + about -- `published`, `url`, `rank` -- stay separable. A paragraph would + bury the date, and the date is what decides between two documents that + contradict each other. + """ + if not isinstance(query, str) or not query.strip(): + return 'web_search: `query` must be a non-empty string.' + if self.live is not None: + return self.live.web_search(query, num_results, freshness) + if not self.search_documents: + return 'web_search: no index is configured for this run.' + try: + limit = int(num_results or 5) + except (TypeError, ValueError): + limit = 5 + limit = max(1, min(limit, 25)) + ranked = self._rank(query) + if freshness: + # A freshness filter without a stable sort is a different answer every + # call, so rank is the tiebreak and order is total. + ranked.sort(key=lambda pair: (pair[1].get('published') or '', pair[0]), reverse=True) + results = [] + for rank, (_score, doc) in enumerate(ranked[:limit], 1): + quoted = [] + want = _terms(query) + for sentence in re.split(r'(?<=\.)\s+', doc['body'].replace('\n', ' ')): + sentence = sentence.strip(' #') + if sentence and want & _terms(sentence): + quoted.append(sentence) + if len(quoted) >= 2: + break + results.append({ + 'rank': rank, + 'url': doc['url'], + 'title': doc['title'], + 'published': doc.get('published'), + 'excerpts': quoted, + }) + if not results: + # Saying so is the answer. A policy trained to expect results will + # invent one here, and that is exactly the failure to catch. + return ('web_search: no results for that query. Try different terms, or ' + 'conclude the information is not in the index.') + return json.dumps({ + 'query': query, + 'num_results': len(results), + 'results': results + }, + ensure_ascii=False, + indent=2) + + def fetch_page(self, url: str, query: str = '') -> str: + if not isinstance(url, str) or not url.strip(): + return 'fetch_page: `url` must be a string.' + if self.live is not None: + return self.live.fetch_page(url, query if isinstance(query, str) else '') + for doc in self.search_documents: + if doc['url'] == url: + return (f"# {doc['title']}\n" + f"published: {doc.get('published')}\n" + f"url: {doc['url']}\n\n{doc['body']}") + return (f'fetch_page: no document at {url!r}. Call web_search again to get a ' + 'url that exists.') + + # ---- the shared PostgreSQL service -------------------------------- + def _db_credentials(self) -> dict[str, Any] | None: + """This episode's own database account, created on first use. + + One account per episode, not one shared read-only login: with a shared + login, "read-only" is a promise about every sandbox's behaviour. With a + per-episode role it is a grant the server enforces, and the blast radius + of a bug is one episode. + + Created lazily so that a run which never uses the database never creates + an account, and dropped in `close()`. + """ + if self._db_creds is not None: + return self._db_creds + if not self.db_enabled: + return None + provision_dir = os.environ.get('TWINKLE_WORLD_DB_MODULE', '') + if not provision_dir: + raise WorldUnavailable('db_enabled is set but TWINKLE_WORLD_DB_MODULE is not: the ' + 'per-episode role provisioning is deployment-specific, so the ' + 'directory holding its `provision` module has to be named.') + if provision_dir not in sys.path: + sys.path.insert(0, provision_dir) + import provision + self._db_creds = provision.provision( + os.environ.get('TWINKLE_WORLD_DB_HOST', '127.0.0.1'), + int(os.environ.get('TWINKLE_WORLD_DB_PORT', '5432')), + ttl=int(os.environ.get('TWINKLE_WORLD_DB_LEASE', '21600'))) + return self._db_creds + + def _db_connect(self): + creds = self._db_credentials() + if creds is None: + raise RuntimeError('no database is configured for this run') + import psycopg2 + conn = psycopg2.connect( + host=creds['host'], + port=creds['port'], + dbname=creds['database'], + user=creds['role'], + password=creds['password'], + connect_timeout=10) + # autocommit on, and NOT set_session(readonly=True). + # + # readonly=True was the obvious belt-and-braces move and it was wrong: a + # read-only transaction refuses CREATE TEMP TABLE AS, which the grant + # deliberately allows so a solver has somewhere to work. The two guards + # disagreed, and the one that fired was the one that removed a capability + # the profile promised -- the model would have been told it could use a + # scratch table and then been refused. + # + # The GRANT is the enforcement, and it is the stronger one: it is a + # property of the role recorded in the catalogue, it survives this process + # dying, and it cannot be lifted by anything the model sends. Verified in + # test_db.py -- insert, update, delete, truncate, drop, create table, + # alter and create schema are all refused by PostgreSQL itself. + conn.autocommit = True + return conn + + def db_query(self, query: str, params: Any = None) -> str: + if not isinstance(query, str) or not query.strip(): + return 'db_query: `query` must be a non-empty string.' + if params is not None and not isinstance(params, (list, tuple)): + return 'db_query: `params` must be a list.' + try: + conn = self._db_connect() + except Exception as error: # noqa: BLE001 + return f'db_query: cannot reach the shared database: {error}' + try: + with conn.cursor() as cur: + cur.execute(query, list(params) if params else None) + if cur.description is None: + # A statement that returns nothing is allowed to succeed (a + # temp table, a SET) but the model is told, so it does not read + # silence as a result. + return 'db_query: statement completed with no result set.' + columns = [d[0] for d in cur.description] + rows = cur.fetchmany(MAX_ROWS + 1) + truncated = len(rows) > MAX_ROWS + rows = rows[:MAX_ROWS] + except Exception as error: # noqa: BLE001 + return f'db_query error: {type(error).__name__}: {str(error).strip()}' + finally: + conn.close() + lines = [' | '.join(str(c) for c in columns), '-+-'.join('-' * len(str(c)) for c in columns)] + for row in rows: + lines.append(' | '.join('NULL' if v is None else str(v) for v in row)) + if truncated: + lines.append(f'... truncated at {MAX_ROWS} rows') + if not rows: + lines.append('(no rows)') + return '\n'.join(lines) + + def db_schema(self, table: str = '') -> str: + table = (table or '').strip() + try: + conn = self._db_connect() + except Exception as error: # noqa: BLE001 + return f'db_schema: cannot reach the shared database: {error}' + try: + with conn.cursor() as cur: + cur.execute('SELECT table_name FROM information_schema.tables ' + "WHERE table_schema = 'public' ORDER BY table_name") + tables = [r[0] for r in cur.fetchall()] + if table and table not in tables: + return (f'db_schema error: unknown table {table!r}. ' + f'Tables: {", ".join(tables)}') + if not table: + return 'Tables: ' + ', '.join(tables) + cur.execute( + 'SELECT column_name, data_type, is_nullable ' + 'FROM information_schema.columns ' + "WHERE table_schema = 'public' AND table_name = %s " + 'ORDER BY ordinal_position', (table, )) + columns = cur.fetchall() + except Exception as error: # noqa: BLE001 + return f'db_schema error: {type(error).__name__}: {str(error).strip()}' + finally: + conn.close() + lines = [f'table {table}:'] + for name, kind, nullable in columns: + suffix = '' if nullable == 'YES' else ' not null' + lines.append(f' {name} {kind}{suffix}') + return '\n'.join(lines) + + def release(self) -> None: + """Drop this episode's database account, if one was made. + + On HostWorld rather than only on the env that owns it: the account belongs + to this object, and every caller that can create one has to be able to + release it. Keeping it on the env alone meant a second caller would have + had to reimplement the drop. + + Safe to call more than once. An episode killed before it gets here never + calls it at all -- that is what the lease and the reaper are for. + """ + if self._db_creds is None: + return + role = self._db_creds['role'] + self._db_creds = None + try: + provision_dir = os.environ.get('TWINKLE_WORLD_DB_MODULE', '') + if provision_dir and provision_dir not in sys.path: + sys.path.insert(0, provision_dir) + import provision + provision.drop(role) + except Exception as error: # noqa: BLE001 + # Not fatal: the account is read-only and leased, so a failed drop + # leaves an inert role rather than a live hazard. + print(f'[world] failed to drop database role {role}: {error}') + + def _receipt(self, tool_name: str, arguments: dict[str, Any], result: str) -> None: + """Record what was served, parsed enough to be checkable. + + The urls are extracted from the tool's own output rather than from its + arguments: `fetch_page` takes one url, but `web_search` is asked a query + and decides which urls to return, so the urls that were actually served + are only knowable from the result. + """ + entry = {'tool': tool_name, 'args': dict(arguments), 'urls': [], 'query': ''} + if tool_name == 'web_search': + entry['query'] = (arguments.get('query') or '').strip() + try: + payload = json.loads(result) + except (TypeError, ValueError): + payload = {} + for hit in payload.get('results') or []: + if isinstance(hit, dict) and hit.get('url'): + entry['urls'].append({ + 'url': hit['url'], + 'published': hit.get('published') or '', + 'title': hit.get('title') or '' + }) + elif tool_name == 'fetch_page': + url = (arguments.get('url') or '').strip() + # A fetch that failed is not a receipt for the page it named. + if url and not result.startswith('fetch_page:'): + entry['urls'].append({'url': url, 'published': '', 'title': ''}) + else: + return + self.receipts.append(entry) + + def _answer(self, tool_name: str, arguments: dict[str, Any]) -> str | None: + if tool_name == 'world_sql': + return self.sql(arguments.get('query'), arguments.get('params') or []) + if tool_name == 'world_schema': + return self.schema(arguments.get('name')) + if tool_name == 'world_doc': + return self.doc(arguments.get('name')) + if tool_name == 'web_search': + return self.web_search( + arguments.get('query'), arguments.get('num_results'), + arguments.get('freshness') or '') + if tool_name == 'fetch_page': + return self.fetch_page(arguments.get('url'), arguments.get('query') or '') + if tool_name == 'db_query': + return self.db_query(arguments.get('query'), arguments.get('params')) + if tool_name == 'db_schema': + return self.db_schema(arguments.get('table') or '') + return None + + def call(self, tool_name: str, arguments: dict[str, Any]) -> str | None: + """Answer one world tool call, or None if the name is not ours. + + The receipt is written here rather than in the caller so that every path + that answers a search tool records it: a check that trusted the caller to + remember would silently stop working the first time a second caller + appeared. A failed call records nothing for the url it failed on, which is + what makes "I fetched this" a claim with evidence behind it. + """ + arguments = arguments or {} + result = self._answer(tool_name, arguments) + if result is not None: + self._receipt(tool_name, arguments, result) + return result + + +def split_calls(calls: Sequence[tuple[str, dict[str, Any]]], + world: HostWorld) -> tuple[list[tuple[int, str]], list[tuple[str, dict[str, Any]]]]: + """Partition one turn's calls into (host-answered, sandbox-bound). + + Batch-aware on purpose: a turn can mix a world call with shell commands, and + the sandbox request must carry only the calls it can actually run. Order is + preserved by index so the results can be reassembled positionally -- GRPO + rewards do not care about order, but a transcript that pairs an observation + with the wrong call teaches the policy something false. + """ + host: list[tuple[int, str]] = [] + sandbox: list[tuple[str, dict[str, Any]]] = [] + for index, (name, args) in enumerate(calls): + short = name.rsplit('---', 1)[-1] + if short in TOOL_NAMES: + host.append((index, short)) + else: + sandbox.append((name, args)) + return host, sandbox + + +class WorldToolsMixin: + """Serve host-side world tools alongside whatever an environment provides. + + Mix in front of any :class:`~twinkle_agentic.envs.base.Env`:: + + class WorldLocalEnv(WorldToolsMixin, LocalEnv): + pass + + env = WorldLocalEnv(workspace='/tmp/slot0', world=HostWorld(root)) + + The mixin answers the world's own tool names from the training host and + forwards everything else to the environment untouched. It adds one attribute + (:attr:`world`) to the public surface, so an environment that already works + keeps working -- and because it is a mixin rather than a change to + ``AgentEnv``, it applies to ``LocalEnv`` and ``OpenEnv`` as well, which is + what makes it testable without a sandbox host. + + Two things that are easy to get wrong: + + * **The mixin goes first.** ``class X(WorldToolsMixin, LocalEnv)``, not the + reverse: it has to precede the environment in the MRO for its ``tools``, + ``step`` and ``step_batch`` to be the ones called. + * **Tool names may arrive suffixed.** A harness that disambiguates + same-named tools across environments appends ``---``. Both + :meth:`step` and :meth:`step_batch` strip that before matching, so a + suffixed world call is answered here instead of being forwarded to a + sandbox that has no such tool. + + The world's lifetime is the caller's, not the mixin's: several environments + may share one read-only world, so ``close()`` does not release it. Pass + ``release_world_on_close=True`` on the environment that owns it. + """ + + def __init__(self, + *args: Any, + world: HostWorld | None = None, + release_world_on_close: bool = False, + **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._world = world + self._release_world_on_close = release_world_on_close + + @property + def world(self) -> HostWorld | None: + """The host world this environment reads from, or None.""" + return self._world + + def tools(self) -> list[ToolInfo]: + declared = list(super().tools()) + if self._world is None: + return declared + # A name the environment already owns wins. Shadowing it silently would + # promise the model one behaviour and hand it another. + names = {(t.get('function') or {}).get('name') for t in declared} + return declared + [ + schema for schema in self._world.schemas() if (schema.get('function') or {}).get('name') not in names + ] + + def step(self, tool_name: str, arguments: dict[str, Any] = None) -> StepResult: + answer = self._answer_world(tool_name, arguments or {}) + if answer is not None: + return StepResult(observation=answer, reward=0.0, done=False) + return super().step(tool_name, arguments) + + def step_batch(self, calls: Sequence[tuple[str, dict[str, Any]]]) -> list[StepResult]: + """Answer the world's calls here, and send the rest as one batch. + + Worth overriding rather than leaving to the serial default: the point of + ``step_batch`` is keeping tool execution off the generation critical + path, and a turn that mixes one world call with three shell commands + should still send those three as a single request. + + Positions are tracked explicitly rather than inferred from order, so a + transcript cannot pair an observation with the wrong call -- which would + teach the policy something false without anything downstream noticing. + """ + if self._world is None: + return super().step_batch(calls) + host_at: list[int] = [] + sandbox_at: list[int] = [] + for index, (name, _) in enumerate(calls): + if _world_name(name) is not None: + host_at.append(index) + else: + sandbox_at.append(index) + out: list[StepResult | None] = [None] * len(calls) + for index in host_at: + name = _world_name(calls[index][0]) + answer = self._world.call(name, calls[index][1] or {}) + out[index] = StepResult( + observation=(answer if answer is not None else f'Error: {name} is not available in this environment.'), + reward=0.0, + done=False) + if sandbox_at: + forwarded = super().step_batch([calls[i] for i in sandbox_at]) + if len(forwarded) != len(sandbox_at): + raise RuntimeError(f'step_batch returned {len(forwarded)} results for ' + f'{len(sandbox_at)} forwarded call(s); cannot pair observations ' + 'with calls, so refusing to guess') + for index, result in zip(sandbox_at, forwarded): + out[index] = result + return [r if r is not None else StepResult(observation='Error: no result') for r in out] + + def _answer_world(self, tool_name: str, arguments: dict[str, Any]) -> str | None: + """The world's answer, or None when this call is not the world's.""" + if self._world is None: + return None + name = _world_name(tool_name) + if name is None: + return None + answer = self._world.call(name, arguments) + if answer is None: + return f'Error: {name} is not available in this environment.' + return answer + + def close(self) -> None: + try: + super().close() + finally: + if self._release_world_on_close and self._world is not None: + self._world.release() diff --git a/tests/twinkle_agentic/test_env_profiles.py b/tests/twinkle_agentic/test_env_profiles.py new file mode 100644 index 000000000..7ecd612f4 --- /dev/null +++ b/tests/twinkle_agentic/test_env_profiles.py @@ -0,0 +1,367 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Least-privilege profiles, and the mixin that enforces one on an Env. + +A profile is what stops every task from getting the same unrestricted tool set: +a topic about querying a database and a topic about office ergonomics should not +arrive in the same environment, and a task whose fixture has to exist before the +episode starts needs the environment seeded rather than merely emptied. +""" +import json + +import pytest + +from twinkle_agentic.envs import (LocalEnv, ProfiledEnvMixin, ProfileRegistry, SandboxProfile, + default_profile_registry, env_names_for) + + +def make_profile(name='test-profile', + allowed_tools=('read_file', ), + capabilities=('data_compute', ), + session_kind='agentenv', + setup_manifest=(), + available=True, + enabled_for_new_tasks=True, + **kwargs): + return SandboxProfile(name=name, + version=1, + session_kind=session_kind, + capabilities=capabilities, + task_forms=('database_analysis', ), + allowed_tools=allowed_tools, + verifier_kinds=('file_exists', ), + input_kinds=('given_text', ), + output_kinds=('answer', ), + setup_manifest=setup_manifest, + available=available, + enabled_for_new_tasks=enabled_for_new_tasks, + **kwargs) + + +# ------------------------------------------------------------- the profile itself +def test_profile_id_is_name_at_version(): + assert make_profile(name='sql-analytics').profile_id == 'sql-analytics@1' + + +def test_the_default_registry_carries_the_builtin_profiles(): + registry = default_profile_registry() + ids = {p.profile_id for p in registry.profiles()} + assert 'sql-analytics@1' in ids and 'web-research@1' in ids and 'lean-proof@1' in ids + assert len(ids) >= 10 + + +def test_a_profile_cannot_grant_a_forbidden_capability(): + # raw_network in particular: the whole point of serving the world from the + # host is that the sandbox has no egress, and a profile that granted it + # would quietly undo that. + with pytest.raises(ValueError, match='forbidden capabilities'): + make_profile(capabilities=('raw_network', )) + + +def test_a_no_tool_profile_cannot_also_allow_tools(): + with pytest.raises(ValueError, match='no-tool profiles cannot advertise tools'): + make_profile(session_kind='no_tool', allowed_tools=('read_file', )) + + +def test_a_profile_name_has_to_be_a_valid_identifier(): + with pytest.raises(ValueError, match='invalid profile name'): + make_profile(name='Not A Name') + + +def test_version_has_to_be_a_positive_integer(): + with pytest.raises(ValueError, match='positive integer'): + SandboxProfile(name='x', version=0, session_kind='agentenv', capabilities=('data_compute', ), + task_forms=('t', ), allowed_tools=('read_file', ), verifier_kinds=('file_exists', ), + input_kinds=('given_text', ), output_kinds=('answer', )) + + +def test_a_duplicated_entry_in_any_list_field_is_rejected(): + # A repeated tool name reads as two permissions and silently halves the + # meaning of the list, so it is refused at build time. + with pytest.raises(ValueError): + make_profile(allowed_tools=('read_file', 'read_file')) + + +def test_the_manifest_is_a_digest_so_a_changed_profile_is_detectable(): + a = make_profile().digest + b = make_profile(allowed_tools=('read_file', 'write_file')).digest + assert a != b + assert make_profile().digest == a + + +# ------------------------------------------------------------------- the registry +def test_registering_the_same_id_twice_is_refused(): + registry = ProfileRegistry() + registry.register(make_profile()) + with pytest.raises(ValueError, match='already registered'): + registry.register(make_profile()) + + +def test_get_raises_for_an_unknown_profile(): + with pytest.raises(ValueError, match='not registered'): + ProfileRegistry().get('nope@1') + + +def test_registry_digest_covers_every_profile(): + one = ProfileRegistry() + one.register(make_profile(name='a')) + two = ProfileRegistry() + two.register(make_profile(name='a')) + two.register(make_profile(name='b')) + assert one.digest != two.digest + + +# --------------------------------------------------------------------- aliasing +def test_env_names_translates_the_agent_runtime_vocabulary(): + # A profile names tools the way the agent runtime that served them does; + # an Env declares fewer, coarser names. The table is the only place that + # knows the mapping. + profile = make_profile(allowed_tools=('shell_executor', 'read_file', 'glob')) + assert env_names_for(profile) == frozenset({'run_command', 'read_file'}) + + +def test_env_names_keeps_a_name_that_needs_no_translation(): + profile = make_profile(allowed_tools=('run_command', 'write_file')) + assert env_names_for(profile) == frozenset({'run_command', 'write_file'}) + + +def test_env_names_passes_through_a_name_it_does_not_know(): + # Unknown means "an environment that declares it", not "drop it": silently + # discarding would widen nothing but would hide a typo from whoever wrote + # the profile. + profile = make_profile(allowed_tools=('some_future_tool', )) + assert env_names_for(profile) == frozenset({'some_future_tool'}) + + +# -------------------------------------------------------------------- the mixin +class ProfiledLocalEnv(ProfiledEnvMixin, LocalEnv): + """The mixin goes first: it has to precede the env in the MRO.""" + + +@pytest.fixture +def slot(tmp_path): + return ProfiledLocalEnv(workspace=str(tmp_path / 'slot')) + + +def test_with_no_profile_the_environment_is_unrestricted(slot): + # Mixing this in must not change an environment whose caller has no + # profiles, or adopting it costs every existing caller something. + assert slot.profile is None + assert slot.profile_id == '' + assert {t['function']['name'] for t in slot.tools()} == {'run_command', 'write_file', 'read_file'} + assert 'fine' in slot.step('run_command', {'command': 'echo fine'}).observation + + +def test_a_profile_narrows_what_is_advertised(tmp_path): + profile = make_profile(allowed_tools=('read_file', )) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profile=profile) + assert [t['function']['name'] for t in slot.tools()] == ['read_file'] + + +def test_a_tool_outside_the_profile_is_refused_not_forwarded(tmp_path): + # Advertising is not enforcement: a policy can name a tool it was never + # shown, and the refusal has to come from here rather than from the + # environment reporting an unknown tool. + profile = make_profile(allowed_tools=('read_file', )) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profile=profile) + result = slot.step('run_command', {'command': 'echo should-not-run'}) + assert 'not available in this environment' in result.observation + assert 'should-not-run' not in result.observation + + +def test_a_permitted_tool_still_works(tmp_path): + profile = make_profile(allowed_tools=('run_command', 'read_file', 'write_file')) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profile=profile) + assert 'ok' in slot.step('run_command', {'command': 'echo ok'}).observation + + +def test_the_refusal_names_what_is_allowed(tmp_path): + # A refusal that only says no teaches the policy nothing; naming the allowed + # set gives it somewhere to go on the next turn. + profile = make_profile(allowed_tools=('read_file', )) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profile=profile) + observation = slot.step('write_file', {'path': 'x', 'content': 'y'}).observation + assert 'read_file' in observation + + +def test_step_batch_answers_refusals_locally_and_forwards_the_rest(tmp_path): + profile = make_profile(allowed_tools=('run_command', )) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profile=profile) + calls = [ + ('run_command', {'command': 'echo A'}), + ('write_file', {'path': 'nope', 'content': 'x'}), + ('run_command', {'command': 'echo B'}), + ] + results = slot.step_batch(calls) + assert len(results) == 3 + assert 'A' in results[0].observation + assert 'not available in this environment' in results[1].observation + assert 'B' in results[2].observation + + +def test_step_batch_with_no_profile_falls_straight_through(tmp_path): + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot')) + results = slot.step_batch([('run_command', {'command': 'echo only'})]) + assert len(results) == 1 and 'only' in results[0].observation + + +# ------------------------------------------------------------------- switching +def test_switch_profile_seeds_the_workspace(tmp_path): + registry = ProfileRegistry() + registry.register(make_profile(name='seeded', + allowed_tools=('run_command', ), + setup_manifest=('seed-sql-analytics', ))) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profiles=registry) + profile = slot.switch_profile('seeded@1') + assert slot.profile_id == 'seeded@1' + assert profile.name == 'seeded' + # The seed built the fixture the task will be graded on. Asserted by name + # and by content, not by "the directory is not empty": an empty file would + # satisfy that and every task built on it would then be unsolvable. + import sqlite3 + db = tmp_path / 'slot' / 'shop.db' + assert db.is_file() + conn = sqlite3.connect(db) + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + conn.close() + assert {'customers', 'products', 'orders'} <= tables + + +def test_switch_profile_records_every_manifest_step(tmp_path): + registry = ProfileRegistry() + registry.register(make_profile(name='two-steps', + allowed_tools=('run_command', ), + setup_manifest=('clear_workspace', 'python-numpy-sympy-canary'))) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profiles=registry) + slot.switch_profile('two-steps@1') + steps = [name for name, _, _ in slot.manifest_results] + assert steps == ['clear_workspace', 'python-numpy-sympy-canary'] + assert all(code == 0 for _, code, _ in slot.manifest_results) + + +def test_an_unknown_manifest_step_fails_loudly(tmp_path): + # A typo in a manifest would otherwise produce an environment missing its + # fixture, and every task built in it would read as unsolvable. + registry = ProfileRegistry() + registry.register(make_profile(name='typo', + allowed_tools=('run_command', ), + setup_manifest=('seed-does-not-exist', ))) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profiles=registry) + with pytest.raises(RuntimeError, match='setup step'): + slot.switch_profile('typo@1') + + +def test_switching_to_an_unavailable_profile_raises(tmp_path): + registry = ProfileRegistry() + registry.register(make_profile(name='off', + allowed_tools=('run_command', ), + available=False, + **{'unavailable_reason': 'no service behind it'})) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profiles=registry) + with pytest.raises(ValueError, match='not available'): + slot.switch_profile('off@1') + + +def test_switching_to_a_retired_profile_raises(tmp_path): + registry = ProfileRegistry() + registry.register(make_profile(name='old', + allowed_tools=('run_command', ), + enabled_for_new_tasks=False)) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profiles=registry) + with pytest.raises(ValueError, match='retired'): + slot.switch_profile('old@1') + + +def test_switching_narrows_the_advertised_tools(tmp_path): + registry = ProfileRegistry() + registry.register(make_profile(name='readers', allowed_tools=('read_file', ))) + slot = ProfiledLocalEnv(workspace=str(tmp_path / 'slot'), profiles=registry) + assert len(slot.tools()) == 3 + slot.switch_profile('readers@1') + assert [t['function']['name'] for t in slot.tools()] == ['read_file'] + + +# ------------------------------------------------- combined with the world mixin +def test_the_profile_also_governs_the_world_tools(tmp_path, monkeypatch): + # MRO order matters: ProfiledEnvMixin ahead of WorldToolsMixin, so the world + # contributes its tools and the profile then decides which of them survive. + from twinkle_agentic.envs import HostWorld, WorldToolsMixin + + root = tmp_path / 'world' + (root / 'docs').mkdir(parents=True) + import hashlib + import sqlite3 + conn = sqlite3.connect(root / 'ops.db') + conn.execute('CREATE TABLE t (a INTEGER)') + conn.commit() + conn.close() + (root / 'manifest.json').write_text( + json.dumps({'files': {'ops.db': hashlib.sha256((root / 'ops.db').read_bytes()).hexdigest()}}), + encoding='utf-8') + monkeypatch.setenv('TWINKLE_WORLD_ROOT', str(root)) + world = HostWorld() + world.load() + + registry = ProfileRegistry() + # world_sql rather than db_query: the world only advertises the database + # tools when a database is actually provisioned for the run. + registry.register(make_profile(name='sql-and-files', allowed_tools=('world_sql', 'read_file'))) + + class Slot(ProfiledEnvMixin, WorldToolsMixin, LocalEnv): + pass + + slot = Slot(workspace=str(tmp_path / 'slot'), world=world, profiles=registry) + slot.switch_profile('sql-and-files@1') + names = {t['function']['name'] for t in slot.tools()} + assert names == {'world_sql', 'read_file'} + # A world tool the profile does not permit is refused, not merely hidden: + # the policy can name a tool it was never shown. + refused = slot.step('world_doc', {'name': 'anything.md'}).observation + assert 'not available in this environment' in refused + # and the permitted one still reaches the host world + assert 'no such table' in slot.step('world_sql', {'query': 'SELECT * FROM nope', + 'params': []}).observation.lower() or slot.step('world_sql', {'query': 'SELECT COUNT(*) FROM t', 'params': []}).observation + + +def test_world_tools_survive_when_the_profile_permits_them(tmp_path, monkeypatch): + import hashlib + import sqlite3 + from twinkle_agentic.envs import HostWorld, WorldToolsMixin + + root = tmp_path / 'world' + (root / 'docs').mkdir(parents=True) + conn = sqlite3.connect(root / 'ops.db') + conn.execute('CREATE TABLE t (a INTEGER)') + conn.commit() + conn.close() + (root / 'manifest.json').write_text( + json.dumps({'files': {'ops.db': hashlib.sha256((root / 'ops.db').read_bytes()).hexdigest()}}), + encoding='utf-8') + monkeypatch.setenv('TWINKLE_WORLD_ROOT', str(root)) + world = HostWorld() + world.load() + + registry = ProfileRegistry() + registry.register(make_profile(name='sql-world', allowed_tools=('world_sql', 'world_schema'))) + + class Slot(ProfiledEnvMixin, WorldToolsMixin, LocalEnv): + pass + + slot = Slot(workspace=str(tmp_path / 'slot'), world=world, profiles=registry) + slot.switch_profile('sql-world@1') + assert {t['function']['name'] for t in slot.tools()} == {'world_sql', 'world_schema'} + result = slot.step('world_sql', {'query': 'SELECT COUNT(*) FROM t', 'params': []}) + assert '0' in result.observation + + +def test_every_builtin_profile_names_setup_steps_something_can_resolve(): + # A manifest step that does not resolve is a typo in the registry, and it is + # invisible until the first switch_profile: clear() raises "unknown setup + # step", which reads as a broken slot rather than as a broken profile. The + # retired legacy profile carried one for exactly that reason. + from twinkle_agentic.envs.seeds import CLEAR_WORKSPACE, LIBRARY_CANARIES, SEED_PROGRAMS + + known = {'clear_workspace'} | set(SEED_PROGRAMS) | set(LIBRARY_CANARIES) + for profile in default_profile_registry().profiles(): + for step in profile.setup_manifest: + assert step in known, f'{profile.profile_id} names unknown setup step {step!r}' + assert CLEAR_WORKSPACE.strip() diff --git a/tests/twinkle_agentic/test_world_tools.py b/tests/twinkle_agentic/test_world_tools.py new file mode 100644 index 000000000..095bc947c --- /dev/null +++ b/tests/twinkle_agentic/test_world_tools.py @@ -0,0 +1,297 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Host-side world tools mixed into an ``Env``. + +Every test here runs against ``LocalEnv``, which needs no sandbox host: the +point of the mixin being a mixin rather than a change to ``AgentEnv`` is that +the host half can be verified without the sandbox half. +""" +import hashlib +import json +import sqlite3 + +import pytest + +from twinkle_agentic.envs import HostWorld, LocalEnv, WorldToolsMixin, WorldUnavailable + + +class WorldLocalEnv(WorldToolsMixin, LocalEnv): + """The mixin goes first: it has to precede the env in the MRO.""" + + +def _sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +@pytest.fixture +def world_root(tmp_path): + """A minimal world: one table, one document, a manifest that matches.""" + root = tmp_path / 'world' + (root / 'docs').mkdir(parents=True) + conn = sqlite3.connect(root / 'ops.db') + conn.execute('CREATE TABLE incidents (id INTEGER PRIMARY KEY, service TEXT, severity TEXT)') + conn.executemany('INSERT INTO incidents (service, severity) VALUES (?, ?)', + [('auth', 'p1'), ('auth', 'p2'), ('billing', 'p1')]) + conn.commit() + conn.close() + (root / 'docs' / 'runbook.md').write_text('# Runbook\npage one\n', encoding='utf-8') + (root / 'manifest.json').write_text( + json.dumps({'files': {'ops.db': _sha(root / 'ops.db')}}), encoding='utf-8') + return root + + +@pytest.fixture +def search_root(tmp_path): + """A two-document frozen corpus, with a manifest that matches.""" + root = tmp_path / 'search' + root.mkdir() + documents = [ + { + 'url': 'https://example.com/a', + 'title': 'Auth outage postmortem', + 'published': '2026-09-01', + 'body': 'The auth service returned 500 for eleven minutes. A leaked pool.' + }, + { + 'url': 'https://example.com/b', + 'title': 'Billing migration notes', + 'published': '2025-01-01', + 'body': 'Billing moved to a new ledger. Old rows are read only.' + }, + ] + (root / 'corpus.json').write_text(json.dumps({'documents': documents}), encoding='utf-8') + (root / 'manifest.json').write_text( + json.dumps({'files': {'corpus.json': _sha(root / 'corpus.json')}}), encoding='utf-8') + return root + + +@pytest.fixture +def env(world_root, tmp_path, monkeypatch): + monkeypatch.setenv('TWINKLE_WORLD_ROOT', str(world_root)) + monkeypatch.delenv('TWINKLE_WORLD_SEARCH_ROOT', raising=False) + world = HostWorld() + world.load() + return WorldLocalEnv(workspace=str(tmp_path / 'slot'), world=world) + + +# ------------------------------------------------------------------ the world + + +def test_unconfigured_world_is_unavailable(monkeypatch): + # A library does not guess where its data lives. Without a root the world + # reports itself unavailable rather than reading some baked-in path. + monkeypatch.delenv('TWINKLE_WORLD_ROOT', raising=False) + world = HostWorld() + assert world.available is False + assert world.schemas() == [] + with pytest.raises(WorldUnavailable, match='TWINKLE_WORLD_ROOT'): + world.load() + + +def test_load_rejects_a_world_that_drifted(world_root): + (world_root / 'ops.db').write_bytes(b'not the database the manifest hashed') + with pytest.raises(WorldUnavailable, match='does not match the manifest'): + HostWorld(world_root).load() + + +def test_schemas_advertise_only_what_is_configured(env, search_root, monkeypatch): + # No search corpus and no database: the model is offered three tools, not + # seven. Advertising one that always answers "not configured" costs a turn. + assert [s['function']['name'] for s in env.world.schemas()] == [ + 'world_sql', 'world_schema', 'world_doc' + ] + monkeypatch.setenv('TWINKLE_WORLD_SEARCH_ROOT', str(search_root)) + with_search = HostWorld(env.world.root) + with_search.load() + assert [s['function']['name'] for s in with_search.schemas()] == [ + 'world_sql', 'world_schema', 'world_doc', 'web_search', 'fetch_page' + ] + + +# ------------------------------------------------------------------- the mixin + + +def test_tools_appends_world_schemas_to_the_environment_own(env): + names = [t['function']['name'] for t in env.tools()] + assert names[:3] == ['run_command', 'write_file', 'read_file'] + assert names[3:] == ['world_sql', 'world_schema', 'world_doc'] + + +def test_environment_tool_of_the_same_name_is_not_shadowed(world_root, tmp_path, monkeypatch): + # Silently shadowing would promise the model one behaviour and hand it + # another, so the environment's own tool has to win. + monkeypatch.setenv('TWINKLE_WORLD_ROOT', str(world_root)) + world = HostWorld() + world.load() + + class Colliding(LocalEnv): + + def tools(self): + return [{ + 'type': 'function', + 'function': { + 'name': 'world_sql', + 'description': 'the environment one', + 'parameters': { + 'type': 'object', + 'properties': {} + } + } + }] + + class WorldColliding(WorldToolsMixin, Colliding): + pass + + env = WorldColliding(workspace=str(tmp_path / 'slot'), world=world) + tools = env.tools() + names = [t['function']['name'] for t in tools] + assert names.count('world_sql') == 1 + assert tools[0]['function']['description'] == 'the environment one' + # the world's other tools still come through + assert 'world_schema' in names and 'world_doc' in names + + +def test_step_answers_a_world_tool(env): + result = env.step('world_sql', { + 'query': 'SELECT service, COUNT(*) FROM incidents GROUP BY service ORDER BY service', + 'params': [] + }) + assert result.done is False + assert 'auth' in result.observation and 'billing' in result.observation + + +def test_step_strips_the_server_prefix_a_harness_adds(env): + # ms-agent namespaces tools as {server}---{tool}, so the tool name is the + # suffix. A call arriving that way is still ours. + result = env.step('msagent---world_sql', {'query': 'SELECT COUNT(*) FROM incidents', 'params': []}) + assert '3' in result.observation + + +def test_step_forwards_a_tool_that_is_not_the_worlds(env): + result = env.step('run_command', {'command': 'echo from-the-env'}) + assert 'from-the-env' in result.observation + + +def test_step_does_not_claim_a_name_that_only_looks_like_ours(env): + # The tool name is the suffix, so 'world_sql---slot3' names a tool called + # 'slot3' on a server called 'world_sql'. Not ours; forward it. + result = env.step('world_sql---slot3', {'query': 'SELECT 1', 'params': []}) + assert 'world_sql---slot3' in result.observation or 'unknown' in result.observation.lower() + + +def test_unconfigured_database_reports_rather_than_raises(env): + result = env.step('db_query', {'query': 'SELECT 1'}) + assert 'no database is configured' in result.observation + assert result.done is False + + +# ------------------------------------------------------------------ batch path + + +def test_step_batch_pairs_each_observation_with_its_own_call(env): + calls = [ + ('run_command', {'command': 'echo A'}), + ('world_sql', {'query': 'SELECT COUNT(*) FROM incidents', 'params': []}), + ('run_command', {'command': 'echo B'}), + ('world_doc', {'name': 'runbook.md'}), + ] + results = env.step_batch(calls) + assert len(results) == 4 + assert 'A' in results[0].observation + assert '3' in results[1].observation + assert 'B' in results[2].observation + assert 'Runbook' in results[3].observation + + +def test_step_batch_without_a_world_falls_through(env): + env._world = None + calls = [('run_command', {'command': 'echo only'})] + results = env.step_batch(calls) + assert len(results) == 1 and 'only' in results[0].observation + + +def test_step_batch_refuses_to_guess_when_the_env_returns_the_wrong_count(env, monkeypatch): + # Pairing an observation with the wrong call teaches the policy something + # false and nothing downstream would notice, so this has to be loud. + monkeypatch.setattr(LocalEnv, 'step_batch', lambda self, calls: []) + with pytest.raises(RuntimeError, match='cannot pair observations'): + env.step_batch([('run_command', {'command': 'echo A'}), + ('world_sql', {'query': 'SELECT 1', 'params': []})]) + + +# -------------------------------------------------------------------- receipts + + +def test_web_search_records_what_it_actually_served(world_root, search_root, tmp_path, monkeypatch): + monkeypatch.setenv('TWINKLE_WORLD_ROOT', str(world_root)) + monkeypatch.setenv('TWINKLE_WORLD_SEARCH_ROOT', str(search_root)) + world = HostWorld() + world.load() + env = WorldLocalEnv(workspace=str(tmp_path / 'slot'), world=world) + + env.step('web_search', {'query': 'auth outage', 'num_results': 2}) + assert len(world.receipts) == 1 + receipt = world.receipts[0] + assert receipt['tool'] == 'web_search' + assert receipt['query'] == 'auth outage' + served = {u['url'] for u in receipt['urls']} + assert 'https://example.com/a' in served + # The date travels with the url: a check that only asks "was it served" + # cannot tell a fresh source from a stale one. + assert {u['url']: u['published'] for u in receipt['urls']}['https://example.com/a'] == '2026-09-01' + + +def test_fetch_page_records_the_page_it_served(world_root, search_root, tmp_path, monkeypatch): + monkeypatch.setenv('TWINKLE_WORLD_ROOT', str(world_root)) + monkeypatch.setenv('TWINKLE_WORLD_SEARCH_ROOT', str(search_root)) + world = HostWorld() + world.load() + env = WorldLocalEnv(workspace=str(tmp_path / 'slot'), world=world) + + env.step('fetch_page', {'url': 'https://example.com/b', 'query': 'ledger'}) + assert [u['url'] for r in world.receipts for u in r['urls']] == ['https://example.com/b'] + + +def test_a_failed_fetch_is_not_a_receipt_for_the_page_it_named(world_root, search_root, tmp_path, + monkeypatch): + monkeypatch.setenv('TWINKLE_WORLD_ROOT', str(world_root)) + monkeypatch.setenv('TWINKLE_WORLD_SEARCH_ROOT', str(search_root)) + world = HostWorld() + world.load() + env = WorldLocalEnv(workspace=str(tmp_path / 'slot'), world=world) + + result = env.step('fetch_page', {'url': 'https://example.com/does-not-exist'}) + assert result.observation.startswith('fetch_page:') + # The call is logged, but it carries no url -- which is the part a citation + # check reads. "I fetched this" needs evidence, and a failed fetch is not it. + assert len(world.receipts) == 1 + assert world.receipts[0]['tool'] == 'fetch_page' + assert world.receipts[0]['urls'] == [] + + +def test_sql_and_doc_calls_produce_no_receipt(env): + # Receipts exist so a citation can be checked. A tool that serves no url has + # nothing to cite, so recording one would only dilute the log. + env.step('world_sql', {'query': 'SELECT 1', 'params': []}) + env.step('world_doc', {'name': 'runbook.md'}) + assert env.world.receipts == [] + + +# -------------------------------------------------------------------- lifetime + + +def test_close_does_not_release_a_shared_world(env): + released = [] + env.world.release = lambda: released.append(True) + env.close() + assert released == [] + + +def test_close_releases_the_world_when_this_env_owns_it(world_root, tmp_path, monkeypatch): + monkeypatch.setenv('TWINKLE_WORLD_ROOT', str(world_root)) + world = HostWorld() + world.load() + released = [] + world.release = lambda: released.append(True) + env = WorldLocalEnv(workspace=str(tmp_path / 'slot'), world=world, release_world_on_close=True) + env.close() + assert released == [True] From 425276b50af17e0c3b510be23ace3c671b377c9b Mon Sep 17 00:00:00 2001 From: Wings-Of-Disaster <1327040545@qq.com> Date: Wed, 21 Oct 2026 13:46:53 +0800 Subject: [PATCH 2/2] fix(envs): isolate world state and trim repeated explanations Give each slot a shallow fork of loaded world data with independent retrieval receipts and database credentials. Owned worlds discard those mutable fields when an environment is reset, preventing one episode from inheriting another's retrieval evidence or role. The cookbook opts into this ownership explicitly. Remove the unused split_calls helper; WorldToolsMixin already dispatches batches while preserving indices. Keep manifest validation, all seven tool schemas, 13 profiles, all workspace seed programs and the tool-alias limitation intact. Condense repeated design discussion into API and deployment guidance. Validation: world/slot targeted run passed 39 cases, including world-fork and reset regressions; no training or broad regression suite was rerun. --- src/twinkle_agentic/envs/profiles.py | 111 +-------- src/twinkle_agentic/envs/seeds.py | 37 +-- src/twinkle_agentic/envs/world.py | 274 ++++------------------ tests/twinkle_agentic/test_world_tools.py | 22 ++ 4 files changed, 80 insertions(+), 364 deletions(-) diff --git a/src/twinkle_agentic/envs/profiles.py b/src/twinkle_agentic/envs/profiles.py index a0d8ac418..a685e4355 100644 --- a/src/twinkle_agentic/envs/profiles.py +++ b/src/twinkle_agentic/envs/profiles.py @@ -1,5 +1,11 @@ # Copyright (c) ModelScope Contributors. All rights reserved. -"""Immutable sandbox profile manifests and least-privilege resolution.""" +"""Validated profile manifests, tool allowlists and reproducible workspace setup. + +Compose ProfiledEnvMixin before WorldToolsMixin and the base environment. +Aliases such as glob -> run_command grant the full command tool; they are +not fine-grained shell permissions. Setup templates use literal replacement +of {workspace!r} so dictionaries and comprehensions remain valid Python. +""" from __future__ import annotations import hashlib @@ -230,11 +236,6 @@ def default_profile_registry() -> ProfileRegistry: )) registry.register( SandboxProfile( - # agentenv, not a new `lean` session kind: the compiler runs inside the - # sandbox through the same shell the other profiles use, so there is - # nothing for a new kind to enable. `session_kind='lean'` was rejected by - # open_profile_session outright, which made the profile unreachable - # regardless of whether Lean itself worked. name='lean-proof', version=1, session_kind='agentenv', @@ -266,12 +267,6 @@ def default_profile_registry() -> ProfileRegistry: setup_manifest=('clear_workspace', 'python-test-runner-canary'), runtime_ref='twinkle-rsi-msagent', )) - # ------------------------------------------------------------------ - # Seeded profiles. Each one starts the agent from a real state instead of an - # empty directory, so the task can require reading before acting. The setup - # manifest names the seed program that builds it (seeds.py) and then - # the canary that proves the state is usable. - # ------------------------------------------------------------------ registry.register( SandboxProfile( name='sql-analytics', @@ -351,15 +346,6 @@ def default_profile_registry() -> ProfileRegistry: name='world-forensics', version=1, session_kind='agentenv', - # The seeded profile that also reads the SHARED world. The distinction - # matters: sql-analytics copies a database into the workspace, so the model - # reads a private file. This one reads a dataset it never receives -- the - # call is answered on the training host, so nothing is copied in and the - # sandbox still has no network. - # - # That is what makes the answer unfakeable. A seeded file can be opened, - # edited, and reasoned about as local material; a shared one can only be - # queried, and what comes back is what the world holds. capabilities=('database_query', 'document_retrieval', 'log_analysis'), task_forms=('incident_analysis', 'cross_source_reconciliation'), allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep', 'world_sql', @@ -378,11 +364,6 @@ def default_profile_registry() -> ProfileRegistry: name='db-analyst', version=1, session_kind='agentenv', - # Reads a REAL PostgreSQL service over the network, with an account the - # service issues to this episode alone. Different from world-forensics, - # which reads a SQLite file that ships with the run: here the model has to - # connect to infrastructure and stay inside the grant it was handed, and - # the grant is enforced by PostgreSQL rather than by the sandbox. capabilities=('database_query', 'database_schema'), task_forms=('database_analysis', 'grant_aware_query'), allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep', 'db_query', @@ -400,25 +381,8 @@ def default_profile_registry() -> ProfileRegistry: name='web-research', version=1, session_kind='agentenv', - # Now agentenv rather than broker: the search tools are answered on the - # training host, exactly like the world tools, so there is no separate - # session kind to enable and no sandbox network to open. The broker kind - # was never implemented (`open_profile_session` rejects it outright), so - # the profile used to be unavailable for a reason that had nothing to do - # with search providers -- it was unreachable by construction. capabilities=('web_search', 'web_fetch', 'citation'), task_forms=('web_research', 'source_verification'), - # Both search tools, not just `web_search`. An earlier design served - # them from inside the sandbox, where only one existed, so `fetch_page` - # was dropped from this list and no profile could reach it. They are - # answered on the training host now (world.SEARCH_TOOL_SCHEMAS), so - # both are advertised and both are allowed. - # - # `fetch_page` is the half that makes `web_receipt` worth scoring: a - # search result carries an excerpt, and a policy can quote an excerpt - # it was shown without ever reading the page. Requiring the source to - # have been fetched is what separates "cited what it read" from - # "cited what it was handed". allowed_tools=('shell_executor', 'python_executor', 'read_file', 'write_file', 'glob', 'grep', 'web_search', 'fetch_page'), # web_receipt is the one that matters here: it checks a cited url against @@ -457,19 +421,6 @@ def default_profile_registry() -> ProfileRegistry: return registry -# A profile names tools in the vocabulary of the agent runtime that served them, -# which is finer than what an Env declares: `shell_executor`, `python_executor`, -# `glob` and `grep` are four names there and one `run_command` here. The table is -# data rather than logic so the coarseness is visible in one place instead of -# being spread across conditionals. -# -# The consequence is worth stating plainly: on an Env whose only command tool is -# `run_command`, a profile that allows `glob` allows a shell. Least privilege at -# the granularity these profiles were written for needs an environment that -# declares the finer tools; what this mixin guarantees on a coarse one is that -# nothing outside the profile is reachable at all. -# The one placeholder a seed program may contain. Kept as a literal so the -# substitution below is exact and leaves every other brace alone. _WORKSPACE_PLACEHOLDER = '{workspace!r}' TOOL_ALIASES: dict[str, tuple[str, ...]] = { @@ -493,30 +444,7 @@ def env_names_for(profile: SandboxProfile) -> frozenset: class ProfiledEnvMixin: - """Restrict an environment to what a profile permits, and seed its workspace. - - Mix in front of any :class:`~twinkle_agentic.envs.base.Env`, and ahead of - :class:`~twinkle_agentic.envs.world.WorldToolsMixin` when both are used, so - that the world's tools are filtered by the profile too:: - - class Slot(ProfiledEnvMixin, WorldToolsMixin, LocalEnv): - pass - - Three things it does: - - * :meth:`tools` advertises only what the active profile allows, so a policy - is never offered a tool it would be refused. - * :meth:`step` and :meth:`step_batch` refuse anything else, because - advertising is not enforcement -- a policy can name a tool it was not - shown. - * :meth:`switch_profile` moves the environment onto another profile and runs - that profile's setup manifest, which is what puts the fixture in the - workspace before the episode starts. - - With no profile set the environment behaves exactly as it did before: every - declared tool is advertised and nothing is refused. That is what makes this - safe to mix into an environment whose caller has no profiles. - """ + """Filter tools and dispatch by profile; clear() prepares its workspace fixtures.""" def __init__(self, *args: Any, @@ -543,12 +471,7 @@ def manifest_results(self) -> list[tuple[str, int, str]]: return list(self._manifest_results) def switch_profile(self, profile_id: str) -> SandboxProfile: - """Move onto ``profile_id`` and run its setup manifest. - - Raises rather than degrading: an environment left on the wrong profile - produces tasks whose fixture is missing, and every one of them reads as a - hard task rather than as a broken slot. - """ + """Select an available profile and run its setup; reject retired or unknown profiles.""" profile = self._profiles.get(profile_id) if not profile.available: raise ValueError(f'profile {profile_id!r} is not available: {profile.unavailable_reason}') @@ -611,11 +534,7 @@ def step_batch(self, calls: Sequence[tuple[str, dict[str, Any]]]) -> list[StepRe return [r if r is not None else StepResult(observation='Error: no result') for r in out] def clear(self) -> None: - """Empty the workspace, then run the active profile's setup manifest. - - Order matters and is the reason this overrides rather than supplements: - a seed written before the clear is a seed deleted by it. - """ + """Clear the workspace before running the active profile's setup manifest.""" super().clear() if self._profile is None: return @@ -627,19 +546,9 @@ def clear(self) -> None: for step in self._profile.setup_manifest: source = self._manifest_step(step) if source is None: - # Raise rather than record and carry on: a manifest step that - # does not resolve is a typo in the profile, and continuing - # would leave the environment missing the fixture every task - # built on it assumes. Each of those then reads as an unsolvable - # task instead of as a broken profile. raise RuntimeError(f'profile {self._profile.profile_id} names an unknown setup ' f'step {step!r}; known steps are clear_workspace, ' f'{sorted(SEED_PROGRAMS)} and {sorted(LIBRARY_CANARIES)}') - # The seed programs are templates, not source: each names the - # workspace it is to build in. Substituted by exact replacement - # rather than str.format, because the programs are real python and - # contain set and dict comprehensions whose braces format() would - # read as replacement fields. exit_code, output = self.run_script(source.replace(_WORKSPACE_PLACEHOLDER, repr(workspace)), 'python') self._manifest_results.append((step, exit_code, output)) if exit_code != 0: diff --git a/src/twinkle_agentic/envs/seeds.py b/src/twinkle_agentic/envs/seeds.py index 506f2be72..c99daf594 100644 --- a/src/twinkle_agentic/envs/seeds.py +++ b/src/twinkle_agentic/envs/seeds.py @@ -1,32 +1,7 @@ # Copyright (c) ModelScope Contributors. All rights reserved. -"""Initial state for sandbox profiles -- the programs that seed a workspace. +"""Workspace fixture programs and dependency canaries used by sandbox profiles. -Why this exists ---------------- -Every task this loop used to generate started from an EMPTY directory. The solver -prompt says so out loud ("Your working directory starts COMPLETELY EMPTY. Every -file the task mentions -- including files it describes as inputs -- does not exist -yet"), which means an empty-workspace task asks the model to invent its own inputs -and then compute over them. Grading that measures self-consistency as much as -capability: the numbers were the model's own choice, so being right about them is -a weaker claim than being right about data someone else fixed. - -These programs give a profile a real starting state instead. They run through the -sandbox's own python tool after ``clear_workspace`` and before the agent sees -anything, so the agent must READ a schema, a file, or a repo before it can act. - -Two rules every seed here follows ---------------------------------- -1. **Byte-deterministic.** The same seed must produce identical bytes, because two - rollouts of one task are graded against the same input. No ``random``, no clock, - no locale-dependent formatting, no dict-iteration-order dependence. -2. **Idempotent.** Re-running replaces rather than appends, so a resumed or retried - episode cannot inherit half of a previous seed. - -Neither of these is the same as "easy". ``seed-sql-analytics`` plants a NULL region -and a duplicate order; ``seed-csv-dirty`` writes a BOM and a quoted comma; -``seed-docs-corpus`` includes a document that states the *superseded* answer. The -difficulty is in the data, not in a missing tool. +Replace only the literal {workspace!r} token when executing a program. """ __all__ = ['CLEAR_WORKSPACE', 'LIBRARY_CANARIES', 'SEED_PROGRAMS'] @@ -40,14 +15,6 @@ 'python-test-runner-canary': 'import unittest\nprint("profile canary ok")', } -# Emptied through the python tool rather than `rm -rf`: ms-agent's safety policy -# rejects `rm -rf`, and it rejects globs in write operations, which rules out -# `find -delete`. The assert is what makes a future policy change surface as a -# failed reset instead of one episode inheriting another's workspace. -# -# Lives here, not in sandbox.py, because sandbox.py already imports from episode.py -# and episode.py needs this constant -- keeping it beside the seeds is what stops -# that pair from becoming a cycle. CLEAR_WORKSPACE = ''' import os, shutil root = {workspace!r} diff --git a/src/twinkle_agentic/envs/world.py b/src/twinkle_agentic/envs/world.py index fb4dca963..5e1868a49 100644 --- a/src/twinkle_agentic/envs/world.py +++ b/src/twinkle_agentic/envs/world.py @@ -1,78 +1,24 @@ # Copyright (c) ModelScope Contributors. All rights reserved. -"""A read-only world served from the training host, not from the sandbox. - -An agentic RSI run needs tasks whose answers the policy cannot invent. A task -that says "report the failure rate in the ops database" is only a real task if -the database exists and the policy has to read it; otherwise the policy writes -plausible numbers and a check that compares strings passes either way. - -This module is that world: a SQLite database, a document set, and a search -corpus, exposed as tools the model calls exactly like the sandbox's own. - -Why host-side -------------- -A tool call already round-trips. :meth:`Env.step_batch` hands the call to the -environment and brings the answer back, so the training host sees every call -before and after the sandbox does. A tool that lives on the host therefore needs -no network path *into* the sandbox, which is what makes this the right place: - -* no egress from the sandbox. An environment configured with no network keeps - that property: a model that could reach the host's network could scan it, a - model that can only call a named tool cannot; -* no reverse tunnel and no new listening port to defend; -* the data never enters the sandbox, so it cannot be edited, copied out of - scope, or left behind in a snapshot. - -The cost is one extra hop, and it is not on the hot path: a tool call is already -a sandbox round trip, and this one answers from a local read-only handle. - -What the model sees -------------------- -Seven tools, in three groups, advertised like any other: - -``world_sql`` / ``world_schema`` / ``world_doc`` - read-only SQL over ``ops.db``, its schema, and the document set. -``web_search`` / ``fetch_page`` - retrieval over a frozen corpus, or a live engine if one is configured. -``db_query`` / ``db_schema`` - a real SQL server, on a per-episode role rather than a shared login. - -Every retrieval answer is recorded as a *receipt* (:attr:`HostWorld.receipts`). -That is what makes a citation checkable: ``web_receipt`` in -:mod:`twinkle_agentic.verifier.result_check` can ask "was this url actually -served to this episode?" and get an answer from what happened rather than from -what the policy wrote down. - -Configuration is by environment variable, because a library does not guess where -its data lives: - -=============================== ============================================ -``TWINKLE_WORLD_ROOT`` directory holding ``manifest.json``, ``ops.db`` - and ``docs/``. Required; without it - :attr:`HostWorld.available` is False. -``TWINKLE_WORLD_SEARCH_ROOT`` directory holding ``corpus.json`` and its - ``manifest.json``. Optional; without it the two - search tools report themselves unavailable. -``TWINKLE_WORLD_SEARCH_BACKEND````corpus`` (default) or ``live``. -``TWINKLE_WORLD_SEARCH_MODULE`` directory holding ``search_backend.py``. - Required when the backend is ``live``. -``TWINKLE_WORLD_DB_MODULE`` directory holding the ``provision`` module that - mints and drops per-episode database roles. - Required only when ``db_enabled``. -``TWINKLE_WORLD_DB_HOST`` database host, default ``127.0.0.1``. -``TWINKLE_WORLD_DB_PORT`` database port, default ``5432``. -``TWINKLE_WORLD_DB_LEASE`` role lease seconds, default ``21600``. -=============================== ============================================ - -A live search backend is a plug-in rather than a dependency: the world imports -``search_backend`` from ``TWINKLE_WORLD_SEARCH_MODULE`` and calls -``Backend().web_search(query, num_results, freshness)`` and -``Backend().fetch_page(url, query)``, both returning the observation string the -tool shows the model. Which provider sits behind that class is the deployment's -choice, and nothing in this repository names one. +"""Host-served read-only SQLite, document and search tools. + +TWINKLE_WORLD_ROOT names a bundle containing manifest.json, ops.db and docs/. +TWINKLE_WORLD_SEARCH_ROOT optionally names a hashed corpus.json bundle. +TWINKLE_WORLD_SEARCH_BACKEND selects corpus (default) or a live backend. +For live search, TWINKLE_WORLD_SEARCH_MODULE contains search_backend.py with +Backend.web_search(query, num_results, freshness) and fetch_page(url, query), +both returning observation strings. + +Database tools require db_enabled and TWINKLE_WORLD_DB_MODULE containing +provision(host, port, ttl=...) -> credentials and drop(role). Host, port and +lease use TWINKLE_WORLD_DB_HOST / _PORT / _LEASE, defaulting to +127.0.0.1 / 5432 / 21600. The deployment supplies permissions and credentials. + +WorldToolsMixin dispatches these tools through the existing Env interface. +Search receipts record served URLs; they do not establish source correctness. """ from __future__ import annotations +import copy import json import os import re @@ -174,14 +120,6 @@ def _terms(text: str) -> set: }, ] -# Search tools. Named and shaped after ms-agent's own web_search/fetch_page so -# swapping the frozen corpus for a live engine is a change of one implementation -# and nothing else -- no task, no profile, no schema moves. -# -# The ranking is not trustworthy and the description says so. An engine that -# returned only good results would train a policy that believes the first hit; -# a task can then be written to require verification, and the policy meets the -# unreliability where it belongs -- in the episode, not in production. SEARCH_TOOL_SCHEMAS: list[dict[str, Any]] = [ { 'type': 'function', @@ -245,11 +183,6 @@ def _terms(text: str) -> set: }, ] -# The shared PostgreSQL service. Separate from the world_* tools above: those -# read a SQLite file that ships with the run, these talk to a real server over -# TCP with a per-sandbox account. Both exist because they are different exercises -# -- reading a file you were handed, versus connecting to infrastructure and -# working within the grant you were given. DB_TOOL_SCHEMAS: list[dict[str, Any]] = [ { 'type': 'function', @@ -302,11 +235,7 @@ def _terms(text: str) -> set: def _world_name(tool_name: str | None) -> str | None: - """The world tool this call names, or None if it is not one of ours. - - Strips the ``---`` suffix a harness adds to disambiguate same-named - tools, so a suffixed call still resolves. - """ + """Resolve a plain tool name or a harness-qualified server---tool name.""" if not tool_name: return None short = tool_name.rsplit('---', 1)[-1] @@ -318,15 +247,7 @@ class WorldUnavailable(RuntimeError): class HostWorld: - """Read-only access to the built world, straight off the filesystem. - - Deliberately does NOT go through world_service.py. The service exists for the - case where something other than this process needs to read the world; the host - already has the files, so routing an in-process call through HTTP would add a - port to secure and a failure mode to handle for nothing. Both read the same - database with the same read-only handle, so a query behaves identically either - way. - """ + """Read a world bundle on the host; mutable receipts and credentials belong to this instance.""" def __init__(self, root: str | None = None): # A library does not guess where its data lives. Without an explicit root @@ -352,15 +273,16 @@ def __init__(self, root: str | None = None): # is opted into per run because its answers change by the hour. self.backend = 'corpus' self.live = None - # The retrieval calls this world actually served, in order -- `web_search` - # and `fetch_page` only, which is what `_receipt` records. The point is - # that a citation check can ask "did you really retrieve this?" and get an - # answer from what happened rather than from what the policy wrote down. A - # policy that invents a url has nothing here to match against. The SQL and - # document tools leave no receipt: they return values, not sources, so - # there is no citation to check them against. self.receipts: list[dict[str, Any]] = [] + def fork(self, *, db_enabled: bool = False) -> HostWorld: + """Share loaded data while keeping receipts and database roles independent.""" + world = copy.copy(self) + world.receipts = [] + world._db_creds = None + world.db_enabled = db_enabled + return world + def _load_search(self) -> None: # Only the explicit env var is honoured. Walking up from __file__ to find # a corpus was tried and dropped: how many parents to count depends on @@ -418,14 +340,7 @@ def available(self) -> bool: return self.db_path.is_file() and bool(self.docs or (self.root / 'docs').is_dir()) def schemas(self) -> list[ToolInfo]: - """The tools this world can actually answer right now. - - Advertising a tool that always answers "not configured" is worse than not - advertising it: the policy spends a turn discovering that, and a - difficulty measurement built on the episode counts the wasted turn as - part of the task. So each group is offered only when its backing store - is present. - """ + """Advertise only tools whose backing data or database account support is enabled.""" out: list[ToolInfo] = [] if self.available: out.extend(TOOL_SCHEMAS) @@ -436,12 +351,7 @@ def schemas(self) -> list[ToolInfo]: return out def load(self) -> None: - """Read the manifest and verify the files still match it. - - Checked once per process at startup. The world is read-only, so a match - now is a match for the run; hashing a 100 KB database per query would be - paid on every tool call for no added safety. - """ + """Validate manifest hashes and load documents and optional search at startup.""" if self.root is None: raise WorldUnavailable('no world root configured: pass root=... to HostWorld or set ' 'TWINKLE_WORLD_ROOT to the directory holding manifest.json, ops.db ' @@ -477,11 +387,6 @@ def sql(self, query: str, params: Any) -> str: if not isinstance(params, (list, tuple)): return 'world_sql: `params` must be a list.' head = query.strip().lstrip('(').split(None, 1)[0].upper() - # Refused as a class. Measured: `PRAGMA journal_mode=WAL` and - # `PRAGMA user_version=99` are rejected by the read-only handle, but - # `PRAGMA journal_mode=DELETE` returns a row and reads like success -- a - # no-op only because the file is already in delete mode, which is a - # coincidence. None of them are needed to answer a question about the data. if head == 'PRAGMA': return 'world_sql: PRAGMA is not available on the shared world.' try: @@ -629,16 +534,7 @@ def fetch_page(self, url: str, query: str = '') -> str: # ---- the shared PostgreSQL service -------------------------------- def _db_credentials(self) -> dict[str, Any] | None: - """This episode's own database account, created on first use. - - One account per episode, not one shared read-only login: with a shared - login, "read-only" is a promise about every sandbox's behaviour. With a - per-episode role it is a grant the server enforces, and the blast radius - of a bug is one episode. - - Created lazily so that a run which never uses the database never creates - an account, and dropped in `close()`. - """ + """Lazily provision this instance's database role; release() drops it.""" if self._db_creds is not None: return self._db_creds if not self.db_enabled: @@ -669,20 +565,6 @@ def _db_connect(self): user=creds['role'], password=creds['password'], connect_timeout=10) - # autocommit on, and NOT set_session(readonly=True). - # - # readonly=True was the obvious belt-and-braces move and it was wrong: a - # read-only transaction refuses CREATE TEMP TABLE AS, which the grant - # deliberately allows so a solver has somewhere to work. The two guards - # disagreed, and the one that fired was the one that removed a capability - # the profile promised -- the model would have been told it could use a - # scratch table and then been refused. - # - # The GRANT is the enforcement, and it is the stronger one: it is a - # property of the role recorded in the catalogue, it survives this process - # dying, and it cannot be lifted by anything the model sends. Verified in - # test_db.py -- insert, update, delete, truncate, drop, create table, - # alter and create schema are all refused by PostgreSQL itself. conn.autocommit = True return conn @@ -753,16 +635,7 @@ def db_schema(self, table: str = '') -> str: return '\n'.join(lines) def release(self) -> None: - """Drop this episode's database account, if one was made. - - On HostWorld rather than only on the env that owns it: the account belongs - to this object, and every caller that can create one has to be able to - release it. Keeping it on the env alone meant a second caller would have - had to reimplement the drop. - - Safe to call more than once. An episode killed before it gets here never - calls it at all -- that is what the lease and the reaper are for. - """ + """Drop this instance's database role, if any. Safe to call repeatedly.""" if self._db_creds is None: return role = self._db_creds['role'] @@ -829,14 +702,7 @@ def _answer(self, tool_name: str, arguments: dict[str, Any]) -> str | None: return None def call(self, tool_name: str, arguments: dict[str, Any]) -> str | None: - """Answer one world tool call, or None if the name is not ours. - - The receipt is written here rather than in the caller so that every path - that answers a search tool records it: a check that trusted the caller to - remember would silently stop working the first time a second caller - appeared. A failed call records nothing for the url it failed on, which is - what makes "I fetched this" a claim with evidence behind it. - """ + """Dispatch a tool and record search receipts. Unknown names return None.""" arguments = arguments or {} result = self._answer(tool_name, arguments) if result is not None: @@ -844,58 +710,13 @@ def call(self, tool_name: str, arguments: dict[str, Any]) -> str | None: return result -def split_calls(calls: Sequence[tuple[str, dict[str, Any]]], - world: HostWorld) -> tuple[list[tuple[int, str]], list[tuple[str, dict[str, Any]]]]: - """Partition one turn's calls into (host-answered, sandbox-bound). - - Batch-aware on purpose: a turn can mix a world call with shell commands, and - the sandbox request must carry only the calls it can actually run. Order is - preserved by index so the results can be reassembled positionally -- GRPO - rewards do not care about order, but a transcript that pairs an observation - with the wrong call teaches the policy something false. - """ - host: list[tuple[int, str]] = [] - sandbox: list[tuple[str, dict[str, Any]]] = [] - for index, (name, args) in enumerate(calls): - short = name.rsplit('---', 1)[-1] - if short in TOOL_NAMES: - host.append((index, short)) - else: - sandbox.append((name, args)) - return host, sandbox - - class WorldToolsMixin: - """Serve host-side world tools alongside whatever an environment provides. - - Mix in front of any :class:`~twinkle_agentic.envs.base.Env`:: - - class WorldLocalEnv(WorldToolsMixin, LocalEnv): - pass - - env = WorldLocalEnv(workspace='/tmp/slot0', world=HostWorld(root)) + """Add host tools before the underlying Env in the MRO. - The mixin answers the world's own tool names from the training host and - forwards everything else to the environment untouched. It adds one attribute - (:attr:`world`) to the public surface, so an environment that already works - keeps working -- and because it is a mixin rather than a change to - ``AgentEnv``, it applies to ``LocalEnv`` and ``OpenEnv`` as well, which is - what makes it testable without a sandbox host. - - Two things that are easy to get wrong: - - * **The mixin goes first.** ``class X(WorldToolsMixin, LocalEnv)``, not the - reverse: it has to precede the environment in the MRO for its ``tools``, - ``step`` and ``step_batch`` to be the ones called. - * **Tool names may arrive suffixed.** A harness that disambiguates - same-named tools across environments appends ``---``. Both - :meth:`step` and :meth:`step_batch` strip that before matching, so a - suffixed world call is answered here instead of being forwarded to a - sandbox that has no such tool. - - The world's lifetime is the caller's, not the mixin's: several environments - may share one read-only world, so ``close()`` does not release it. Pass - ``release_world_on_close=True`` on the environment that owns it. + Use class Slot(WorldToolsMixin, LocalEnv). When combined with profiles, + ProfiledEnvMixin must precede WorldToolsMixin so it filters world tools too. + By default the caller owns world cleanup; release_world_on_close=True makes + this environment responsible for releasing its world. """ def __init__(self, @@ -930,17 +751,7 @@ def step(self, tool_name: str, arguments: dict[str, Any] = None) -> StepResult: return super().step(tool_name, arguments) def step_batch(self, calls: Sequence[tuple[str, dict[str, Any]]]) -> list[StepResult]: - """Answer the world's calls here, and send the rest as one batch. - - Worth overriding rather than leaving to the serial default: the point of - ``step_batch`` is keeping tool execution off the generation critical - path, and a turn that mixes one world call with three shell commands - should still send those three as a single request. - - Positions are tracked explicitly rather than inferred from order, so a - transcript cannot pair an observation with the wrong call -- which would - teach the policy something false without anything downstream noticing. - """ + """Dispatch host and sandbox calls separately, preserving their original order.""" if self._world is None: return super().step_batch(calls) host_at: list[int] = [] @@ -969,7 +780,7 @@ def step_batch(self, calls: Sequence[tuple[str, dict[str, Any]]]) -> list[StepRe return [r if r is not None else StepResult(observation='Error: no result') for r in out] def _answer_world(self, tool_name: str, arguments: dict[str, Any]) -> str | None: - """The world's answer, or None when this call is not the world's.""" + """Return a host observation, or None for a non-world tool.""" if self._world is None: return None name = _world_name(tool_name) @@ -986,3 +797,10 @@ def close(self) -> None: finally: if self._release_world_on_close and self._world is not None: self._world.release() + + def clear(self) -> None: + """Discard the owned world's previous episode state before reusing a slot.""" + if self._release_world_on_close and self._world is not None: + self._world.release() + self._world.receipts.clear() + super().clear() diff --git a/tests/twinkle_agentic/test_world_tools.py b/tests/twinkle_agentic/test_world_tools.py index 095bc947c..b3043375e 100644 --- a/tests/twinkle_agentic/test_world_tools.py +++ b/tests/twinkle_agentic/test_world_tools.py @@ -279,6 +279,28 @@ def test_sql_and_doc_calls_produce_no_receipt(env): # -------------------------------------------------------------------- lifetime +def test_world_forks_keep_receipts_and_roles_separate(env): + first = env.world.fork(db_enabled=True) + second = env.world.fork() + first.call('fetch_page', {'url': 'https://example.com/missing'}) + assert len(first.receipts) == 1 + assert second.receipts == env.world.receipts == [] + first._db_creds = {'role': 'first'} + assert second._db_creds is None + assert first.db_enabled and not second.db_enabled + assert first.docs is second.docs + + +def test_owned_world_state_is_cleared_between_episodes(env): + env._release_world_on_close = True + released = [] + env.world.release = lambda: released.append(True) + env.step('fetch_page', {'url': 'https://example.com/missing'}) + env.clear() + assert released == [True] + assert env.world.receipts == [] + + def test_close_does_not_release_a_shared_world(env): released = [] env.world.release = lambda: released.append(True)