From d80fb7e50b0e3d6b1d022aa527dbec7d40851aa4 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 16:43:30 +0800 Subject: [PATCH 01/15] feat(sdk): compose virtual machine execution profiles --- opencollab/adapters/session_environments.py | 2 + .../bootstrap/_workflow_runtime_session.py | 2 + opencollab/bootstrap/authored_workflow.py | 30 ++++- opencollab/bootstrap/config.py | 8 ++ opencollab/bootstrap/environment_profiles.py | 73 ++++++++++ opencollab/bootstrap/isolation.py | 4 +- opencollab/environments.py | 5 + tests/interfaces/test_vm_profiles.py | 127 ++++++++++++++++++ 8 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 opencollab/bootstrap/environment_profiles.py create mode 100644 tests/interfaces/test_vm_profiles.py diff --git a/opencollab/adapters/session_environments.py b/opencollab/adapters/session_environments.py index d0b769ea..9290f649 100644 --- a/opencollab/adapters/session_environments.py +++ b/opencollab/adapters/session_environments.py @@ -65,6 +65,8 @@ def child_pool(self, environment: EnvironmentPort, *, role: str) -> SessionEnvir def _validate(self, name: str) -> None: isolation_name(name) if name not in {"shared", "worktree"} and name not in self._providers: + if name in {"vm-group", "vm-session"}: + raise ValueError(f"isolation strategy {name!r} requires VM resources in config.vm or an environment provider") raise ValueError(f"isolation strategy {name!r} needs an environment provider") def _pool(self, name: str, source: EnvironmentPort) -> WorktreePoolPort: diff --git a/opencollab/bootstrap/_workflow_runtime_session.py b/opencollab/bootstrap/_workflow_runtime_session.py index 5e0ceab1..07b1d617 100644 --- a/opencollab/bootstrap/_workflow_runtime_session.py +++ b/opencollab/bootstrap/_workflow_runtime_session.py @@ -33,6 +33,7 @@ resolve_model_route, resolve_thinking_params, ) +from opencollab.bootstrap.environment_profiles import configured_environment_providers from opencollab.bootstrap.session_factory import ( build_session, validate_responses_model_controls, @@ -529,6 +530,7 @@ def build_workflow_context( ephemeral. """ environment = env if env is not None else _WORKFLOW_ENV_OVERRIDE.get() + environment_providers = configured_environment_providers(cfg, environment_providers) factory = WorkflowSessionFactory( llm=llm, session_builder=session_builder, diff --git a/opencollab/bootstrap/authored_workflow.py b/opencollab/bootstrap/authored_workflow.py index 53901b17..c66a1432 100644 --- a/opencollab/bootstrap/authored_workflow.py +++ b/opencollab/bootstrap/authored_workflow.py @@ -19,6 +19,7 @@ from opencollab.adapters.tools._paths import checked_path from opencollab.adapters.tools.base import Tool from opencollab.application.async_timeout import await_owned_operation +from opencollab.application.isolation import isolation_name from opencollab.application.tool_execution import ToolRuntime from opencollab.bootstrap.authored_workflow_bridge import AuthoredWorkflowBridge from opencollab.bootstrap.tool_registry import TOOL_LIMIT_FIELDS @@ -69,6 +70,7 @@ class AuthoredWorkflowTool(Tool): "python": {"type": "string", "default": "python3"}, "budget": {"type": "integer", "minimum": 1}, "concurrency": {"type": "integer", "minimum": 1, "default": 4}, + "isolation": {"type": "string", "description": "launch only. Default environment strategy for child agents. Omitted inherits the member strategy."}, "timeout": {"type": "number", "minimum": 0, "description": "Optional wait duration. Expiry leaves the child running."}, }, @@ -96,6 +98,8 @@ async def execute_with_runtime(self, params: dict[str, Any], runtime: ToolRuntim parent.own_runtime(self.aclose, reserve_tokens=self._reserved_tokens) self._registered = True action = params["action"] + if action != "launch" and "isolation" in params: + raise ValueError("isolation is only supported by the launch action") if action == "launch": async with self._launch_lock: child = await self._launch(params, runtime) @@ -156,6 +160,19 @@ async def _launch(self, params: dict[str, Any], runtime: ToolRuntime) -> _Child: if remaining <= 0: raise ValueError("Team session token budget is exhausted or reserved by child runs") budget = max(1, remaining // 2) if budget is None else min(budget, remaining) + pool = (self._environment_pool_factory(environment, role=parent.agent.name) + if self._environment_pool_factory is not None else SessionEnvironmentPool( + environment.workspace, isolation="shared", base_environment=environment, + )) + strategy = None + try: + if "isolation" in params: + strategy = isolation_name(params["isolation"]) + if strategy not in pool.strategy_names: + raise ValueError(f"isolation strategy {strategy!r} needs an environment provider") + except BaseException: + await pool.release() + raise child_id = f"child-{uuid.uuid4().hex}" artifacts = posixpath.join(environment.workspace, ".opencollab", "child-runs", child_id) child = _Child(child_id, environment, artifacts, budget) @@ -184,16 +201,21 @@ async def _launch(self, params: dict[str, Any], runtime: ToolRuntime) -> _Child: "opencollab.bootstrap.authored_workflow_runner"]) if runtime.safety_policy is not None: await runtime.safety_policy.check_cmd_interactive(command, runtime.confirm_fn()) - pool = (self._environment_pool_factory(environment, role=parent.agent.name) - if self._environment_pool_factory is not None else SessionEnvironmentPool( - environment.workspace, isolation="shared", base_environment=environment, - )) child.bridge = AuthoredWorkflowBridge( environment, artifacts, pool, config, runtime, allow_unisolated_shell=self._allow_unisolated_shell and not getattr(shell, "require_process_isolation", False), ) request["bridge"] = child.bridge.options() + if strategy is not None: + request["bridge"]["isolation"] = strategy + request["config"] = {key: value for key, value in config.items() if key in { + "model", "logical_model", "provider", "wire_protocol", "temperature", "top_p", + "thinking", "thinking_params", "thinking_params_explicit", "reasoning_effort", + "context_window", "max_output_tokens", "llm_timeout", "llm_max_retries", + "llm_connect_timeout", "llm_first_event_timeout", "llm_stream_idle_timeout", + "llm_stream_chat", "provider_error_time_budget", + }} request_bytes = json.dumps(request, allow_nan=False).encode("utf-8") self._children[child_id] = child child.task = asyncio.create_task(self._execute(child, command, request_bytes)) diff --git a/opencollab/bootstrap/config.py b/opencollab/bootstrap/config.py index a57fd31a..b6e7eccf 100644 --- a/opencollab/bootstrap/config.py +++ b/opencollab/bootstrap/config.py @@ -47,6 +47,7 @@ ) from opencollab.adapters.llm.types import DEFAULT_MAX_OUTPUT_TOKENS from opencollab.adapters.safe_files import read_regular_text +from opencollab.bootstrap.environment_profiles import VMProfile MAX_DOTENV_BYTES = 1024 * 1024 @@ -186,6 +187,7 @@ class OpenCollabConfig(BaseModel): auth_token: str | None = Field(default=None, repr=False) base_url: str | None = None model_routes: dict[str, ModelRoute] = Field(default_factory=dict, repr=False) + vm: VMProfile | None = Field(default=None, repr=False) request_pool: str | None = None request_capacity: int | None = Field(default=None, ge=1) budget: int = Field(default=1_000_000, ge=1) @@ -206,6 +208,11 @@ class OpenCollabConfig(BaseModel): provider_error_time_budget: float = Field(default=0.0, ge=0, allow_inf_nan=False) filter_messages: bool = Field(default=False) + @field_validator("vm", mode="before") + @classmethod + def _vm_profile(cls, value: Any) -> Any: + return json.loads(value) if isinstance(value, str) else value + @model_validator(mode="before") @classmethod def _record_thinking_params_source(cls, value: Any) -> Any: @@ -522,6 +529,7 @@ def resolve_ordered(*keys: str, default: str | None = None) -> str | None: "model_routes": None, "request_pool": None, "request_capacity": None, + "vm": None, "wire_protocol": "chat_completions", "budget": "1000000", "temperature": str(DEFAULT_TEMPERATURE), diff --git a/opencollab/bootstrap/environment_profiles.py b/opencollab/bootstrap/environment_profiles.py new file mode 100644 index 00000000..c79e5b37 --- /dev/null +++ b/opencollab/bootstrap/environment_profiles.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +import os +from collections.abc import Mapping +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from opencollab.bootstrap.vm import VMConnection, VMResource, vm_isolation_provider + + +class VMProfile(BaseModel): + model_config = ConfigDict(extra="forbid", hide_input_in_errors=True) + + resources: tuple[VMResource, ...] = Field(min_length=1) + lease_database: str + wait_timeout: float | None = Field(default=0, ge=0, allow_inf_nan=False) + + @field_validator("resources", mode="before") + @classmethod + def _resources(cls, value: Any) -> Any: + if not isinstance(value, (list, tuple)): + raise ValueError("VM resources must be a sequence") + resources = [] + for item in value: + if isinstance(item, Mapping): + item = dict(item) + connection = item.get("connection") + try: + if isinstance(connection, Mapping): + item["connection"] = VMConnection(**connection) + item = VMResource(**item) + except TypeError as exc: + raise ValueError(f"invalid VM resource configuration {exc}") from exc + if not isinstance(item, VMResource): + raise ValueError("VM resources must contain VMResource values or resource objects") + resources.append(item) + return tuple(resources) + + @field_validator("lease_database") + @classmethod + def _lease_database(cls, value: str) -> str: + if not os.path.isabs(value) or "\x00" in value: + raise ValueError("VM lease_database must be an absolute local path") + return value + + @field_validator("wait_timeout", mode="before") + @classmethod + def _wait_timeout(cls, value: Any) -> Any: + if isinstance(value, bool): + raise ValueError("VM wait_timeout must be a non-negative number or None") + return value + + +def configured_environment_providers( + config: Mapping[str, Any], providers: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + selected: dict[str, Any] = {} + value = config.get("vm") + if value is not None: + if isinstance(value, str): + value = json.loads(value) + profile = value if isinstance(value, VMProfile) else VMProfile.model_validate(value) + selected.update({ + f"vm-{scope}": vm_isolation_provider( + profile.resources, lease_database=profile.lease_database, + scope=scope, wait_timeout=profile.wait_timeout, + ) + for scope in ("group", "session") + }) + selected.update(providers or {}) + return selected diff --git a/opencollab/bootstrap/isolation.py b/opencollab/bootstrap/isolation.py index 8e566d35..52d3e6f5 100644 --- a/opencollab/bootstrap/isolation.py +++ b/opencollab/bootstrap/isolation.py @@ -4,6 +4,7 @@ from opencollab.adapters.session_environments import SessionEnvironmentPool from opencollab.application.isolation import isolation_name +from opencollab.bootstrap.environment_profiles import configured_environment_providers from opencollab.bootstrap.team_config import load_team_config @@ -14,7 +15,8 @@ async def prepare_scheduler(ctx: Any, *, builder: Any, isolation=None, ) selected = isolation_name(isolation, default=team.isolation) roles = {name: role.isolation for name, role in team.roles.items() if role.isolation is not None} - if selected is None and not roles: + environment_providers = configured_environment_providers(ctx.config, environment_providers) + if selected is None and not roles and not environment_providers: return builder(ctx, **kwargs) pool = SessionEnvironmentPool( ctx.workspace, isolation=selected or ("worktree" if kwargs["use_worktrees"] else "shared"), diff --git a/opencollab/environments.py b/opencollab/environments.py index bf16efab..c282632c 100644 --- a/opencollab/environments.py +++ b/opencollab/environments.py @@ -10,16 +10,21 @@ local_environment, worktree_environment, ) +from opencollab.bootstrap.vm import VMConnection, VMEnvironment, VMResource, vm_isolation_provider __all__ = [ "Environment", "EnvironmentPool", "DockerMount", "DockerOptions", + "VMConnection", + "VMEnvironment", + "VMResource", "attach_container", "build_repo_map_via_env", "docker_environment", "docker_isolation_provider", "local_environment", "worktree_environment", + "vm_isolation_provider", ] diff --git a/tests/interfaces/test_vm_profiles.py b/tests/interfaces/test_vm_profiles.py new file mode 100644 index 00000000..87674b7a --- /dev/null +++ b/tests/interfaces/test_vm_profiles.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import json +import os +import sys + +import pytest + +from opencollab import OpenCollab +from opencollab.adapters.session_environments import SessionEnvironmentPool +from opencollab.bootstrap.config import build_config +from opencollab.bootstrap.environment_profiles import configured_environment_providers +from opencollab.environments import VMConnection, VMEnvironment, VMResource, vm_isolation_provider +from tests.interfaces.test_member_authored_workflow import _call, _manager, _serve_model +from tests.interfaces.test_session_isolation import _repo +from tests.support.paths import REPO_ROOT + + +@pytest.fixture(autouse=True) +def _child_import_path(monkeypatch): + monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) + monkeypatch.setenv("OPENCOLLAB_API_USAGE_LOG", os.devnull) + + +def _profile(tmp_path): + return { + "resources": [{"resource_id": "guest-a", "connection": {"host": "guest.example", "user": "worker"}, + "reset_command": "/usr/local/sbin/reset-opencollab-guest"}], + "lease_database": str(tmp_path / "leases.sqlite3"), + "wait_timeout": 0, + } + + +def test_vm_config_round_trips_and_environment_factories_remain_overridable(tmp_path, monkeypatch): + profile = _profile(tmp_path) + monkeypatch.setenv("OPENCOLLAB_VM", json.dumps(profile)) + loaded = build_config(str(tmp_path)) + assert loaded.vm.resources[0].connection == VMConnection("guest.example", "worker") + dumped = json.loads(loaded.model_dump_json()) + assert build_config(str(tmp_path), dumped).vm == loaded.vm + client = OpenCollab(tmp_path, config=dumped) + replacement = object() + providers = configured_environment_providers(client._config, {"vm-session": replacement}) + assert providers["vm-session"] is replacement + assert providers["vm-group"].keywords["scope"] == "group" + assert providers["vm-group"].keywords["lease_database"] == profile["lease_database"] + assert isinstance(loaded.vm.resources[0], VMResource) + assert callable(vm_isolation_provider) + assert VMEnvironment is not None + + +@pytest.mark.parametrize("change", [ + {"resources": []}, {"lease_database": "relative.sqlite3"}, {"wait_timeout": -1}, + {"wait_timeout": True}, {"wait_timeout": float("inf")}, {"unexpected": "value"}, + {"resources": [{"resource_id": "a", "connection": {"host": "guest", "user": "worker", "port": True}}]}, + {"resources": [{"resource_id": "a", "connection": {"host": "guest", "user": "worker", "password": "unused"}}]}, +]) +def test_invalid_vm_configuration_reports_the_field(tmp_path, change): + with pytest.raises(ValueError): + OpenCollab(tmp_path, config={"vm": {**_profile(tmp_path), **change}}) + + +@pytest.mark.parametrize("strategy", ["vm-group", "vm-session"]) +async def test_vm_strategy_without_resources_fails_before_model_use(tmp_path, strategy): + client = OpenCollab(tmp_path) + + async def flow(ctx, _args): + return await ctx.agent("unused", tools=[]) + + result = await client.workflow(flow, isolation=strategy, trace=False) + assert not result.ok + assert isinstance(result.error, ValueError) + assert "requires VM resources" in str(result.error) + + +@pytest.mark.parametrize("selected", [None, "worktree", "shared"]) +async def test_authored_workflow_can_override_or_inherit_child_default( + tmp_path, local_environment_factory, selected, +): + _repo(tmp_path, dirty=False) + environment = local_environment_factory(str(tmp_path)) + (tmp_path / "authored.py").write_text( + "from opencollab.tools import builtin_tools\n" + "async def main(ctx, args):\n return await ctx.agent('child', tools=builtin_tools('bash'))\n" + ) + parent, tool, runtime = _manager(environment) + tool._environment_pool_factory = lambda environment, **_kwargs: SessionEnvironmentPool( + str(tmp_path), isolation="worktree", base_environment=environment, + ) + def respond(body): + replies = [message for message in body["messages"] if message["role"] == "tool"] + if not replies: + return {"role": "assistant", "tool_calls": [_call("bash", { + "command": "pwd && printf child > child.txt", + })]}, {"prompt_tokens": 3, "completion_tokens": 2} + return {"role": "assistant", "content": replies[-1]["content"]}, {"prompt_tokens": 3, "completion_tokens": 2} + + with _serve_model(respond) as (endpoint, _requests): + parent.agent.base_url = endpoint + parent.agent.api_key = "host-only-secret" # pragma: allowlist secret + try: + launched = json.loads(await tool.execute_with_runtime({ + "action": "launch", "source": "authored.py", "python": sys.executable, + **({"isolation": selected} if selected is not None else {}), + }, runtime)) + result = json.loads(await tool.execute_with_runtime({"action": "wait", "id": launched["id"]}, runtime)) + assert result["status"] == "completed", result + assert (tmp_path / "child.txt").exists() is (selected == "shared") + assert str(tmp_path) in result["result"]["output"] if selected == "shared" else ( + ".opencollab/worktrees" in result["result"]["output"] + ) + finally: + await parent.aclose() + + +async def test_authored_isolation_override_is_validated_at_launch(tmp_path, local_environment_factory): + environment = local_environment_factory(str(tmp_path)) + (tmp_path / "authored.py").write_text("async def main(ctx, args):\n return 'unused'\n") + parent, tool, runtime = _manager(environment) + try: + with pytest.raises(ValueError, match="only supported by the launch"): + await tool.execute_with_runtime({"action": "status", "isolation": "worktree"}, runtime) + with pytest.raises(ValueError, match="needs an environment provider"): + await tool.execute_with_runtime({"action": "launch", "source": "authored.py", "isolation": "missing"}, runtime) + assert not tool._children + finally: + await parent.aclose() From 1d291264b6771a326751e28d9c7a0bc82aef756c Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 16:53:50 +0800 Subject: [PATCH 02/15] feat(workflows): configure guest-authored child execution defaults --- docs/session-isolation.md | 80 ++++++ docs/session-isolation.zh-CN.md | 66 +++++ examples/member-authored-workflow/README.md | 10 +- examples/vm-authored-workflow/README.md | 31 +++ examples/vm-authored-workflow/README.zh-CN.md | 25 ++ examples/vm-authored-workflow/demo.py | 111 ++++++++ opencollab/adapters/session_environments.py | 4 +- .../bootstrap/_workflow_runtime_session.py | 2 +- opencollab/bootstrap/authored_workflow.py | 3 +- opencollab/bootstrap/environment_profiles.py | 6 +- opencollab/bootstrap/programmatic.py | 2 + tests/interfaces/test_sdk_vm_isolation.py | 260 ++++++++++++++++++ tests/interfaces/test_vm_profiles.py | 67 ++++- 13 files changed, 648 insertions(+), 19 deletions(-) create mode 100644 examples/vm-authored-workflow/README.md create mode 100644 examples/vm-authored-workflow/README.zh-CN.md create mode 100644 examples/vm-authored-workflow/demo.py create mode 100644 tests/interfaces/test_sdk_vm_isolation.py diff --git a/docs/session-isolation.md b/docs/session-isolation.md index 389165e0..060a0e7f 100644 --- a/docs/session-isolation.md +++ b/docs/session-isolation.md @@ -124,3 +124,83 @@ retry. Recovery locations include the full container ID. The runtime's existing process cancellation and environment revocation behavior continues to apply. The Docker adapter tests can execute these paths against a local image through `OPENCOLLAB_DOCKER_TEST_IMAGE`. + +## VM strategies + +`vm-group` leases one existing Linux VM for a Team or Workflow and gives each +Session a private worktree inside that guest. `vm-session` leases one VM for +each Session. `shared` and `worktree` remain relative to the parent environment, +so a member running in a VM can keep its authored Workflow agents on that guest. +Prepare guest software and SSH access using the [VM guide](virtual-machines.md). + +```python +from opencollab import OpenCollab +from opencollab.environments import VMConnection, VMResource, vm_isolation_provider + +resources = [VMResource( + resource_id="worker-01", + connection=VMConnection(host="vm.example.net", user="worker"), + workspace_root="/srv/opencollab", + state_directory="/var/tmp/opencollab-operations", + reset_command="/usr/local/sbin/reset-opencollab-guest", +)] +client = OpenCollab(".", environment_providers={ + "vm-group": vm_isolation_provider(resources, lease_database="/var/tmp/oc-leases.sqlite3"), + "vm-session": vm_isolation_provider(resources, lease_database="/var/tmp/oc-leases.sqlite3", scope="session"), +}) +result = await client.workflow(solve, {"task": "Inspect the code"}, isolation="vm-group") +``` + +The stable `resource_id` identifies the actual machine across connection aliases. +Controller processes sharing a pool use the same absolute `lease_database` path. +SQLite transactions and resource uniqueness keep each leased VM exclusive. +`wait_timeout=0` reports unavailable capacity immediately, a positive number +waits for that many seconds, and `None` waits until capacity or cancellation. +A child requesting `vm-session` needs an available VM in addition to its parent. +An external allocation service can use the existing custom-provider entry point +when several controller machines share resources. + +The workspace root must already exist on the guest. Each resource requires an +explicit `reset_command` that restores every guest change the workload may make. +Deleting a working directory restores files inside it. Dependency installations, +system configuration and other guest changes require corresponding restoration, +such as an administrator's reset operation. OC exports candidate files and logs, +stops owned operations, cleans its workspaces, and runs reset while retaining the +lease. Successful reset returns the VM to the pool. An export, cleanup, transport +or reset failure preserves the lease and recovery material for retry. + +For a serializable SDK configuration, put the same values in `config["vm"]`. +The `vm` object contains `resources`, `lease_database`, and optional `wait_timeout`. +Each resource object contains the `VMResource` fields, with a `connection` object +containing `VMConnection` fields. `OpenCollab` loads both VM strategies from this +profile. Explicit `environment_providers` entries override matching configured +names. + +```python +import json +from pathlib import Path + +profile = json.loads(Path("vm-profile.json").read_text()) +client = OpenCollab(".", config={"vm": profile}) +result = await client.team("Inspect the source", config="team.yaml", isolation="vm-group") +``` + +The CLI reads the JSON profile from `OPENCOLLAB_VM`, including the existing +`configs/.env` source. Team YAML uses the same `isolation` defaults and role +overrides shown above. Workflow runs use the existing `--isolation` option. + +```bash +OPENCOLLAB_VM="$(cat vm-profile.json)" opencollab workflow run solve --isolation vm-group +``` + +Model routes, credentials and shared request slots stay with the controller. +VM selection controls tool execution and workspace ownership. Team communication +continues to follow its declared topology. + +`manage_workflow` accepts an optional `isolation` on `launch` to select the child +Workflow's default. Omission inherits its member's strategy. A member can launch +with `{"action":"launch","source":"generated.py","isolation":"worktree"}` +to run its child agents in independent worktrees inside its own VM. Individual +child calls can select `isolation="vm-session"` to request another guest. The +[executable VM example](../examples/vm-authored-workflow/README.md) starts with a +member writing source and finishes by collecting the native child result. diff --git a/docs/session-isolation.zh-CN.md b/docs/session-isolation.zh-CN.md index c1644ef3..678433ae 100644 --- a/docs/session-isolation.zh-CN.md +++ b/docs/session-isolation.zh-CN.md @@ -80,3 +80,69 @@ result = await client.workflow(solve, {"task": "Inspect the code"}, isolation="d 容器网络默认为 `none`。任务需要网络访问时,`network` 可以指定 `bridge` 或已有 Docker 网络名。附加挂载均为只读,并拒绝暴露宿主 socket。工作区内容复制到容器私有存储,已有工作树和子模块捕获逻辑保留文件的物理内容,可移植 Git 元数据让容器独立使用自己的仓库。源工作区可以来自本机或已有 Docker 环境,其所有权继续由调用方持有。 完整补丁在拥有的容器移除前捕获。导出失败或被截断时,工作区及其容器会保留,用于恢复和清理重试,恢复位置包含完整容器 ID。进程取消与环境撤销沿用已有行为。可以通过 `OPENCOLLAB_DOCKER_TEST_IMAGE` 选择本地镜像,执行 Docker adapter 测试中的真实容器路径。 + +## VM 隔离策略 + +`vm-group` 为一个 Team 或 Workflow 领取一台现有 Linux VM,每个 Session 在来宾内使用独立工作树。 +`vm-session` 为每个 Session 领取一台 VM。`shared` 和 `worktree` 继续相对于父环境生效, +成员可以让自己编写的 Workflow Agent 留在自己的 VM 中执行。 +来宾软件和 SSH 连接准备见 [VM 使用指南](virtual-machines.zh-CN.md)。 + +```python +from opencollab import OpenCollab +from opencollab.environments import VMConnection, VMResource, vm_isolation_provider + +resources = [VMResource( + resource_id="worker-01", + connection=VMConnection(host="vm.example.net", user="worker"), + workspace_root="/srv/opencollab", + state_directory="/var/tmp/opencollab-operations", + reset_command="/usr/local/sbin/reset-opencollab-guest", +)] +client = OpenCollab(".", environment_providers={ + "vm-group": vm_isolation_provider(resources, lease_database="/var/tmp/oc-leases.sqlite3"), + "vm-session": vm_isolation_provider(resources, lease_database="/var/tmp/oc-leases.sqlite3", scope="session"), +}) +result = await client.workflow(solve, {"task": "Inspect the code"}, isolation="vm-group") +``` + +稳定的 `resource_id` 标识实际机器,同一机器的不同连接别名应使用相同标识。 +共享机器池的控制进程使用相同的绝对 `lease_database` 路径。SQLite 事务和资源唯一约束保持独占。 +`wait_timeout=0` 在容量不足时立即报错,正数指定等待秒数,`None` 等待容量或取消。 +子 Agent 选择 `vm-session` 时,需要父 VM 之外的一台空闲 VM。 +多台控制机器共享资源时,可以通过既有自定义提供者入口接入外部分配服务。 + +来宾的工作区根目录须已存在。每台资源需要显式 `reset_command`,恢复工作负载可能产生的全部来宾改动。 +删除工作目录可以恢复其中的文件。依赖安装、系统配置和其他来宾改动需要对应的恢复操作, +例如管理员提供的重置程序。OC 先导出候选文件和日志,停止拥有的操作,清理工作区, +再持有租约执行重置。重置成功后,VM 回到可分配状态。导出、清理、连接或重置失败时, +保留租约和恢复资料以供重试。 + +SDK 可将相同配置序列化后放入 `config["vm"]`。`vm` 对象包含 `resources`、 +`lease_database` 和可选的 `wait_timeout`。每个资源对象采用 `VMResource` 的字段, +其中 `connection` 对象采用 `VMConnection` 字段。OpenCollab 据此注册两种 VM 策略。 +显式 `environment_providers` 中的同名项目优先。 + +```python +import json +from pathlib import Path + +profile = json.loads(Path("vm-profile.json").read_text()) +client = OpenCollab(".", config={"vm": profile}) +result = await client.team("Inspect the source", config="team.yaml", isolation="vm-group") +``` + +CLI 通过 `OPENCOLLAB_VM` 读取 JSON 配置,也支持既有 `configs/.env` 来源。 +Team YAML 继续使用上述运行默认与角色覆盖。Workflow 使用既有 `--isolation` 选项。 + +```bash +OPENCOLLAB_VM="$(cat vm-profile.json)" opencollab workflow run solve --isolation vm-group +``` + +模型路由、凭据和共享请求额度保存在控制端。VM 选择决定工具执行位置和工作区归属。 +Team 成员之间的通信继续遵循配置中的拓扑。 + +`manage_workflow` 的 `launch` 可选传入 `isolation`,设置子 Workflow 的默认隔离策略。 +省略时继承成员策略。成员使用 `{"action":"launch","source":"generated.py","isolation":"worktree"}` +可让子 Agent 在自己的 VM 中使用独立工作树。单个子调用可通过 `isolation="vm-session"` 申请另一台来宾。 +[可执行 VM 示例](../examples/vm-authored-workflow/README.zh-CN.md) 从成员写出源码开始,执行到取得原生子运行结果。 diff --git a/examples/member-authored-workflow/README.md b/examples/member-authored-workflow/README.md index aef56bcf..5342b261 100644 --- a/examples/member-authored-workflow/README.md +++ b/examples/member-authored-workflow/README.md @@ -53,8 +53,9 @@ wait and leaves the child running. {"action": "stop", "id": ""} ``` -Model credentials and supported generation settings inherit the member's -effective configuration through the child process's standard input. Child usage is charged once to the parent's token +Supported generation settings inherit the member's effective configuration +through the child process's standard input. Model credentials, endpoint routes +and shared request slots stay in the host bridge. Child usage is charged once to the parent's token total. Finite child budgets reserve the member's remaining budget across simultaneously active children and the parent's subsequent model requests. A child with an omitted budget receives half the unreserved remainder so the @@ -81,9 +82,10 @@ command and performs its existing process cleanup. A `wait` call uses the managed lifecycle, so an ordinary `bash` timeout does not determine how long the Workflow can run. -An authored Workflow inherits its member's isolation strategy. Each +An authored Workflow inherits its member's isolation strategy. The `launch` +action can set `isolation` to override the child Workflow's default. Each `ctx.agent` or `ctx.agent_run` call can select `shared`, `worktree`, -`docker-group`, `docker-session`, or a host-registered strategy through its +`docker-group`, `docker-session`, `vm-group`, `vm-session`, or a host-registered strategy through its `isolation` argument. See the [session isolation guide](../../docs/session-isolation.md) for provider configuration. For example, these calls create two independent Docker execution environments when the host configures the `docker-session` diff --git a/examples/vm-authored-workflow/README.md b/examples/vm-authored-workflow/README.md new file mode 100644 index 00000000..478ff6f4 --- /dev/null +++ b/examples/vm-authored-workflow/README.md @@ -0,0 +1,31 @@ +# VM member-authored Workflow + +[中文](README.zh-CN.md) + +Prepare two existing Linux VMs using the [VM guide](../../docs/virtual-machines.md). +Install the same OpenCollab revision on the controller and both guests, then save +a VM profile JSON file with `resources` and `lease_database` as described in the +[session isolation guide](../../docs/session-isolation.md). + +```bash +uv run python examples/vm-authored-workflow/demo.py --config /path/to/vm-profile.json +``` + +The offline HTTP model tells a Team member to write `generated.py`, launch it +with `isolation="worktree"`, and collect its native Workflow result. The first +child agent works in a private Git worktree inside the member VM. The second +explicitly selects `vm-session` and receives another guest. Each agent reads +the source seed, reports its hostname, kernel boot identity and directory, then +creates its own `worker.txt`. + +The printed workspace retains parent and child transcripts, the Workflow +manifest, usage, results and exported VM workspaces. Model requests execute in +the controller through the existing authored Workflow bridge. The guests need +their installed runtime and must accept the controller's SSH connection. Model API +credentials and request-pool paths stay in the controller. + +Each VM resource needs its administrator's reset command. That command must +restore every guest change allowed by the workload before the guest returns to +the pool. The example writes inside its allocated workspaces and uses the +existing command cleanup. A workload that changes dependencies or system +configuration needs a reset that restores those changes as well. diff --git a/examples/vm-authored-workflow/README.zh-CN.md b/examples/vm-authored-workflow/README.zh-CN.md new file mode 100644 index 00000000..69fec5a0 --- /dev/null +++ b/examples/vm-authored-workflow/README.zh-CN.md @@ -0,0 +1,25 @@ +# VM 成员自编 Workflow + +[English](README.md) + +按照 [VM 使用指南](../../docs/virtual-machines.zh-CN.md) 准备两台现有 Linux VM。 +在控制端和两台来宾中安装相同版本的 OpenCollab,再参照 +[Session 隔离指南](../../docs/session-isolation.zh-CN.md) 保存包含 `resources` 和 +`lease_database` 的 VM 配置 JSON 文件。 + +```bash +uv run python examples/vm-authored-workflow/demo.py --config /path/to/vm-profile.json +``` + +本地 HTTP 模型让 Team 成员写出 `generated.py`,通过 `isolation="worktree"` +启动它,再取得原生 Workflow 结果。第一个子 Agent 在成员 VM 内的独立 Git +工作树执行。第二个显式选择 `vm-session`,领取另一台来宾。每个 Agent 读取 +输入文件,返回主机名、内核启动标识和目录,再创建自己的 `worker.txt`。 + +输出中的工作目录保留父子 Session 记录、Workflow 清单、用量、结果和导出的 +VM 工作区。模型请求通过既有成员自编 Workflow 桥接在控制端执行。来宾需要 +控制端能通过 SSH 访问,并已安装执行环境。模型凭据和请求额度目录保存在控制端。 + +每台 VM 资源需要管理员提供重置命令。来宾重新进入池之前,该命令须恢复工作负载 +允许产生的全部来宾改动。示例只在分配的工作区中写文件,并使用既有命令清理。 +若工作负载会修改依赖或系统配置,重置操作也须恢复这些变化。 diff --git a/examples/vm-authored-workflow/demo.py b/examples/vm-authored-workflow/demo.py new file mode 100644 index 00000000..fe60d71f --- /dev/null +++ b/examples/vm-authored-workflow/demo.py @@ -0,0 +1,111 @@ +"""Have a Team member author and run a Workflow across two existing Linux VMs.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import subprocess +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from opencollab import OpenCollab + +SOURCE = '''from opencollab.tools import builtin_tools + +async def main(ctx, args): + tools = builtin_tools("bash") + first = await ctx.agent("Inspect the member VM", tools=tools) + second = await ctx.agent("Inspect a separate VM", tools=tools, isolation="vm-session") + return {"workers": [first, second], "spent_tokens": ctx.tokens_spent()} +''' + + +async def run(workspace: Path, profile: dict) -> dict: + observed = {} + + class Model(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + messages = body["messages"] + replies = [item for item in messages if item["role"] == "tool"] + author = messages[0].get("content", "").startswith("Offline VM workflow author") + call = None + if author: + if not replies: + name, args = "file_write", {"path": "generated.py", "mode": "create", "content": SOURCE} + elif len(replies) == 1: + name, args = "manage_workflow", { + "action": "launch", "source": "generated.py", "isolation": "worktree", + } + elif len(replies) == 2: + name, args = "manage_workflow", {"action": "wait", "id": json.loads(replies[-1]["content"])["id"]} + else: + observed.update(json.loads(replies[-1]["content"])) + if len(replies) < 3: + call = {"id": f"author-{len(replies)}", "type": "function", "function": { + "name": name, "arguments": json.dumps(args), + }} + elif not replies: + call = {"id": "probe", "type": "function", "function": {"name": "bash", "arguments": json.dumps({ + "command": "cat seed.txt && hostname && cat /proc/sys/kernel/random/boot_id && pwd && " + "printf worker > worker.txt", + })}} + content = "Collected child result" if author else replies[-1]["content"] if replies else None + message = {"role": "assistant", "content": content} + if call is not None: + message.update(content=None, tool_calls=[call]) + payload = json.dumps({ + "id": "offline", "object": "chat.completion", "created": 0, "model": "gpt-4o", + "choices": [{"index": 0, "message": message, "finish_reason": "tool_calls" if call else "stop"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}, + }).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + workspace.mkdir(parents=True, exist_ok=True) + (workspace / "seed.txt").write_text("Source seed\n", encoding="utf-8") + for args in (("init", "-q"), ("add", "seed.txt"), ( + "-c", "user.name=Example", "-c", "user.email=example@example.invalid", "commit", "-qm", "initial", + )): + subprocess.run(["git", "-C", str(workspace), *args], check=True, capture_output=True) + team = workspace / "team.yaml" + team.write_text("entry: author\nisolation: vm-group\nroles:\n author:\n" + " prompt: Offline VM workflow author\n" + " tools: [file_write, bash, manage_workflow]\n", encoding="utf-8") + server = ThreadingHTTPServer(("127.0.0.1", 0), Model) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + result = await OpenCollab( + workspace, model="gpt-4o", provider="openai", api_key="offline-model", # pragma: allowlist secret + base_url=f"http://127.0.0.1:{server.server_port}/v1", + config={"vm": profile, "budget": 10_000, "llm_max_retries": 0, "thinking": False}, + ).team("Write and execute your own Workflow", config=team, artifacts=workspace / "parent-run", trace=False) + finally: + server.shutdown() + server.server_close() + thread.join() + result.raise_for_status() + if observed.get("status") != "completed": + raise RuntimeError(f"child failed {observed}") + return {"parent_output": result.output, "total_tokens": result.tokens, + "child": observed, "workspace": str(workspace)} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", type=Path, required=True, + help="JSON VM profile containing resources and lease_database") + parser.add_argument("--workspace", type=Path) + options = parser.parse_args() + directory = options.workspace or Path(tempfile.mkdtemp(prefix="opencollab-vm-authored-")) + print(json.dumps(asyncio.run(run(directory.resolve(), json.loads(options.config.read_text()))), indent=2)) diff --git a/opencollab/adapters/session_environments.py b/opencollab/adapters/session_environments.py index 9290f649..3a91a3d0 100644 --- a/opencollab/adapters/session_environments.py +++ b/opencollab/adapters/session_environments.py @@ -66,7 +66,9 @@ def _validate(self, name: str) -> None: isolation_name(name) if name not in {"shared", "worktree"} and name not in self._providers: if name in {"vm-group", "vm-session"}: - raise ValueError(f"isolation strategy {name!r} requires VM resources in config.vm or an environment provider") + raise ValueError( + f"isolation strategy {name!r} requires VM resources in config.vm or an environment provider" + ) raise ValueError(f"isolation strategy {name!r} needs an environment provider") def _pool(self, name: str, source: EnvironmentPort) -> WorktreePoolPort: diff --git a/opencollab/bootstrap/_workflow_runtime_session.py b/opencollab/bootstrap/_workflow_runtime_session.py index 07b1d617..7bfd4aac 100644 --- a/opencollab/bootstrap/_workflow_runtime_session.py +++ b/opencollab/bootstrap/_workflow_runtime_session.py @@ -530,7 +530,7 @@ def build_workflow_context( ephemeral. """ environment = env if env is not None else _WORKFLOW_ENV_OVERRIDE.get() - environment_providers = configured_environment_providers(cfg, environment_providers) + environment_providers = configured_environment_providers(cfg, environment_providers, isolation=isolation) factory = WorkflowSessionFactory( llm=llm, session_builder=session_builder, diff --git a/opencollab/bootstrap/authored_workflow.py b/opencollab/bootstrap/authored_workflow.py index c66a1432..e7edc94a 100644 --- a/opencollab/bootstrap/authored_workflow.py +++ b/opencollab/bootstrap/authored_workflow.py @@ -70,7 +70,8 @@ class AuthoredWorkflowTool(Tool): "python": {"type": "string", "default": "python3"}, "budget": {"type": "integer", "minimum": 1}, "concurrency": {"type": "integer", "minimum": 1, "default": 4}, - "isolation": {"type": "string", "description": "launch only. Default environment strategy for child agents. Omitted inherits the member strategy."}, + "isolation": {"type": "string", "description": "launch only. Default strategy for child agents. " + "Omitted inherits the member strategy."}, "timeout": {"type": "number", "minimum": 0, "description": "Optional wait duration. Expiry leaves the child running."}, }, diff --git a/opencollab/bootstrap/environment_profiles.py b/opencollab/bootstrap/environment_profiles.py index c79e5b37..1a4b9b98 100644 --- a/opencollab/bootstrap/environment_profiles.py +++ b/opencollab/bootstrap/environment_profiles.py @@ -54,7 +54,7 @@ def _wait_timeout(cls, value: Any) -> Any: def configured_environment_providers( - config: Mapping[str, Any], providers: Mapping[str, Any] | None = None, + config: Mapping[str, Any], providers: Mapping[str, Any] | None = None, *, isolation: str | None = None, ) -> dict[str, Any]: selected: dict[str, Any] = {} value = config.get("vm") @@ -70,4 +70,8 @@ def configured_environment_providers( for scope in ("group", "session") }) selected.update(providers or {}) + if isolation in {"vm-group", "vm-session"} and isolation not in selected: + raise ValueError( + f"isolation strategy {isolation!r} requires VM resources in config.vm or an environment provider" + ) return selected diff --git a/opencollab/bootstrap/programmatic.py b/opencollab/bootstrap/programmatic.py index 056f723f..bb961ecd 100644 --- a/opencollab/bootstrap/programmatic.py +++ b/opencollab/bootstrap/programmatic.py @@ -49,6 +49,7 @@ run_agent as _run_agent, ) from opencollab.bootstrap.config import resolve_model_route, resolve_thinking_params +from opencollab.bootstrap.environment_profiles import configured_environment_providers from opencollab.bootstrap.scheduler_factory import build_scheduler # noqa: F401 from opencollab.bootstrap.session_factory import SESSION_MAX_STEPS from opencollab.bootstrap.tool_registry import build_tools_for_role @@ -513,6 +514,7 @@ async def run_workflow( environment_providers: Mapping[str, Any] | None = None, ) -> ProgrammaticResult: """Run one workflow and return its live metrics directly.""" + environment_providers = configured_environment_providers(config, environment_providers, isolation=isolation) run_id = run_id if run_id is not None else f"workflow-{uuid.uuid4().hex}" workflow_inputs = dict(inputs) if artifacts is not None: diff --git a/tests/interfaces/test_sdk_vm_isolation.py b/tests/interfaces/test_sdk_vm_isolation.py new file mode 100644 index 00000000..b0ccc5e9 --- /dev/null +++ b/tests/interfaces/test_sdk_vm_isolation.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import json +import os +import shlex +import sqlite3 +import sys +import uuid +from pathlib import Path + +import pytest + +from opencollab import OpenCollab +from opencollab.bootstrap import _workflow_runtime_session as wiring +from opencollab.bootstrap.authored_workflow import AuthoredWorkflowTool +from opencollab.environments import VMConnection, VMEnvironment +from opencollab.tools import builtin_tools +from tests.interfaces.test_member_authored_workflow import _call, _serve_model +from tests.interfaces.test_session_isolation import _repo +from tests.support.paths import REPO_ROOT + + +@pytest.fixture +async def vm_profile(tmp_path, monkeypatch): + source = os.environ.get("OPENCOLLAB_TEST_VM_PROFILE") + if not source: + pytest.skip("OPENCOLLAB_TEST_VM_PROFILE enables real VM SDK and CLI probes") + profile = json.loads(Path(source).read_text()) + if len(profile["resources"]) < 2: + pytest.fail("the VM SDK probe requires two existing guests") + monkeypatch.setenv("OPENCOLLAB_API_USAGE_LOG", os.devnull) + monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) + identity = f"sdk-{uuid.uuid4().hex}" + controls = [] + for resource in profile["resources"]: + control = VMEnvironment(VMConnection(**resource["connection"]), resource["workspace_root"], + state_directory=resource["state_directory"]) + await control.setup() + root = resource["workspace_root"] + "/" + identity + state = resource["state_directory"] + "/" + identity + result = await control.exec_cmd(f"mkdir -p -- {shlex.quote(root)} {shlex.quote(state)}") + assert result.returncode == 0, result.stderr + resource.update(workspace_root=root, state_directory=state, reset_command=( + "find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && " + f"printf reset >> {shlex.quote(state + '/reset-receipt')}" + )) + controls.append((control, root, state)) + profile["lease_database"] = str(tmp_path / "vm-leases.sqlite3") + profile["wait_timeout"] = 0 + try: + yield profile + with sqlite3.connect(profile["lease_database"]) as database: + assert database.execute("SELECT count(*) FROM vm_leases").fetchone()[0] == 0 + finally: + retained = [] + if Path(profile["lease_database"]).exists(): + with sqlite3.connect(profile["lease_database"]) as database: + retained = database.execute("SELECT resource_id FROM vm_leases").fetchall() + for control, root, state in controls: + if not retained: + await control.exec_cmd(f"rm -rf -- {shlex.quote(root)} {shlex.quote(state)}") + await control.cleanup() + + +@pytest.mark.parametrize("scope, unbounded", [("group", False), ("session", False), ("group", True)]) +async def test_vm_profile_runs_public_workflow_sessions_on_real_guests( + tmp_path, monkeypatch, vm_profile, scope, unbounded, +): + monkeypatch.setenv("OPENCOLLAB_UNBOUNDED_LIMITS", "true" if unbounded else "false") + workspace = tmp_path / "workspace" + workspace.mkdir() + _repo(workspace, dirty=False) + sessions = [] + build = wiring.build_session + + def remember(**kwargs): + session = build(**kwargs) + sessions.append(session) + return session + + monkeypatch.setattr(wiring, "build_session", remember) + + def respond(body): + replies = [message for message in body["messages"] if message["role"] == "tool"] + if not replies: + return {"role": "assistant", "tool_calls": [_call("bash", { + "command": "cat seed.txt && hostname && cat /proc/sys/kernel/random/boot_id && pwd && " + "test ! -e worker.txt && printf private > worker.txt", + })]}, {"prompt_tokens": 3, "completion_tokens": 2} + return {"role": "assistant", "content": replies[-1]["content"]}, {"prompt_tokens": 3, "completion_tokens": 2} + + async def flow(ctx, _args): + answers = [await ctx.agent(label, tools=builtin_tools("bash")) for label in ("first", "second")] + assert await sessions[0].env.read_file("worker.txt") == "private" + assert await sessions[1].env.read_file("worker.txt") == "private" + identities = [session.env.resource_id for session in sessions] + assert (identities[0] == identities[1]) is (scope == "group") + assert sessions[0].env.workspace != sessions[1].env.workspace + assert all((session.max_budget_tokens is None) is unbounded for session in sessions) + return answers + + with _serve_model(respond) as (endpoint, requests): + result = await OpenCollab( + workspace, model="gpt-4o", api_key="host-offline", base_url=endpoint, # pragma: allowlist secret + config={"vm": json.loads(json.dumps(vm_profile)), "llm_max_retries": 0}, + ).workflow(flow, isolation=f"vm-{scope}", artifacts=tmp_path / "artifacts", trace=False) + assert result.ok and not result.agent_failures, result + assert len(requests) == 4 + assert all("initial" in answer for answer in result.output) + assert not (workspace / "worker.txt").exists() + assert len(list((tmp_path / "artifacts" / "vm").glob("*/*/worker.txt"))) == 2 + + +async def test_vm_member_authors_workflow_and_collects_two_guest_results(tmp_path, vm_profile): + spec = importlib.util.spec_from_file_location( + "vm_authored_demo", REPO_ROOT / "examples/vm-authored-workflow/demo.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + output = await module.run(tmp_path / "example", vm_profile) + child = output["child"] + assert output["parent_output"] == "Collected child result" + assert child["status"] == "completed" + answers = child["result"]["output"]["workers"] + assert len(answers) == 2 and all("Source seed" in answer for answer in answers) + boot_ids = [next(line.strip() for line in answer.splitlines() if len(line.strip()) == 36) for answer in answers] + assert boot_ids[0] != boot_ids[1] + saved = Path(child["artifacts"]) + assert json.loads((saved / "workflow.json").read_text())["sessions"] == 2 + assert len(list(saved.glob("[0-9]*.json"))) == 2 + assert all("host-offline" not in path.read_text() for path in saved.glob("*.json")) + + +async def test_vm_parent_cancellation_stops_authored_agents_and_preserves_native_records( + tmp_path, monkeypatch, vm_profile, +): + workspace = tmp_path / "workspace" + workspace.mkdir() + _repo(workspace, dirty=False) + team = tmp_path / "team.yaml" + team.write_text("entry: author\nisolation: vm-group\nroles:\n author:\n" + " prompt: VM author\n tools: [file_write, bash, manage_workflow]\n") + source = '''import asyncio +from opencollab.tools import builtin_tools + +async def main(ctx, args): + tools = builtin_tools("bash") + return await asyncio.gather(ctx.agent("first worker", tools=tools), + ctx.agent("second worker", tools=tools, isolation="vm-session")) +''' + children = [] + launch = AuthoredWorkflowTool._launch + + async def remember(tool, params, runtime): + child = await launch(tool, params, runtime) + children.append(child) + return child + + monkeypatch.setattr(AuthoredWorkflowTool, "_launch", remember) + + def respond(body): + replies = [message for message in body["messages"] if message["role"] == "tool"] + author = body["messages"][0]["content"].startswith("VM author") + if author: + if not replies: + call = _call("file_write", {"path": "authored.py", "mode": "create", "content": source}) + elif len(replies) == 1: + call = _call("manage_workflow", {"action": "launch", "source": "authored.py", "isolation": "worktree"}) + else: + call = _call("manage_workflow", {"action": "wait", "id": json.loads(replies[-1]["content"])["id"]}) + elif not replies: + call = _call("bash", {"command": "echo $$ > command.pid && printf ready > entered && sleep 300"}) + else: + return {"role": "assistant", "content": "finished"}, {"prompt_tokens": 3, "completion_tokens": 2} + return {"role": "assistant", "tool_calls": [call]}, {"prompt_tokens": 3, "completion_tokens": 2} + + async def entered(): + while not children or len(children[0].bridge._environments) < 3: + await asyncio.sleep(0.1) + environments = list(children[0].bridge._environments.values())[1:] + for environment in environments: + while True: + try: + if await environment.read_file("entered") == "ready": + break + except FileNotFoundError: + pass + await asyncio.sleep(0.1) + return [(environment.connection, int(await environment.read_file("command.pid"))) + for environment in environments] + + with _serve_model(respond) as (endpoint, _requests): + task = asyncio.create_task(OpenCollab( + workspace, model="gpt-4o", api_key="host-offline", base_url=endpoint, # pragma: allowlist secret + config={"vm": vm_profile, "llm_max_retries": 0}, + ).team("write and run your Workflow", config=team, artifacts=tmp_path / "artifacts", trace=False)) + try: + processes = await asyncio.wait_for(entered(), 180) + finally: + task.cancel() + try: + result = await task + except asyncio.CancelledError: + pass + else: + assert result.status == "stopped", result + saved = list((tmp_path / "artifacts").glob("child-*/result.json")) + assert len(saved) == 1 + assert json.loads(saved[0].read_text())["status"] == "stopped" + assert json.loads((saved[0].parent / "workflow.json").read_text())["status"] == "stopped" + for connection, pid in processes: + resource = next(resource for resource in vm_profile["resources"] + if resource["connection"]["port"] == connection.port) + control = VMEnvironment(connection, resource["workspace_root"], state_directory=resource["state_directory"]) + try: + outcome = await control.exec_cmd(f"test ! -e /proc/{pid}") + assert outcome.returncode == 0 + finally: + await control.cleanup() + + +async def test_cli_reads_vm_profile_from_existing_config_source(tmp_path, monkeypatch, vm_profile): + workspace = tmp_path / "workspace" + workspace.mkdir() + workflows = workspace / "workflows" + workflows.mkdir() + (workflows / "probe.py").write_text('''from opencollab.workflows import workflow +from opencollab.tools import builtin_tools + +@workflow +async def vm_probe(ctx, args): + return await ctx.agent("probe", tools=builtin_tools("bash")) +''') + _repo(workspace, dirty=False) + configs = workspace / "configs" + configs.mkdir() + (configs / ".env").write_text(f"OPENCOLLAB_VM='{json.dumps(vm_profile)}'\n") + + def respond(body): + replies = [message for message in body["messages"] if message["role"] == "tool"] + if not replies: + return {"role": "assistant", "tool_calls": [_call("bash", {"command": "hostname && cat seed.txt"})]}, { + "prompt_tokens": 3, "completion_tokens": 2, + } + return {"role": "assistant", "content": replies[-1]["content"]}, {"prompt_tokens": 3, "completion_tokens": 2} + + with _serve_model(respond) as (endpoint, requests): + monkeypatch.setenv("OPENCOLLAB_BASE_URL", endpoint) + monkeypatch.setenv("OPENCOLLAB_API_KEY", "host-offline") + process = await asyncio.create_subprocess_exec( + sys.executable, "-m", "opencollab", "workflow", "run", "vm_probe", "--workspace", str(workspace), + "--isolation", "vm-group", "--no-save", "--no-trace", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(process.communicate(), 180) + assert process.returncode == 0, (stdout.decode(), stderr.decode()) + assert "initial" in stdout.decode() + assert len(requests) == 2 diff --git a/tests/interfaces/test_vm_profiles.py b/tests/interfaces/test_vm_profiles.py index 87674b7a..0afbcf0e 100644 --- a/tests/interfaces/test_vm_profiles.py +++ b/tests/interfaces/test_vm_profiles.py @@ -13,6 +13,7 @@ from opencollab.environments import VMConnection, VMEnvironment, VMResource, vm_isolation_provider from tests.interfaces.test_member_authored_workflow import _call, _manager, _serve_model from tests.interfaces.test_session_isolation import _repo +from tests.support.environment_test_support import local_environment_factory as local_environment_factory from tests.support.paths import REPO_ROOT @@ -67,10 +68,8 @@ async def test_vm_strategy_without_resources_fails_before_model_use(tmp_path, st async def flow(ctx, _args): return await ctx.agent("unused", tools=[]) - result = await client.workflow(flow, isolation=strategy, trace=False) - assert not result.ok - assert isinstance(result.error, ValueError) - assert "requires VM resources" in str(result.error) + with pytest.raises(ValueError, match="requires VM resources"): + await client.workflow(flow, isolation=strategy, trace=False) @pytest.mark.parametrize("selected", [None, "worktree", "shared"]) @@ -81,9 +80,10 @@ async def test_authored_workflow_can_override_or_inherit_child_default( environment = local_environment_factory(str(tmp_path)) (tmp_path / "authored.py").write_text( "from opencollab.tools import builtin_tools\n" - "async def main(ctx, args):\n return await ctx.agent('child', tools=builtin_tools('bash'))\n" + "async def main(ctx, args):\n" + " return await ctx.agent('child', tools=builtin_tools('bash', headless=False))\n" ) - parent, tool, runtime = _manager(environment) + parent, tool, runtime = _manager(environment, budget=20_000) tool._environment_pool_factory = lambda environment, **_kwargs: SessionEnvironmentPool( str(tmp_path), isolation="worktree", base_environment=environment, ) @@ -105,10 +105,9 @@ def respond(body): }, runtime)) result = json.loads(await tool.execute_with_runtime({"action": "wait", "id": launched["id"]}, runtime)) assert result["status"] == "completed", result - assert (tmp_path / "child.txt").exists() is (selected == "shared") - assert str(tmp_path) in result["result"]["output"] if selected == "shared" else ( - ".opencollab/worktrees" in result["result"]["output"] - ) + assert (tmp_path / "child.txt").exists() is (selected == "shared"), result + output = result["result"]["output"] + assert str(tmp_path) in output if selected == "shared" else str(tmp_path) not in output, result finally: await parent.aclose() @@ -121,7 +120,53 @@ async def test_authored_isolation_override_is_validated_at_launch(tmp_path, loca with pytest.raises(ValueError, match="only supported by the launch"): await tool.execute_with_runtime({"action": "status", "isolation": "worktree"}, runtime) with pytest.raises(ValueError, match="needs an environment provider"): - await tool.execute_with_runtime({"action": "launch", "source": "authored.py", "isolation": "missing"}, runtime) + await tool.execute_with_runtime({ + "action": "launch", "source": "authored.py", "isolation": "missing", + }, runtime) assert not tool._children finally: await parent.aclose() + + +@pytest.mark.parametrize("budget", [None, 20_000]) +async def test_authored_source_uses_host_model_without_receiving_host_credentials( + tmp_path, local_environment_factory, budget, +): + environment = local_environment_factory(str(tmp_path)) + (tmp_path / "authored.py").write_text('''import inspect +configs = [frame.frame.f_locals["request"]["config"] for frame in inspect.stack() + if isinstance(frame.frame.f_locals.get("request"), dict) + and "config" in frame.frame.f_locals["request"]] + +async def main(ctx, args): + answer = await ctx.agent("answer through the host bridge", tools=[]) + return {"config": configs[0], "answer": answer} +''') + parent, tool, runtime = _manager(environment, budget=budget) + tool._config.model_routes = {"unused": {"model": "unused", "api_key": "route-secret"}} # pragma: allowlist secret + tool._config.request_pool = str(tmp_path / "host-request-slots") + tool._config.request_capacity = 1 + + def respond(body): + return {"role": "assistant", "content": "host served"}, {"prompt_tokens": 3, "completion_tokens": 2} + + with _serve_model(respond) as (endpoint, requests): + parent.agent.base_url = endpoint + parent.agent.api_key = "host-secret" # pragma: allowlist secret + try: + launched = json.loads(await tool.execute_with_runtime({ + "action": "launch", "source": "authored.py", "python": sys.executable, + }, runtime)) + child = tool._children[launched["id"]] + assert (child.budget is None) is (budget is None) + result = json.loads(await tool.execute_with_runtime({"action": "wait", "id": child.id}, runtime)) + assert result["status"] == "completed", result + output = result["result"]["output"] + assert output["answer"] == "host served" + assert output["config"]["model"] == "gpt-4o" + assert not {"api_key", "auth_token", "base_url", "model_routes", "request_pool"} & output["config"].keys() + assert len(requests) == 1 + assert requests[0]["model"] == "gpt-4o" + assert parent.used_tokens == result["tokens"] == 5 + finally: + await parent.aclose() From 6cef0ef63f76be22a580ea241b0dfc825fe5b01c Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 17:05:36 +0800 Subject: [PATCH 03/15] docs(environments): explain public virtual machine execution profiles --- docs/session-isolation.md | 10 ++++++++++ docs/session-isolation.zh-CN.md | 8 ++++++++ docs/virtual-machines.md | 20 ++++++++++++++++++-- docs/virtual-machines.zh-CN.md | 15 +++++++++++++-- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/session-isolation.md b/docs/session-isolation.md index 060a0e7f..f649e9b4 100644 --- a/docs/session-isolation.md +++ b/docs/session-isolation.md @@ -178,8 +178,12 @@ names. ```python import json +from dataclasses import asdict from pathlib import Path +profile = {"resources": [asdict(resource) for resource in resources], + "lease_database": "/var/tmp/oc-leases.sqlite3"} +Path("vm-profile.json").write_text(json.dumps(profile)) profile = json.loads(Path("vm-profile.json").read_text()) client = OpenCollab(".", config={"vm": profile}) result = await client.team("Inspect the source", config="team.yaml", isolation="vm-group") @@ -197,6 +201,12 @@ Model routes, credentials and shared request slots stay with the controller. VM selection controls tool execution and workspace ownership. Team communication continues to follow its declared topology. +Leases survive controller process exit. An administrator can recover a retained +resource after confirming that its owned remote processes have stopped and its +guest reset succeeded, then removing that resource's exact lease record. Active +pool objects support cleanup retries. Recreating a pool leaves retained leases +in place until that recovery is complete. + `manage_workflow` accepts an optional `isolation` on `launch` to select the child Workflow's default. Omission inherits its member's strategy. A member can launch with `{"action":"launch","source":"generated.py","isolation":"worktree"}` diff --git a/docs/session-isolation.zh-CN.md b/docs/session-isolation.zh-CN.md index 678433ae..3528a615 100644 --- a/docs/session-isolation.zh-CN.md +++ b/docs/session-isolation.zh-CN.md @@ -125,8 +125,12 @@ SDK 可将相同配置序列化后放入 `config["vm"]`。`vm` 对象包含 `res ```python import json +from dataclasses import asdict from pathlib import Path +profile = {"resources": [asdict(resource) for resource in resources], + "lease_database": "/var/tmp/oc-leases.sqlite3"} +Path("vm-profile.json").write_text(json.dumps(profile)) profile = json.loads(Path("vm-profile.json").read_text()) client = OpenCollab(".", config={"vm": profile}) result = await client.team("Inspect the source", config="team.yaml", isolation="vm-group") @@ -142,6 +146,10 @@ OPENCOLLAB_VM="$(cat vm-profile.json)" opencollab workflow run solve --isolation 模型路由、凭据和共享请求额度保存在控制端。VM 选择决定工具执行位置和工作区归属。 Team 成员之间的通信继续遵循配置中的拓扑。 +控制进程退出后租约继续保留。管理员确认资源拥有的远端进程已结束、来宾已成功重置后, +可以移除该资源对应的确切租约记录并恢复分配。仍在运行的池对象支持清理重试。 +重新创建池会保留已有租约,等待上述恢复完成。 + `manage_workflow` 的 `launch` 可选传入 `isolation`,设置子 Workflow 的默认隔离策略。 省略时继承成员策略。成员使用 `{"action":"launch","source":"generated.py","isolation":"worktree"}` 可让子 Agent 在自己的 VM 中使用独立工作树。单个子调用可通过 `isolation="vm-session"` 申请另一台来宾。 diff --git a/docs/virtual-machines.md b/docs/virtual-machines.md index 22d35a7f..0f73e984 100644 --- a/docs/virtual-machines.md +++ b/docs/virtual-machines.md @@ -5,8 +5,7 @@ Prepare an existing workspace and a private state directory owned by the SSH account. Keep operation state outside the workspace. Each environment uses a run ID beneath that directory. Preserve the run ID when reconstructing an environment after a controller restart. ```python -from opencollab.adapters.env import VMEnvironment -from opencollab.adapters.vm_config import VMConnection +from opencollab.environments import VMConnection, VMEnvironment connection = VMConnection( host="vm.example.net", @@ -49,3 +48,20 @@ The opt-in SSH regression suite reads `OPENCOLLAB_TEST_VM_CONFIG` from a JSON fi Adapter integrations can call `copy_to_host(source, target, directory=False)` and `copy_from_host(source, destination, directory=False)` to stream binary files or directory archives across SSH beyond command-output limits. Guest source and destination paths are explicit absolute paths selected by trusted workspace adapters, including repository metadata outside an exposed subdirectory. Directory transfers copy contents into an empty or absent destination and preserve the inode of an existing workspace. Regular permissions, symlinks, empty files, and filenames supported by the destination filesystem survive the roundtrip. Setuid and setgid bits are removed when crossing the transfer boundary. Archive extraction uses an owned staging directory on the destination filesystem and descriptor-relative publication. Parent traversal, repeated paths, hard links, device entries, and writes through symlink parents fail with the staging retained. Upload chunks carry an explicit completion frame before a durable import can execute. An interrupted upload keeps its operation ID and partial archive for inspection or cleanup. Operation records also retain failed import staging paths. Copying a directory into an existing populated destination raises `FileExistsError`. + +For SDK, CLI and Team execution, select `vm-group` or `vm-session` through the +existing isolation setting. The [session isolation guide](session-isolation.md#vm-strategies) +describes `VMResource`, the public `vm_isolation_provider` helper, serializable +`config["vm"]` profiles and `OPENCOLLAB_VM`. Both scopes use the same local lease +database and stable resource identities. Returned guests run the administrator's +reset command before their leases become available again. A process restart +preserves persistent leases for administrator recovery after verifying remote +process cleanup and resetting the guest. + +The [member-authored VM example](../examples/vm-authored-workflow/README.md) +starts with a Team member writing Workflow source inside its own guest. Its +child default selects `worktree`, and an explicit `vm-session` call allocates +another VM. Model requests use the existing host bridge while native results +and Session records return to the controller. Run the public integration suite +with `pytest -q tests/interfaces/test_sdk_vm_isolation.py` after setting +`OPENCOLLAB_TEST_VM_PROFILE` to a JSON profile for two dedicated guests. diff --git a/docs/virtual-machines.zh-CN.md b/docs/virtual-machines.zh-CN.md index bec4f5cf..656c3d56 100644 --- a/docs/virtual-machines.zh-CN.md +++ b/docs/virtual-machines.zh-CN.md @@ -5,8 +5,7 @@ 准备已有工作区,以及由 SSH 账号拥有的私有状态目录。操作状态放在工作区之外。每个环境在状态目录内使用一个运行 ID。控制端重启后重建环境时继续使用原运行 ID。 ```python -from opencollab.adapters.env import VMEnvironment -from opencollab.adapters.vm_config import VMConnection +from opencollab.environments import VMConnection, VMEnvironment connection = VMConnection( host="vm.example.net", @@ -49,3 +48,15 @@ await env.cleanup() 工作区适配器可以通过 `copy_to_host(source, target, directory=False)` 与 `copy_from_host(source, destination, directory=False)` 流式传输二进制文件和目录归档,传输大小独立于命令输出上限。客户机源路径和目标路径是工作区适配器明确指定的绝对路径,也可以指向暴露子目录之外的仓库元数据。目录复制将内容传入空目录或新目录,并保留已有工作区的 inode。常规权限、符号链接、空文件,以及目标文件系统支持的文件名会随传输保留。传输时移除 setuid 与 setgid 位。 归档先在目标文件系统内的自有临时目录解包,再通过目录描述符写入目标。父目录跳转、重复路径、硬链接、设备文件,以及经由符号链接父目录写入的情况会触发失败,并保留临时目录。上传分块带有明确结束帧,完整接收后才允许执行持久化导入。上传中断时保留操作编号和部分归档,供检查或清理继续处理。操作记录还保存失败导入的临时目录路径。向已有内容的目录复制时抛出 `FileExistsError`。 + +SDK、CLI 和 Team 通过既有隔离设置选择 `vm-group` 或 `vm-session`。 +[Session 隔离指南](session-isolation.zh-CN.md#vm-隔离策略) 介绍 `VMResource`、 +公开的 `vm_isolation_provider`、可序列化的 `config["vm"]` 和 `OPENCOLLAB_VM`。 +两种作用域使用相同的本地租约数据库和稳定资源标识。归还来宾时,先运行管理员提供的重置命令, +再释放租约。控制进程重启后,持久化租约继续保留,由管理员核实远端进程已清理并重置来宾后恢复。 + +[成员自编 VM 示例](../examples/vm-authored-workflow/README.zh-CN.md) 从 Team 成员在自己的来宾中写出 +Workflow 源码开始。子运行默认选择 `worktree`,显式 `vm-session` 调用会分配另一台 VM。 +模型请求继续使用既有宿主桥接,原生结果和 Session 记录传回控制端。 +将 `OPENCOLLAB_TEST_VM_PROFILE` 指向两台专用来宾的 JSON 配置后,运行 +`pytest -q tests/interfaces/test_sdk_vm_isolation.py` 验证公开入口。 From 3411b9659c0482d644756394cc79009e0170340f Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 17:18:52 +0800 Subject: [PATCH 04/15] fix(environments): allow configured VM cleanup to finish --- docs/session-isolation.md | 12 ++- docs/session-isolation.zh-CN.md | 10 ++- opencollab/adapters/cli/main.py | 12 ++- opencollab/adapters/cli/workflow.py | 4 + opencollab/bootstrap/authored_workflow.py | 19 ++++- .../bootstrap/authored_workflow_runner.py | 1 + opencollab/bootstrap/context_builder.py | 1 + opencollab/bootstrap/environment_profiles.py | 17 +++- opencollab/bootstrap/programmatic.py | 2 +- opencollab/bootstrap/programmatic_team.py | 6 +- opencollab/bootstrap/scheduler_factory.py | 3 + opencollab/bootstrap/workflow_runtime.py | 6 +- opencollab/sdk/client.py | 14 ++- tests/interfaces/test_sdk_api.py | 6 +- tests/interfaces/test_sdk_vm_isolation.py | 38 ++++++++- tests/interfaces/test_vm_profiles.py | 85 +++++++++++++++++++ 16 files changed, 209 insertions(+), 27 deletions(-) diff --git a/docs/session-isolation.md b/docs/session-isolation.md index f649e9b4..2ae17fdf 100644 --- a/docs/session-isolation.md +++ b/docs/session-isolation.md @@ -148,7 +148,7 @@ client = OpenCollab(".", environment_providers={ "vm-group": vm_isolation_provider(resources, lease_database="/var/tmp/oc-leases.sqlite3"), "vm-session": vm_isolation_provider(resources, lease_database="/var/tmp/oc-leases.sqlite3", scope="session"), }) -result = await client.workflow(solve, {"task": "Inspect the code"}, isolation="vm-group") +result = await client.workflow(solve, {"task": "Inspect the code"}, isolation="vm-group", cleanup_timeout=60) ``` The stable `resource_id` identifies the actual machine across connection aliases. @@ -170,12 +170,20 @@ lease. Successful reset returns the VM to the pool. An export, cleanup, transpor or reset failure preserves the lease and recovery material for retry. For a serializable SDK configuration, put the same values in `config["vm"]`. -The `vm` object contains `resources`, `lease_database`, and optional `wait_timeout`. +The `vm` object contains `resources`, `lease_database`, optional `wait_timeout`, +and `cleanup_timeout` with a default of 60 seconds. Each resource object contains the `VMResource` fields, with a `connection` object containing `VMConnection` fields. `OpenCollab` loads both VM strategies from this profile. Explicit `environment_providers` entries override matching configured names. +The profile's cleanup grace applies to SDK Team and Workflow calls, the CLI, +and member-authored child Workflows. An explicit `cleanup_timeout` on an SDK +call or child `launch` takes precedence. The Workflow CLI accepts +`--cleanup-timeout`. Without a VM profile, existing runtime cleanup defaults +continue to apply. Custom VM providers can set cleanup grace through those +same call options, as in the provider example above. + ```python import json from dataclasses import asdict diff --git a/docs/session-isolation.zh-CN.md b/docs/session-isolation.zh-CN.md index 3528a615..774369f6 100644 --- a/docs/session-isolation.zh-CN.md +++ b/docs/session-isolation.zh-CN.md @@ -103,7 +103,7 @@ client = OpenCollab(".", environment_providers={ "vm-group": vm_isolation_provider(resources, lease_database="/var/tmp/oc-leases.sqlite3"), "vm-session": vm_isolation_provider(resources, lease_database="/var/tmp/oc-leases.sqlite3", scope="session"), }) -result = await client.workflow(solve, {"task": "Inspect the code"}, isolation="vm-group") +result = await client.workflow(solve, {"task": "Inspect the code"}, isolation="vm-group", cleanup_timeout=60) ``` 稳定的 `resource_id` 标识实际机器,同一机器的不同连接别名应使用相同标识。 @@ -119,10 +119,16 @@ result = await client.workflow(solve, {"task": "Inspect the code"}, isolation="v 保留租约和恢复资料以供重试。 SDK 可将相同配置序列化后放入 `config["vm"]`。`vm` 对象包含 `resources`、 -`lease_database` 和可选的 `wait_timeout`。每个资源对象采用 `VMResource` 的字段, +`lease_database`、可选的 `wait_timeout` 和默认 60 秒的 `cleanup_timeout`。 +每个资源对象采用 `VMResource` 的字段, 其中 `connection` 对象采用 `VMConnection` 字段。OpenCollab 据此注册两种 VM 策略。 显式 `environment_providers` 中的同名项目优先。 +配置中的清理等待时间适用于 SDK Team、Workflow、CLI 和成员自编子 Workflow。 +SDK 调用或子运行 `launch` 显式传入的 `cleanup_timeout` 优先。 +Workflow CLI 支持 `--cleanup-timeout`。省略 VM 配置时,继续使用已有运行默认值。 +自定义 VM 提供者通过这些调用参数选择清理等待时间,上面的提供者示例使用了这一方式。 + ```python import json from dataclasses import asdict diff --git a/opencollab/adapters/cli/main.py b/opencollab/adapters/cli/main.py index 0e4c1484..d74efaf8 100644 --- a/opencollab/adapters/cli/main.py +++ b/opencollab/adapters/cli/main.py @@ -306,9 +306,13 @@ async def _drive( if not task.cancelled() and task.exception() is not None: raise task.exception() -async def _await_scheduler_cleanup(scheduler: Any) -> asyncio.CancelledError | None: +async def _await_scheduler_cleanup( + scheduler: Any, *, cleanup_timeout: float | None = None, +) -> asyncio.CancelledError | None: """Keep the CLI-owned scheduler teardown alive through repeated cancel.""" - cleanup_task = asyncio.create_task(scheduler.cleanup()) + cleanup_task = asyncio.create_task(scheduler.cleanup( + **({"cleanup_timeout": cleanup_timeout} if cleanup_timeout is not None else {}), + )) repeated_cancellation: asyncio.CancelledError | None = None while True: try: @@ -470,7 +474,9 @@ async def turn(line: str, target_aid: int, cancel: asyncio.Event) -> None: primary_failure = exc if scheduler is not None: try: - repeated_cancellation = await _await_scheduler_cleanup(scheduler) + repeated_cancellation = await _await_scheduler_cleanup( + scheduler, cleanup_timeout=(cfg.get("vm") or {}).get("cleanup_timeout"), + ) except BaseException as exc: cleanup_failure = exc if ctx.tracer: diff --git a/opencollab/adapters/cli/workflow.py b/opencollab/adapters/cli/workflow.py index 2183b3fe..e5fc797d 100644 --- a/opencollab/adapters/cli/workflow.py +++ b/opencollab/adapters/cli/workflow.py @@ -98,6 +98,9 @@ def run_cmd( help="Max active parallel/pipeline units (defaults to --concurrency)", ), isolation: Optional[str] = typer.Option(None, "--isolation", help="Default agent environment strategy"), + cleanup_timeout: Optional[float] = typer.Option( + None, "--cleanup-timeout", min=0.001, help="Cleanup grace in seconds (default from the VM profile or runtime)", + ), agent_profile: Optional[str] = typer.Option( None, "--agent-profile", @@ -173,6 +176,7 @@ def run_cmd( task_concurrency=task_concurrency, agent_profile=agent_profile, **({"isolation": isolation} if isolation is not None else {}), + **({"cleanup_timeout": cleanup_timeout} if cleanup_timeout is not None else {}), save_dir=save_dir, trace=trace, return_details=True, diff --git a/opencollab/bootstrap/authored_workflow.py b/opencollab/bootstrap/authored_workflow.py index e7edc94a..4529a8ec 100644 --- a/opencollab/bootstrap/authored_workflow.py +++ b/opencollab/bootstrap/authored_workflow.py @@ -38,6 +38,7 @@ class _Child: environment: EnvironmentPort artifacts: str budget: int | None + cleanup_timeout: float = 2.0 task: asyncio.Task[None] | None = None execution: asyncio.Task[Any] | None = None stop_requested: bool = False @@ -70,6 +71,8 @@ class AuthoredWorkflowTool(Tool): "python": {"type": "string", "default": "python3"}, "budget": {"type": "integer", "minimum": 1}, "concurrency": {"type": "integer", "minimum": 1, "default": 4}, + "cleanup_timeout": {"type": "number", "minimum": 0, + "description": "launch only. Cleanup grace in seconds. Omitted inherits the parent."}, "isolation": {"type": "string", "description": "launch only. Default strategy for child agents. " "Omitted inherits the member strategy."}, "timeout": {"type": "number", "minimum": 0, @@ -99,8 +102,8 @@ async def execute_with_runtime(self, params: dict[str, Any], runtime: ToolRuntim parent.own_runtime(self.aclose, reserve_tokens=self._reserved_tokens) self._registered = True action = params["action"] - if action != "launch" and "isolation" in params: - raise ValueError("isolation is only supported by the launch action") + if action != "launch" and (extra := {"isolation", "cleanup_timeout"} & params.keys()): + raise ValueError(f"{' and '.join(sorted(extra))} is only supported by the launch action") if action == "launch": async with self._launch_lock: child = await self._launch(params, runtime) @@ -161,6 +164,12 @@ async def _launch(self, params: dict[str, Any], runtime: ToolRuntime) -> _Child: if remaining <= 0: raise ValueError("Team session token budget is exhausted or reserved by child runs") budget = max(1, remaining // 2) if budget is None else min(budget, remaining) + cleanup_timeout = params.get("cleanup_timeout", self._config.workflow_cleanup_timeout) + if ( + isinstance(cleanup_timeout, bool) or not isinstance(cleanup_timeout, (int, float)) + or not math.isfinite(cleanup_timeout) or cleanup_timeout <= 0 + ): + raise ValueError("cleanup_timeout must be finite and positive") pool = (self._environment_pool_factory(environment, role=parent.agent.name) if self._environment_pool_factory is not None else SessionEnvironmentPool( environment.workspace, isolation="shared", base_environment=environment, @@ -176,7 +185,7 @@ async def _launch(self, params: dict[str, Any], runtime: ToolRuntime) -> _Child: raise child_id = f"child-{uuid.uuid4().hex}" artifacts = posixpath.join(environment.workspace, ".opencollab", "child-runs", child_id) - child = _Child(child_id, environment, artifacts, budget) + child = _Child(child_id, environment, artifacts, budget, cleanup_timeout=cleanup_timeout) config = {key: value for key, value in {**vars(self._config), **vars(parent.agent)}.items() if key in {"model", "provider", "wire_protocol", "api_key", "auth_token", "base_url", "temperature", "top_p", "thinking", "thinking_params", "reasoning_effort", @@ -194,6 +203,7 @@ async def _launch(self, params: dict[str, Any], runtime: ToolRuntime) -> _Child: request = {"id": child_id, "source": source, "entry": params.get("entry", "main"), "args": params.get("args", {}), "config": config, "budget": budget, "max_steps": parent.max_steps, "concurrency": params.get("concurrency", 4), + "cleanup_timeout": cleanup_timeout, "allowed_tools": [tool.name for tool in parent.agent.tools], "tool_limits": tool_limits, "process_isolated": isolated, @@ -328,7 +338,8 @@ async def _stop(self, child: _Child) -> None: child.stop_requested = True try: await child.environment.write_file(posixpath.join(child.artifacts, "stop"), "stop") - await asyncio.wait_for(asyncio.shield(child.task), timeout=_RUNNER_STOP_GRACE_SECONDS) + await asyncio.wait_for(asyncio.shield(child.task), + timeout=max(_RUNNER_STOP_GRACE_SECONDS, child.cleanup_timeout)) except (OSError, RuntimeError, asyncio.TimeoutError): if child.execution is not None and not child.execution.done(): child.execution.cancel() diff --git a/opencollab/bootstrap/authored_workflow_runner.py b/opencollab/bootstrap/authored_workflow_runner.py index 1a81c13c..9335af89 100644 --- a/opencollab/bootstrap/authored_workflow_runner.py +++ b/opencollab/bootstrap/authored_workflow_runner.py @@ -99,6 +99,7 @@ async def execute() -> Any: max_steps=request["max_steps"], max_concurrency=request["concurrency"], save_dir=str(artifacts), trace=False, run_id=request["id"], session_builder=build_child, return_details=True, limit_mode="explicit", + cleanup_timeout=request.get("cleanup_timeout", 2.0), **({"isolation": request["bridge"]["isolation"], "environment_providers": {name: partial(bridge.provider, name) for name in request["bridge"]["strategies"]}} diff --git a/opencollab/bootstrap/context_builder.py b/opencollab/bootstrap/context_builder.py index 39fa6dc3..137c3b6a 100644 --- a/opencollab/bootstrap/context_builder.py +++ b/opencollab/bootstrap/context_builder.py @@ -78,6 +78,7 @@ class SpawnConfig: request_pool: str | None = None request_capacity: int | None = None logical_model: str | None = None + workflow_cleanup_timeout: float = 2.0 def model_settings(self, model: str | None = None) -> dict[str, Any]: return resolve_model_route( diff --git a/opencollab/bootstrap/environment_profiles.py b/opencollab/bootstrap/environment_profiles.py index 1a4b9b98..8276a2f3 100644 --- a/opencollab/bootstrap/environment_profiles.py +++ b/opencollab/bootstrap/environment_profiles.py @@ -16,6 +16,7 @@ class VMProfile(BaseModel): resources: tuple[VMResource, ...] = Field(min_length=1) lease_database: str wait_timeout: float | None = Field(default=0, ge=0, allow_inf_nan=False) + cleanup_timeout: float = Field(default=60, gt=0, allow_inf_nan=False) @field_validator("resources", mode="before") @classmethod @@ -45,11 +46,11 @@ def _lease_database(cls, value: str) -> str: raise ValueError("VM lease_database must be an absolute local path") return value - @field_validator("wait_timeout", mode="before") + @field_validator("wait_timeout", "cleanup_timeout", mode="before") @classmethod def _wait_timeout(cls, value: Any) -> Any: if isinstance(value, bool): - raise ValueError("VM wait_timeout must be a non-negative number or None") + raise ValueError("VM timeouts must be numeric") return value @@ -75,3 +76,15 @@ def configured_environment_providers( f"isolation strategy {isolation!r} requires VM resources in config.vm or an environment provider" ) return selected + + +def environment_cleanup_timeout(config: Mapping[str, Any], requested: float | None, *, default: float) -> float: + if requested is not None: + return requested + value = config.get("vm") + if value is None: + return default + if isinstance(value, str): + value = json.loads(value) + profile = value if isinstance(value, VMProfile) else VMProfile.model_validate(value) + return profile.cleanup_timeout diff --git a/opencollab/bootstrap/programmatic.py b/opencollab/bootstrap/programmatic.py index bb961ecd..51ef3393 100644 --- a/opencollab/bootstrap/programmatic.py +++ b/opencollab/bootstrap/programmatic.py @@ -740,7 +740,7 @@ async def run_team( team_config_path: str | os.PathLike[str] | None, max_tokens: int, timeout: float | None, - cleanup_timeout: float = DEFAULT_TEAM_CLEANUP_TIMEOUT_SECONDS, + cleanup_timeout: float | None = None, artifacts: Path | None, trace: bool, use_worktrees: bool, diff --git a/opencollab/bootstrap/programmatic_team.py b/opencollab/bootstrap/programmatic_team.py index 2371e4e8..01b9e713 100644 --- a/opencollab/bootstrap/programmatic_team.py +++ b/opencollab/bootstrap/programmatic_team.py @@ -14,6 +14,7 @@ from opencollab.application.exception_notes import add_exception_note from opencollab.application.scheduler_types import SchedulerTurnError from opencollab.bootstrap import programmatic as _programmatic +from opencollab.bootstrap.environment_profiles import environment_cleanup_timeout from opencollab.bootstrap.isolation import prepare_scheduler from opencollab.bootstrap.programmatic import ( DEFAULT_TEAM_CLEANUP_TIMEOUT_SECONDS, @@ -33,7 +34,7 @@ async def run_team( team_config_path: str | os.PathLike[str] | None, max_tokens: int, timeout: float | None, - cleanup_timeout: float = DEFAULT_TEAM_CLEANUP_TIMEOUT_SECONDS, + cleanup_timeout: float | None = None, artifacts: Path | None, trace: bool, use_worktrees: bool, @@ -57,6 +58,8 @@ async def run_team( ``environment`` supplies the agents' execution workspace. ``workspace`` anchors local configuration, skills, the repository map and session storage. """ + workflow_cleanup_timeout = environment_cleanup_timeout(config, cleanup_timeout, default=2.0) + cleanup_timeout = environment_cleanup_timeout(config, cleanup_timeout, default=DEFAULT_TEAM_CLEANUP_TIMEOUT_SECONDS) team_config = load_team_config(workspace, path=team_config_path) if team_config.role_budgets and budget_explicit: raise ValueError( @@ -81,6 +84,7 @@ async def run_team( scheduler = await prepare_scheduler( context, builder=_programmatic.build_scheduler, isolation=isolation, environment_providers=environment_providers, + workflow_cleanup_timeout=workflow_cleanup_timeout, use_worktrees=use_worktrees, # Programmatic runs cannot answer ``ask_user``. The caller separately # chooses whether to allow an unsandboxed shell. diff --git a/opencollab/bootstrap/scheduler_factory.py b/opencollab/bootstrap/scheduler_factory.py index 3a7e9d86..eeebed9f 100644 --- a/opencollab/bootstrap/scheduler_factory.py +++ b/opencollab/bootstrap/scheduler_factory.py @@ -26,6 +26,7 @@ ) from opencollab.bootstrap.container import RuntimeContext, build_workspace_safety_policy from opencollab.bootstrap.context_builder import SpawnConfig +from opencollab.bootstrap.environment_profiles import environment_cleanup_timeout from opencollab.bootstrap.session_factory import ( SESSION_MAX_STEPS, DefaultSessionFactory, @@ -100,6 +101,7 @@ def build_scheduler( record_delivery_tree: bool = False, run_id: str | None = None, worktree_pool=None, + workflow_cleanup_timeout: float | None = None, ) -> Scheduler: """Compose a team and initialize agent 0 with the selected configuration. @@ -186,6 +188,7 @@ def build_scheduler( llm_stream_idle_timeout=cfg.get("llm_stream_idle_timeout", 180.0), llm_stream_chat=bool(cfg.get("llm_stream_chat", False)), provider_error_time_budget=cfg.get("provider_error_time_budget", 0.0), + workflow_cleanup_timeout=environment_cleanup_timeout(cfg, workflow_cleanup_timeout, default=2.0), tracer=ctx.tracer, event_bus=event_bus, permission_policy=ctx.permission_policy, diff --git a/opencollab/bootstrap/workflow_runtime.py b/opencollab/bootstrap/workflow_runtime.py index ba977377..c22a468a 100644 --- a/opencollab/bootstrap/workflow_runtime.py +++ b/opencollab/bootstrap/workflow_runtime.py @@ -35,6 +35,7 @@ resolve_agent_profile, ) from opencollab.bootstrap.agent_runtime import revoke_and_abort_environment +from opencollab.bootstrap.environment_profiles import environment_cleanup_timeout from opencollab.bootstrap.session_factory import build_session # The integrity runtime's own bounded cleanup can legitimately span several @@ -85,7 +86,7 @@ async def run_workflow( save_dir: str | None = None, trace: bool = True, env: Any | None = None, - cleanup_timeout: float = DEFAULT_WORKFLOW_CLEANUP_TIMEOUT_SECONDS, + cleanup_timeout: float | None = None, source_root: str | None = None, deadline_monotonic: float | None = None, deadline_margin_seconds: float = 120.0, @@ -101,6 +102,9 @@ async def run_workflow( environment_providers: Any | None = None, ) -> Any: """Run through one owned lifecycle with an optional wall-clock deadline.""" + cleanup_timeout = environment_cleanup_timeout( + cfg, cleanup_timeout, default=DEFAULT_WORKFLOW_CLEANUP_TIMEOUT_SECONDS, + ) if isinstance(agent_profile, str): agent_profile = resolve_agent_profile(agent_profile) if cleanup_environment is None: diff --git a/opencollab/sdk/client.py b/opencollab/sdk/client.py index 160a9332..565573a5 100644 --- a/opencollab/sdk/client.py +++ b/opencollab/sdk/client.py @@ -14,8 +14,8 @@ from opencollab.application.isolation import isolation_name from opencollab.application.run_control import RunControl from opencollab.bootstrap.config import _optional_string, build_config, resolve_provider_base_url +from opencollab.bootstrap.environment_profiles import environment_cleanup_timeout from opencollab.bootstrap.programmatic import ( - DEFAULT_TEAM_CLEANUP_TIMEOUT_SECONDS, ProgrammaticLifecycleError, ProgrammaticResult, run_agent, @@ -322,7 +322,7 @@ async def team( config: str | os.PathLike[str] | None = None, budget: int | None = None, timeout: float | None = None, - cleanup_timeout: float = DEFAULT_TEAM_CLEANUP_TIMEOUT_SECONDS, + cleanup_timeout: float | None = None, artifacts: str | os.PathLike[str] | None = None, trace: bool = True, use_worktrees: bool = True, @@ -382,10 +382,8 @@ async def team( "budget", ), timeout=_positive_timeout(timeout, "timeout"), - cleanup_timeout=_required_positive_timeout( - cleanup_timeout, - "cleanup_timeout", - ), + cleanup_timeout=(None if cleanup_timeout is None else + _required_positive_timeout(cleanup_timeout, "cleanup_timeout")), artifacts=_path(artifacts, "artifacts"), trace=trace, use_worktrees=use_worktrees, @@ -417,7 +415,7 @@ async def workflow( max_steps: int | None = 100, system_prompt: str | None = None, agent_profile: str | None = None, - cleanup_timeout: float = 2.0, + cleanup_timeout: float | None = None, artifacts: str | os.PathLike[str] | None = None, trace: bool = True, candidate_workspace: Any | None = None, @@ -514,7 +512,7 @@ async def workflow( "environment_providers": self._environment_providers} if isolation is not None or self._environment_providers else {}), cleanup_timeout=_required_positive_timeout( - cleanup_timeout, + environment_cleanup_timeout(self._config, cleanup_timeout, default=2.0), "cleanup_timeout", ), artifacts=_path(artifacts, "artifacts"), diff --git a/tests/interfaces/test_sdk_api.py b/tests/interfaces/test_sdk_api.py index bf6bff54..5a27f09a 100644 --- a/tests/interfaces/test_sdk_api.py +++ b/tests/interfaces/test_sdk_api.py @@ -72,7 +72,7 @@ def test_public_class_and_method_shapes_stay_lean() -> None: positional: {"self": required, "prompt": required}, keyword: { "config": None, "budget": None, "timeout": None, - "cleanup_timeout": 10.0, "artifacts": None, "trace": True, + "cleanup_timeout": None, "artifacts": None, "trace": True, "use_worktrees": True, "prebuild_team": False, "isolation": None, "allow_unisolated_shell": None, "max_steps": 100, "serialize_turns": False, "record_delivery_tree": False, @@ -84,7 +84,7 @@ def test_public_class_and_method_shapes_stay_lean() -> None: keyword: { "budget": None, "concurrency": 4, "task_concurrency": None, "timeout": None, "max_steps": 100, "system_prompt": None, - "agent_profile": None, "cleanup_timeout": 2.0, + "agent_profile": None, "cleanup_timeout": None, "artifacts": None, "trace": True, "candidate_workspace": None, "run_id": None, "llm": None, "limit_mode": "environment", "isolation": None, }, @@ -167,6 +167,7 @@ def test_advanced_capabilities_live_in_small_opt_in_modules() -> None: ] assert environments.__all__ == [ "Environment", "EnvironmentPool", "DockerMount", "DockerOptions", + "VMConnection", "VMEnvironment", "VMResource", "attach_container", # A workspace an agent reads is not always on this host, so the listing # of it is asked of the environment rather than walked here. @@ -175,6 +176,7 @@ def test_advanced_capabilities_live_in_small_opt_in_modules() -> None: "docker_isolation_provider", "local_environment", "worktree_environment", + "vm_isolation_provider", ] assert models.__all__ == ["inspect_model_runtime"] assert workflows.__all__ == ["CandidateRun", "WorkflowAgentResult", "WorkflowContext", "workflow"] diff --git a/tests/interfaces/test_sdk_vm_isolation.py b/tests/interfaces/test_sdk_vm_isolation.py index b0ccc5e9..709486c2 100644 --- a/tests/interfaces/test_sdk_vm_isolation.py +++ b/tests/interfaces/test_sdk_vm_isolation.py @@ -7,12 +7,14 @@ import shlex import sqlite3 import sys +import time import uuid from pathlib import Path import pytest from opencollab import OpenCollab +from opencollab.adapters.vm_isolation import VMIsolationPool from opencollab.bootstrap import _workflow_runtime_session as wiring from opencollab.bootstrap.authored_workflow import AuthoredWorkflowTool from opencollab.environments import VMConnection, VMEnvironment @@ -34,6 +36,22 @@ async def vm_profile(tmp_path, monkeypatch): monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) identity = f"sdk-{uuid.uuid4().hex}" controls = [] + cleanup_operations = [] + for method_name in ("_export", "_cleanup_owner"): + method = getattr(VMIsolationPool, method_name) + + async def measured(pool, *args, _method=method, _name=method_name): + started = time.monotonic() + completed = False + try: + result = await _method(pool, *args) + completed = True + return result + finally: + cleanup_operations.append({"operation": _name, "resource_id": args[-1].resource.resource_id, + "seconds": time.monotonic() - started, "completed": completed}) + + monkeypatch.setattr(VMIsolationPool, method_name, measured) for resource in profile["resources"]: control = VMEnvironment(VMConnection(**resource["connection"]), resource["workspace_root"], state_directory=resource["state_directory"]) @@ -57,11 +75,29 @@ async def vm_profile(tmp_path, monkeypatch): retained = [] if Path(profile["lease_database"]).exists(): with sqlite3.connect(profile["lease_database"]) as database: - retained = database.execute("SELECT resource_id FROM vm_leases").fetchall() + retained = database.execute("SELECT resource_id, owner, workspace FROM vm_leases").fetchall() + guests = [] for control, root, state in controls: + script = '''import json, pathlib, socket, sys +root, state = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) +receipt = state / 'reset-receipt' +print(json.dumps({'hostname': socket.gethostname(), + 'boot_id': pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip(), + 'workspace': str(root), 'state_directory': str(state), + 'reset_receipt': receipt.read_text() if receipt.exists() else '', + 'remaining_workspace_entries': sorted(path.name for path in root.iterdir())})) +''' + try: + evidence = await control.exec_cmd("python3 - " + shlex.quote(root) + " " + shlex.quote(state), + input_bytes=script.encode()) + guests.append(json.loads(evidence.stdout)) + except Exception as exc: + guests.append({"workspace": root, "receipt_error": str(exc)}) if not retained: await control.exec_cmd(f"rm -rf -- {shlex.quote(root)} {shlex.quote(state)}") await control.cleanup() + (tmp_path / "vm-receipt.json").write_text(json.dumps({"guests": guests, "retained_leases": retained, + "cleanup_operations": cleanup_operations}, indent=2)) @pytest.mark.parametrize("scope, unbounded", [("group", False), ("session", False), ("group", True)]) diff --git a/tests/interfaces/test_vm_profiles.py b/tests/interfaces/test_vm_profiles.py index 0afbcf0e..5d91846b 100644 --- a/tests/interfaces/test_vm_profiles.py +++ b/tests/interfaces/test_vm_profiles.py @@ -3,11 +3,14 @@ import json import os import sys +from types import SimpleNamespace import pytest from opencollab import OpenCollab from opencollab.adapters.session_environments import SessionEnvironmentPool +from opencollab.bootstrap import _workflow_runtime_execution as workflow_execution +from opencollab.bootstrap import programmatic from opencollab.bootstrap.config import build_config from opencollab.bootstrap.environment_profiles import configured_environment_providers from opencollab.environments import VMConnection, VMEnvironment, VMResource, vm_isolation_provider @@ -53,6 +56,7 @@ def test_vm_config_round_trips_and_environment_factories_remain_overridable(tmp_ @pytest.mark.parametrize("change", [ {"resources": []}, {"lease_database": "relative.sqlite3"}, {"wait_timeout": -1}, {"wait_timeout": True}, {"wait_timeout": float("inf")}, {"unexpected": "value"}, + {"cleanup_timeout": 0}, {"cleanup_timeout": True}, {"cleanup_timeout": float("nan")}, {"resources": [{"resource_id": "a", "connection": {"host": "guest", "user": "worker", "port": True}}]}, {"resources": [{"resource_id": "a", "connection": {"host": "guest", "user": "worker", "password": "unused"}}]}, ]) @@ -128,6 +132,87 @@ async def test_authored_isolation_override_is_validated_at_launch(tmp_path, loca await parent.aclose() +@pytest.mark.parametrize("with_vm, explicit, parent_grace, child_grace", [ + (False, None, 10, 2), (True, None, 60, 60), (False, 7, 7, 7), (True, 7, 7, 7), +]) +async def test_team_cleanup_defaults_preserve_child_grace_and_accept_explicit_override( + tmp_path, monkeypatch, with_vm, explicit, parent_grace, child_grace, +): + captured = {} + + class Scheduler: + used_tokens = 0 + table = SimpleNamespace(entries={0: object()}) + lead_session = SimpleNamespace(phase=SimpleNamespace(value="done"), state=SimpleNamespace(terminal_reason=None)) + + async def run(self, prompt): + return "done" + + async def cleanup(self, *, cleanup_timeout): + captured["parent_grace"] = cleanup_timeout + if "worktree_pool" in captured: + await captured["worktree_pool"].release() + + def build(_ctx, **kwargs): + captured.update(kwargs) + return Scheduler() + + monkeypatch.setattr(programmatic, "build_scheduler", build) + result = await OpenCollab(tmp_path, config={"vm": _profile(tmp_path)} if with_vm else {}).team( + "done", use_worktrees=False, trace=False, + **({"cleanup_timeout": explicit} if explicit is not None else {}), + ) + assert result.ok + assert captured["parent_grace"] == parent_grace + assert captured["workflow_cleanup_timeout"] == child_grace + + +@pytest.mark.parametrize("with_vm, explicit, expected", [(False, None, 2), (True, None, 60), (True, 7, 7)]) +async def test_workflow_cleanup_uses_profile_default_and_explicit_override( + tmp_path, monkeypatch, with_vm, explicit, expected, +): + captured = [] + finalize = workflow_execution._quiesce_and_finalize_workflow_context + + async def remember(ctx, *, timeout): + captured.append(timeout) + return await finalize(ctx, timeout=timeout) + + monkeypatch.setattr(workflow_execution, "_quiesce_and_finalize_workflow_context", remember) + + async def flow(ctx, args): + return "done" + + result = await OpenCollab(tmp_path, config={"vm": _profile(tmp_path)} if with_vm else {}).workflow( + flow, trace=False, **({"cleanup_timeout": explicit} if explicit is not None else {}), + ) + assert result.ok + assert captured == [expected] + + +@pytest.mark.parametrize("inherited, explicit, expected", [(2, None, 2), (60, None, 60), (60, 7, 7)]) +async def test_authored_launch_inherits_cleanup_grace_and_can_override_it( + tmp_path, local_environment_factory, inherited, explicit, expected, +): + environment = local_environment_factory(str(tmp_path)) + (tmp_path / "authored.py").write_text("async def main(ctx, args):\n return 'done'\n") + parent, tool, runtime = _manager(environment) + tool._config.workflow_cleanup_timeout = inherited + try: + launched = json.loads(await tool.execute_with_runtime({ + "action": "launch", "source": "authored.py", "python": sys.executable, + **({"cleanup_timeout": explicit} if explicit is not None else {}), + }, runtime)) + child = tool._children[launched["id"]] + assert child.cleanup_timeout == expected + result = json.loads(await tool.execute_with_runtime({"action": "wait", "id": child.id}, runtime)) + assert result["result"]["output"] == "done", result + with pytest.raises(ValueError, match="only supported by the launch"): + await tool.execute_with_runtime({"action": "status", "cleanup_timeout": 7}, runtime) + finally: + await parent.aclose() + + @pytest.mark.parametrize("budget", [None, 20_000]) async def test_authored_source_uses_host_model_without_receiving_host_credentials( tmp_path, local_environment_factory, budget, From 064ecb991c70b59702021ae20676cbe4c1e7ccbb Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 17:35:05 +0800 Subject: [PATCH 05/15] fix(cli): retain configured cleanup grace after interruption --- opencollab/adapters/cli/main.py | 6 +- opencollab/adapters/cli/workflow.py | 4 +- tests/interfaces/test_cli_main_lifecycle.py | 2 +- tests/interfaces/test_cli_vm_shutdown.py | 86 +++++++++++++++++++++ tests/interfaces/test_sdk_vm_isolation.py | 2 +- tests/workflows/test_workflow_cli.py | 3 + 6 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/interfaces/test_cli_vm_shutdown.py diff --git a/opencollab/adapters/cli/main.py b/opencollab/adapters/cli/main.py index d74efaf8..c4570414 100644 --- a/opencollab/adapters/cli/main.py +++ b/opencollab/adapters/cli/main.py @@ -134,7 +134,8 @@ def main_callback( team_config_path=team_config, one_shot_prompt=one_shot, hold_after_run=hold, - ) + ), + shutdown_timeout=(cfg.get("vm") or {}).get("cleanup_timeout", 2.0), ) def _initial_model_config(cfg: dict[str, Any], team_config_path: str | None) -> dict[str, Any]: @@ -474,8 +475,9 @@ async def turn(line: str, target_aid: int, cancel: asyncio.Event) -> None: primary_failure = exc if scheduler is not None: try: + cleanup_grace = (cfg.get("vm") or {}).get("cleanup_timeout") repeated_cancellation = await _await_scheduler_cleanup( - scheduler, cleanup_timeout=(cfg.get("vm") or {}).get("cleanup_timeout"), + scheduler, **({"cleanup_timeout": cleanup_grace} if cleanup_grace is not None else {}), ) except BaseException as exc: cleanup_failure = exc diff --git a/opencollab/adapters/cli/workflow.py b/opencollab/adapters/cli/workflow.py index e5fc797d..2734e15e 100644 --- a/opencollab/adapters/cli/workflow.py +++ b/opencollab/adapters/cli/workflow.py @@ -180,7 +180,9 @@ def run_cmd( save_dir=save_dir, trace=trace, return_details=True, - ) + ), + shutdown_timeout=(cleanup_timeout if cleanup_timeout is not None else + (cfg.get("vm") or {}).get("cleanup_timeout", 2.0)), ) if save_dir is not None: console.print(f"[dim]== sessions saved to {save_dir}[/dim]") diff --git a/tests/interfaces/test_cli_main_lifecycle.py b/tests/interfaces/test_cli_main_lifecycle.py index fa16485d..5c37b636 100644 --- a/tests/interfaces/test_cli_main_lifecycle.py +++ b/tests/interfaces/test_cli_main_lifecycle.py @@ -266,7 +266,7 @@ async def fake_run(**kwargs): monkeypatch.setattr( cli_main, "run_with_bounded_shutdown", - lambda coroutine: asyncio.run(coroutine), + lambda coroutine, **_kwargs: asyncio.run(coroutine), ) result = CliRunner().invoke( diff --git a/tests/interfaces/test_cli_vm_shutdown.py b/tests/interfaces/test_cli_vm_shutdown.py new file mode 100644 index 00000000..f0abfea3 --- /dev/null +++ b/tests/interfaces/test_cli_vm_shutdown.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time + +import pytest + +from tests.support.paths import REPO_ROOT + +_DRIVER = '''import asyncio, importlib, json, sys +from pathlib import Path +entry, path, requested, delay = sys.argv[1:] +root = Path(path) +module = importlib.import_module("opencollab.adapters.cli." + ("main" if entry == "team" else "workflow")) +shutdown = module.run_with_bounded_shutdown + +def remember(awaitable, **kwargs): + (root / "shutdown.json").write_text(json.dumps(kwargs)) + return shutdown(awaitable, **kwargs) + +async def run(*args, **kwargs): + try: + (root / "entered").write_text("ready") + await asyncio.sleep(300) + finally: + (root / "cleanup-started").write_text("started") + await asyncio.sleep(float(delay)) + (root / "cleanup-complete").write_text("complete") + +module.run_with_bounded_shutdown = remember +if entry == "team": + module._run = run + arguments = ["--workspace", path, "--prompt", "run"] +else: + module.run_workflow = run + arguments = ["workflow", "run", "probe", "--workspace", path, "--no-save", "--no-trace"] + if requested != "none": + arguments.extend(["--cleanup-timeout", requested]) +importlib.import_module("opencollab.adapters.cli.main").app(args=arguments, standalone_mode=False) +''' + + +@pytest.mark.parametrize("entry, profile, explicit, expected", [ + ("team", True, None, 6), ("workflow", True, None, 6), ("workflow", True, 4, 4), + ("team", False, None, 2), ("workflow", False, None, 2), +]) +def test_cli_sigint_finishes_cleanup_with_the_selected_grace(tmp_path, entry, profile, explicit, expected): + (tmp_path / "driver.py").write_text(_DRIVER) + workflows = tmp_path / "workflows" + workflows.mkdir() + (workflows / "probe.py").write_text( + "from opencollab.workflows import workflow\n@workflow\nasync def probe(ctx, args):\n return 'unused'\n" + ) + if profile: + configs = tmp_path / "configs" + configs.mkdir() + value = {"resources": [{"resource_id": "unused", "connection": {"host": "unused.invalid", "user": "worker"}}], + "lease_database": str(tmp_path / "unused.sqlite3"), "cleanup_timeout": 6} + (configs / ".env").write_text("OPENCOLLAB_VM='" + json.dumps(value) + "'\n") + environment = {key: value for key, value in os.environ.items() if not key.startswith("OPENCOLLAB_")} + environment.update(PYTHONPATH=str(REPO_ROOT), OPENCOLLAB_API_KEY="offline-cli-test", # pragma: allowlist secret + OPENCOLLAB_API_USAGE_LOG=os.devnull) + process = subprocess.Popen( + [sys.executable, str(tmp_path / "driver.py"), entry, str(tmp_path), str(explicit) if explicit else "none", + "3" if profile else "0.2"], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + try: + deadline = time.monotonic() + 15 + while not (tmp_path / "entered").exists(): + if process.poll() is not None or time.monotonic() > deadline: + pytest.fail("CLI failed to enter the run") + time.sleep(0.02) + process.send_signal(signal.SIGINT) + stdout, stderr = process.communicate(timeout=15) + assert process.returncode == 0, (stdout, stderr) + assert (tmp_path / "cleanup-started").exists() + assert (tmp_path / "cleanup-complete").exists() + assert json.loads((tmp_path / "shutdown.json").read_text())["shutdown_timeout"] == expected + finally: + if process.poll() is None: + process.kill() + process.communicate() diff --git a/tests/interfaces/test_sdk_vm_isolation.py b/tests/interfaces/test_sdk_vm_isolation.py index 709486c2..38677cec 100644 --- a/tests/interfaces/test_sdk_vm_isolation.py +++ b/tests/interfaces/test_sdk_vm_isolation.py @@ -166,7 +166,7 @@ async def test_vm_member_authors_workflow_and_collects_two_guest_results(tmp_pat saved = Path(child["artifacts"]) assert json.loads((saved / "workflow.json").read_text())["sessions"] == 2 assert len(list(saved.glob("[0-9]*.json"))) == 2 - assert all("host-offline" not in path.read_text() for path in saved.glob("*.json")) + assert all("offline-model" not in path.read_text() for path in saved.glob("*.json")) async def test_vm_parent_cancellation_stops_authored_agents_and_preserves_native_records( diff --git a/tests/workflows/test_workflow_cli.py b/tests/workflows/test_workflow_cli.py index f6c2fc4f..43b885be 100644 --- a/tests/workflows/test_workflow_cli.py +++ b/tests/workflows/test_workflow_cli.py @@ -71,6 +71,8 @@ async def fake_run_workflow(spec_or_fn, args, **kwargs): "2", "--task-concurrency", "5", + "--cleanup-timeout", + "7.5", "--workspace", str(workspace), ], @@ -83,6 +85,7 @@ async def fake_run_workflow(spec_or_fn, args, **kwargs): assert captured["kwargs"]["budget"] == 50000 assert captured["kwargs"]["max_concurrency"] == 2 assert captured["kwargs"]["task_concurrency"] == 5 + assert captured["kwargs"]["cleanup_timeout"] == 7.5 assert captured["kwargs"]["return_details"] is True assert captured["registry_workspace"] == str(workspace) From 5463483b775a7a0028779c277c269b88556858cf Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 17:45:21 +0800 Subject: [PATCH 06/15] fix(runtime): honor configured scheduler cleanup on cancellation --- opencollab/application/_scheduler_cleanup.py | 12 +++++-- opencollab/application/scheduler.py | 3 ++ opencollab/bootstrap/programmatic_team.py | 1 + opencollab/bootstrap/scheduler_factory.py | 2 ++ tests/interfaces/test_vm_profiles.py | 37 ++++++++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/opencollab/application/_scheduler_cleanup.py b/opencollab/application/_scheduler_cleanup.py index 6f13dbd6..29063672 100644 --- a/opencollab/application/_scheduler_cleanup.py +++ b/opencollab/application/_scheduler_cleanup.py @@ -6,9 +6,9 @@ import inspect import logging import math +from enum import Enum from typing import Any -from opencollab.application._scheduler_constants import DEFAULT_SCHEDULER_CLEANUP_TIMEOUT from opencollab.application.async_timeout import ( await_owned_operation, cancel_tasks_and_wait, @@ -19,6 +19,10 @@ logger = logging.getLogger(__name__) + +class _CleanupTimeout(Enum): + INHERIT = "inherit" + def _unique_task_owners(*groups: Any) -> list[tuple[int, asyncio.Task[Any]]]: owners: list[tuple[int, asyncio.Task[Any]]] = [] seen: set[asyncio.Task[Any]] = set() @@ -33,10 +37,12 @@ class SchedulerCleanupMixin: async def cleanup( self, *, - cleanup_timeout: float = DEFAULT_SCHEDULER_CLEANUP_TIMEOUT, + cleanup_timeout: float | _CleanupTimeout = _CleanupTimeout.INHERIT, ) -> None: """Stop scheduler-owned work and persist one terminal snapshot.""" - timeout = self._validate_cleanup_timeout(cleanup_timeout) + timeout = self._validate_cleanup_timeout( + self._cleanup_timeout if cleanup_timeout is _CleanupTimeout.INHERIT else cleanup_timeout + ) if self._cleanup_task is None: self._cleanup_task = asyncio.create_task(self._cleanup_impl(timeout=timeout)) task = self._cleanup_task diff --git a/opencollab/application/scheduler.py b/opencollab/application/scheduler.py index 5adb0297..48d8a4e7 100644 --- a/opencollab/application/scheduler.py +++ b/opencollab/application/scheduler.py @@ -11,6 +11,7 @@ from typing import Any, Awaitable, Callable, Mapping from opencollab.application._scheduler_cleanup import SchedulerCleanupMixin +from opencollab.application._scheduler_constants import DEFAULT_SCHEDULER_CLEANUP_TIMEOUT from opencollab.application._scheduler_delivery_tree import SchedulerDeliveryTreeMixin from opencollab.application._scheduler_persistence import SchedulerPersistenceMixin from opencollab.application._scheduler_review import SchedulerReviewMixin @@ -81,8 +82,10 @@ def __init__( role_budgets: Mapping[str, int] | None = None, entry_role: str | None = None, candidate_diff_writer: Callable[[int, str, str], Awaitable[str]] | None = None, + cleanup_timeout: float = DEFAULT_SCHEDULER_CLEANUP_TIMEOUT, ): self.preserve_entry_changes = False + self._cleanup_timeout = self._validate_cleanup_timeout(cleanup_timeout) self.entry_patch_path: str | None = None self._session_factory = session_factory self._worktree_pool = worktree_pool diff --git a/opencollab/bootstrap/programmatic_team.py b/opencollab/bootstrap/programmatic_team.py index 01b9e713..5303eaa2 100644 --- a/opencollab/bootstrap/programmatic_team.py +++ b/opencollab/bootstrap/programmatic_team.py @@ -85,6 +85,7 @@ async def run_team( context, builder=_programmatic.build_scheduler, isolation=isolation, environment_providers=environment_providers, workflow_cleanup_timeout=workflow_cleanup_timeout, + cleanup_timeout=cleanup_timeout, use_worktrees=use_worktrees, # Programmatic runs cannot answer ``ask_user``. The caller separately # chooses whether to allow an unsandboxed shell. diff --git a/opencollab/bootstrap/scheduler_factory.py b/opencollab/bootstrap/scheduler_factory.py index eeebed9f..05342d91 100644 --- a/opencollab/bootstrap/scheduler_factory.py +++ b/opencollab/bootstrap/scheduler_factory.py @@ -102,6 +102,7 @@ def build_scheduler( run_id: str | None = None, worktree_pool=None, workflow_cleanup_timeout: float | None = None, + cleanup_timeout: float | None = None, ) -> Scheduler: """Compose a team and initialize agent 0 with the selected configuration. @@ -247,6 +248,7 @@ async def write_candidate_diff(aid: int, role: str, patch: str) -> str: scheduler = Scheduler( session_factory=session_factory, + cleanup_timeout=environment_cleanup_timeout(cfg, cleanup_timeout, default=10.0), worktree_pool=worktree_pool, event_sink=event_bus, tracer=ctx.tracer, diff --git a/tests/interfaces/test_vm_profiles.py b/tests/interfaces/test_vm_profiles.py index 5d91846b..d1793d4a 100644 --- a/tests/interfaces/test_vm_profiles.py +++ b/tests/interfaces/test_vm_profiles.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import os import sys @@ -9,6 +10,7 @@ from opencollab import OpenCollab from opencollab.adapters.session_environments import SessionEnvironmentPool +from opencollab.application.scheduler import Scheduler from opencollab.bootstrap import _workflow_runtime_execution as workflow_execution from opencollab.bootstrap import programmatic from opencollab.bootstrap.config import build_config @@ -164,9 +166,44 @@ def build(_ctx, **kwargs): ) assert result.ok assert captured["parent_grace"] == parent_grace + assert captured["cleanup_timeout"] == parent_grace assert captured["workflow_cleanup_timeout"] == child_grace +@pytest.mark.parametrize("with_vm, explicit, expected", [(False, None, 10), (True, None, 60), (True, 7, 7)]) +async def test_cancelled_public_team_uses_resolved_grace_on_its_first_internal_cleanup( + tmp_path, monkeypatch, with_vm, explicit, expected, +): + entered = asyncio.Event() + observed = [] + cleanup = Scheduler._cleanup_impl + + async def block_turn(scheduler, aid, prompt): + entered.set() + await asyncio.Event().wait() + + async def remember(scheduler, *, timeout): + observed.append(timeout) + return await cleanup(scheduler, timeout=timeout) + + monkeypatch.setattr(Scheduler, "_run_turn_exclusive", block_turn) + monkeypatch.setattr(Scheduler, "_cleanup_impl", remember) + team = tmp_path / "team.yaml" + team.write_text("entry: author\nroles:\n author:\n prompt: Author\n tools: []\n") + task = asyncio.create_task(OpenCollab( + tmp_path, api_key="offline-test", # pragma: allowlist secret + config={"vm": _profile(tmp_path)} if with_vm else {}, + ).team( + "run", config=team, use_worktrees=False, trace=False, + **({"cleanup_timeout": explicit} if explicit is not None else {}), + )) + await asyncio.wait_for(entered.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 5) + assert observed == [expected] + + @pytest.mark.parametrize("with_vm, explicit, expected", [(False, None, 2), (True, None, 60), (True, 7, 7)]) async def test_workflow_cleanup_uses_profile_default_and_explicit_override( tmp_path, monkeypatch, with_vm, explicit, expected, From 16ed3efccd289188105642eea4b3f266ebae8b2e Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 17:50:31 +0800 Subject: [PATCH 07/15] test(environments): cancel live guest commands after explicit readiness --- tests/interfaces/test_sdk_vm_isolation.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/interfaces/test_sdk_vm_isolation.py b/tests/interfaces/test_sdk_vm_isolation.py index 38677cec..63fed8ae 100644 --- a/tests/interfaces/test_sdk_vm_isolation.py +++ b/tests/interfaces/test_sdk_vm_isolation.py @@ -207,7 +207,8 @@ def respond(body): else: call = _call("manage_workflow", {"action": "wait", "id": json.loads(replies[-1]["content"])["id"]}) elif not replies: - call = _call("bash", {"command": "echo $$ > command.pid && printf ready > entered && sleep 300"}) + call = _call("bash", {"command": "echo $$ > command.pid && printf ready > entered && sleep 600", + "timeout": 600}) else: return {"role": "assistant", "content": "finished"}, {"prompt_tokens": 3, "completion_tokens": 2} return {"role": "assistant", "tool_calls": [call]}, {"prompt_tokens": 3, "completion_tokens": 2} @@ -223,9 +224,13 @@ async def entered(): break except FileNotFoundError: pass - await asyncio.sleep(0.1) - return [(environment.connection, int(await environment.read_file("command.pid"))) - for environment in environments] + await asyncio.sleep(1) + processes = [] + for environment in environments: + pid = int(await environment.read_file("command.pid")) + assert (await environment.exec_cmd(f"kill -0 {pid}")).returncode == 0 + processes.append((environment.connection, pid)) + return processes with _serve_model(respond) as (endpoint, _requests): task = asyncio.create_task(OpenCollab( @@ -233,7 +238,7 @@ async def entered(): config={"vm": vm_profile, "llm_max_retries": 0}, ).team("write and run your Workflow", config=team, artifacts=tmp_path / "artifacts", trace=False)) try: - processes = await asyncio.wait_for(entered(), 180) + processes = await asyncio.wait_for(entered(), 300) finally: task.cancel() try: From 0bf3e80d0fec800f70cf26b537ac48a4e7262b10 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 18:12:53 +0800 Subject: [PATCH 08/15] fix(workflows): preserve entry isolation with unused VM profiles --- opencollab/adapters/session_environments.py | 7 +- opencollab/bootstrap/isolation.py | 7 +- tests/interfaces/test_sdk_vm_isolation.py | 12 +++- tests/interfaces/test_vm_profiles.py | 79 +++++++++++++++++++++ 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/opencollab/adapters/session_environments.py b/opencollab/adapters/session_environments.py index 3a91a3d0..32c4df4d 100644 --- a/opencollab/adapters/session_environments.py +++ b/opencollab/adapters/session_environments.py @@ -46,6 +46,7 @@ def __init__( if name is not None: self._validate(name) self._source = base_environment or LocalEnvironment(workspace) + self._source_isolation: str | None = None self._owned_source = self._source if base_environment is None else None @property @@ -57,8 +58,9 @@ def strategy_names(self) -> tuple[str, ...]: return tuple(dict.fromkeys(("shared", "worktree", *self._providers))) def child_pool(self, environment: EnvironmentPort, *, role: str) -> SessionEnvironmentPool: + source_isolation = self._source_isolation if environment is self._source else None return SessionEnvironmentPool( - self._workspace, isolation=self._roles.get(role) or self._default, + self._workspace, isolation=self._roles.get(role) or source_isolation or self._default, base_environment=environment, providers=self._providers, ) @@ -107,8 +109,9 @@ async def acquire( self._inflight -= 1 self._lifecycle.notify_all() - def set_source(self, environment: EnvironmentPort) -> None: + def set_source(self, environment: EnvironmentPort, *, isolation: str | None = None) -> None: self._source = environment + self._source_isolation = isolation_name(isolation) async def adopt(self, patch: str) -> None: pool = self._delivery_pool() diff --git a/opencollab/bootstrap/isolation.py b/opencollab/bootstrap/isolation.py index 52d3e6f5..23a91b26 100644 --- a/opencollab/bootstrap/isolation.py +++ b/opencollab/bootstrap/isolation.py @@ -27,11 +27,12 @@ async def prepare_scheduler(ctx: Any, *, builder: Any, isolation=None, ) try: # An unset strategy keeps the historical entry workspace even when a child overrides it. - entry = await pool.acquire(team.entry, isolation=roles.get(team.entry) or selected or "shared") - pool.set_source(entry) + entry_isolation = roles.get(team.entry) or selected or "shared" + entry = await pool.acquire(team.entry, isolation=entry_isolation) + pool.set_source(entry, isolation=entry_isolation) scheduler = builder(ctx, **{**kwargs, "environment": entry, "worktree_pool": pool, "resolved_team_config": team}) - scheduler.preserve_entry_changes = (roles.get(team.entry) or selected or "shared") != "shared" + scheduler.preserve_entry_changes = entry_isolation != "shared" return scheduler except BaseException: await pool.release() diff --git a/tests/interfaces/test_sdk_vm_isolation.py b/tests/interfaces/test_sdk_vm_isolation.py index 63fed8ae..94574b1b 100644 --- a/tests/interfaces/test_sdk_vm_isolation.py +++ b/tests/interfaces/test_sdk_vm_isolation.py @@ -187,6 +187,8 @@ async def main(ctx, args): ctx.agent("second worker", tools=tools, isolation="vm-session")) ''' children = [] + pid_receipt = {"targets": []} + pid_receipt_path = tmp_path / "cancel-ready.json" launch = AuthoredWorkflowTool._launch async def remember(tool, params, runtime): @@ -228,8 +230,12 @@ async def entered(): processes = [] for environment in environments: pid = int(await environment.read_file("command.pid")) - assert (await environment.exec_cmd(f"kill -0 {pid}")).returncode == 0 + outcome = await environment.exec_cmd(f"kill -0 {pid}") + assert outcome.returncode == 0 + pid_receipt["targets"].append({"host": environment.connection.host, "port": environment.connection.port, + "pid": pid, "before_cancel_returncode": outcome.returncode}) processes.append((environment.connection, pid)) + pid_receipt_path.write_text(json.dumps(pid_receipt, indent=2)) return processes with _serve_model(respond) as (endpoint, _requests): @@ -258,6 +264,10 @@ async def entered(): try: outcome = await control.exec_cmd(f"test ! -e /proc/{pid}") assert outcome.returncode == 0 + target = next(target for target in pid_receipt["targets"] + if target["port"] == connection.port and target["pid"] == pid) + target["after_cancel_returncode"] = outcome.returncode + pid_receipt_path.write_text(json.dumps(pid_receipt, indent=2)) finally: await control.cleanup() diff --git a/tests/interfaces/test_vm_profiles.py b/tests/interfaces/test_vm_profiles.py index d1793d4a..d889895e 100644 --- a/tests/interfaces/test_vm_profiles.py +++ b/tests/interfaces/test_vm_profiles.py @@ -4,6 +4,7 @@ import json import os import sys +from pathlib import Path from types import SimpleNamespace import pytest @@ -292,3 +293,81 @@ def respond(body): assert parent.used_tokens == result["tokens"] == 5 finally: await parent.aclose() + + +@pytest.mark.parametrize("with_vm", [False, True]) +async def test_unused_vm_profile_preserves_legacy_authored_shared_workspace(tmp_path, with_vm): + workspace = tmp_path / "workspace" + workspace.mkdir() + _repo(workspace, dirty=False) + team = tmp_path / "team.yaml" + team.write_text("entry: author\nroles:\n author:\n prompt: Profile author\n" + " tools: [file_write, bash, manage_workflow]\n") + source = '''from opencollab.tools import builtin_tools +async def main(ctx, args): + return await ctx.agent("deliver", tools=builtin_tools("file_write")) +''' + observed = {} + + def respond(body): + replies = [message for message in body["messages"] if message["role"] == "tool"] + author = body["messages"][0]["content"].startswith("Profile author") + if author: + if not replies: + call = _call("file_write", {"path": "authored.py", "mode": "create", "content": source}) + elif len(replies) == 1: + call = _call("manage_workflow", {"action": "launch", "source": "authored.py", "python": sys.executable}) + elif len(replies) == 2: + call = _call("manage_workflow", {"action": "wait", "id": json.loads(replies[-1]["content"])["id"]}) + else: + observed.update(json.loads(replies[-1]["content"])) + return {"role": "assistant", "content": "collected"}, {"prompt_tokens": 3, "completion_tokens": 2} + elif not replies: + call = _call("file_write", {"path": "delivered.txt", "mode": "create", "content": "kept"}) + else: + return {"role": "assistant", "content": "done"}, {"prompt_tokens": 3, "completion_tokens": 2} + return {"role": "assistant", "tool_calls": [call]}, {"prompt_tokens": 3, "completion_tokens": 2} + + profile = _profile(tmp_path) + with _serve_model(respond) as (endpoint, requests): + result = await OpenCollab( + workspace, api_key="offline-test", base_url=endpoint, # pragma: allowlist secret + config={"vm": profile} if with_vm else {}, + ).team("run", config=team, trace=False, allow_unisolated_shell=True) + assert result.ok and result.output == "collected", result + assert observed["status"] == "completed", observed + assert (workspace / "delivered.txt").read_text() == "kept" + assert len(requests) == 6 + assert not Path(profile["lease_database"]).exists() + + +async def test_entry_source_policy_keeps_same_role_spawned_candidates_isolated(tmp_path, local_environment_factory): + _repo(tmp_path, dirty=False) + base = local_environment_factory(str(tmp_path)) + parent = SessionEnvironmentPool(str(tmp_path), isolation="worktree", base_environment=base, + roles={"borrower": "shared"}) + children = [] + try: + entry = await parent.acquire("author", isolation="shared") + parent.set_source(entry, isolation="shared") + shared = parent.child_pool(entry, role="author") + children.append(shared) + assert await shared.acquire("worker") is entry + candidate = await parent.acquire("author") + assert candidate.workspace != entry.workspace + private = parent.child_pool(candidate, role="author") + children.append(private) + nested = await private.acquire("worker") + assert nested.workspace not in {candidate.workspace, entry.workspace} + parent.set_source(entry, isolation="vm-group") + borrowed = parent.child_pool(entry, role="borrower") + children.append(borrowed) + assert await borrowed.acquire("worker") is entry + parent.set_source(entry) + legacy = parent.child_pool(entry, role="author") + children.append(legacy) + assert legacy.default_isolation == "worktree" + finally: + for child in reversed(children): + await child.release() + await parent.release() From f2246f1d4b4ba5f87952c9215f7fee2405136251 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 18:12:23 +0800 Subject: [PATCH 09/15] fix(runtime): expose configured capabilities to model callers --- opencollab/adapters/session_environments.py | 4 + opencollab/adapters/tools/fs.py | 12 + opencollab/adapters/tools/message.py | 3 +- opencollab/adapters/tools/use_skill.py | 13 + opencollab/bootstrap/authored_workflow.py | 82 +++- opencollab/bootstrap/context_builder.py | 36 +- opencollab/bootstrap/prompts/analyst.md | 5 +- opencollab/bootstrap/scheduler_factory.py | 1 + opencollab/bootstrap/session_factory.py | 6 + opencollab/bootstrap/single2_prompt.py | 13 +- opencollab/bootstrap/team_workflow.py | 18 +- opencollab/builtin_workflows/duo.py | 3 + opencollab/builtin_workflows/reconstruct.py | 11 +- opencollab/builtin_workflows/weave.py | 6 + ...test_message_ack_under_serialized_turns.py | 5 +- tests/agents/test_single2_profile.py | 2 +- .../interfaces/test_model_discoverability.py | 363 ++++++++++++++++++ .../test_team_serialized_feedback.py | 3 +- 18 files changed, 548 insertions(+), 38 deletions(-) create mode 100644 tests/interfaces/test_model_discoverability.py diff --git a/opencollab/adapters/session_environments.py b/opencollab/adapters/session_environments.py index 32c4df4d..39405c23 100644 --- a/opencollab/adapters/session_environments.py +++ b/opencollab/adapters/session_environments.py @@ -57,6 +57,10 @@ def default_isolation(self) -> str: def strategy_names(self) -> tuple[str, ...]: return tuple(dict.fromkeys(("shared", "worktree", *self._providers))) + def describe(self, role: str) -> dict[str, Any]: + return {"default_isolation": self._roles.get(role) or self._default, + "strategies": self.strategy_names} + def child_pool(self, environment: EnvironmentPort, *, role: str) -> SessionEnvironmentPool: source_isolation = self._source_isolation if environment is self._source else None return SessionEnvironmentPool( diff --git a/opencollab/adapters/tools/fs.py b/opencollab/adapters/tools/fs.py index 9afca2ac..ad1ec24b 100644 --- a/opencollab/adapters/tools/fs.py +++ b/opencollab/adapters/tools/fs.py @@ -4,6 +4,7 @@ import re import shlex +from copy import deepcopy from typing import Any from opencollab.adapters._env_base import TextFileRange @@ -215,6 +216,17 @@ def __init__(self, allow_create: bool = True): # files, disabling new files and whole-file replacement. self.allow_create = allow_create + def to_openai_schema(self) -> dict[str, Any]: + schema = deepcopy(super().to_openai_schema()) + if not self.allow_create: + schema["function"]["parameters"]["properties"]["mode"]["enum"] = ["str_replace"] + schema["function"]["description"] = ( + "Edit an existing file with mode='str_replace'. Read the target span first, " + "then replace a unique old_str with new_str. This session permits targeted " + "replacements only; creating files and replacing whole files are disabled." + ) + return schema + async def execute_with_runtime( self, params: dict[str, Any], diff --git a/opencollab/adapters/tools/message.py b/opencollab/adapters/tools/message.py index 7bbda065..b9f16d0c 100644 --- a/opencollab/adapters/tools/message.py +++ b/opencollab/adapters/tools/message.py @@ -27,7 +27,8 @@ _SERIALIZED_DELIVERY_NOTE = ( "Turns on this team run one at a time. The teammate cannot start until you " - "end your turn. Call submit now with what you have so far. If it answers, " + "end your turn. Give a final response without tool calls with what you have so far. " + "If the submit tool is available, you may use it to end the turn instead. If it answers, " "the answer arrives as a message and reopens your turn." ) diff --git a/opencollab/adapters/tools/use_skill.py b/opencollab/adapters/tools/use_skill.py index a8143217..9d423911 100644 --- a/opencollab/adapters/tools/use_skill.py +++ b/opencollab/adapters/tools/use_skill.py @@ -7,6 +7,7 @@ from __future__ import annotations +from copy import deepcopy from typing import Any from opencollab.adapters.tools.base import Tool @@ -37,6 +38,18 @@ class UseSkillTool(Tool): def __init__(self, store: SkillStorePort) -> None: self._store = store + def to_openai_schema(self) -> dict[str, Any]: + schema = deepcopy(super().to_openai_schema()) + manifests = self._store.list_manifests() + if manifests: + schema["function"]["parameters"]["properties"]["name"].update( + enum=[item.name for item in manifests], + description="\n".join(f"{item.name}: {item.description}" for item in manifests), + ) + else: + schema["function"]["description"] = "The configured skill catalog is empty. No skill is available to load." + return schema + async def execute_with_runtime( self, params: dict[str, Any], diff --git a/opencollab/bootstrap/authored_workflow.py b/opencollab/bootstrap/authored_workflow.py index 4529a8ec..725bfc45 100644 --- a/opencollab/bootstrap/authored_workflow.py +++ b/opencollab/bootstrap/authored_workflow.py @@ -11,9 +11,10 @@ import tempfile import uuid from collections.abc import Callable +from copy import deepcopy from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, get_args from opencollab.adapters.session_environments import SessionEnvironmentPool from opencollab.adapters.tools._paths import checked_path @@ -30,6 +31,14 @@ from opencollab.bootstrap.context_builder import SpawnConfig _RUNNER_STOP_GRACE_SECONDS = 5.0 +_ISOLATION_DESCRIPTIONS = { + "shared": "Share the member's execution environment and workspace.", + "worktree": "Use separate worktrees inside the inherited execution environment.", + "docker-group": "Share a dedicated container with separate worktrees for child agents.", + "docker-session": "Use a dedicated container and workspace for each child agent.", + "vm-group": "Lease one configured Linux VM with separate worktrees for child agents.", + "vm-session": "Lease a separate configured Linux VM for each child agent.", +} @dataclass @@ -55,6 +64,8 @@ class AuthoredWorkflowTool(Tool): name = "manage_workflow" description = ( "Execute a Workflow you wrote in a Python source file in your execution environment. " + "Before authoring source, call describe for the configured isolation strategies, inherited tool permissions " + "and a minimal authoring example. " "launch returns a child id immediately. Use status, wait, stop or result with that id. " "entry defaults to main and accepts (ctx, args). Tokens share your Team budget. " "The environment's Python must have OpenCollab installed." @@ -63,20 +74,23 @@ class AuthoredWorkflowTool(Tool): parameters = { "type": "object", "properties": { - "action": {"type": "string", "enum": ["launch", "status", "wait", "stop", "result"]}, - "id": {"type": "string"}, - "source": {"type": "string", "description": "Python file inside your workspace."}, + "action": {"type": "string", "enum": ["describe", "launch", "status", "wait", "stop", "result"]}, + "id": {"type": "string", "description": "Required for status, wait, stop and result. " + "Use the id from launch."}, + "source": {"type": "string", "description": "Required for launch. Python file inside your workspace."}, "entry": {"type": "string", "default": "main"}, "args": {"type": "object"}, "python": {"type": "string", "default": "python3"}, "budget": {"type": "integer", "minimum": 1}, "concurrency": {"type": "integer", "minimum": 1, "default": 4}, "cleanup_timeout": {"type": "number", "minimum": 0, - "description": "launch only. Cleanup grace in seconds. Omitted inherits the parent."}, + "description": "launch only. Positive cleanup grace in seconds. " + "Omitted inherits the parent."}, "isolation": {"type": "string", "description": "launch only. Default strategy for child agents. " "Omitted inherits the member strategy."}, "timeout": {"type": "number", "minimum": 0, - "description": "Optional wait duration. Expiry leaves the child running."}, + "description": "Positive wait duration in seconds. Expiry leaves the child running. " + "Use status for an immediate observation."}, }, "required": ["action"], "additionalProperties": False, @@ -86,17 +100,73 @@ def __init__( self, get_session: Callable[[], Session], config: SpawnConfig, save_dir: str | None, *, allow_unisolated_shell: bool = False, environment_pool_factory: Callable[..., Any] | None = None, + environment_catalog: Callable[[], dict[str, Any]] | None = None, ) -> None: self._get_session = get_session self._config = config self._save_dir = save_dir self._allow_unisolated_shell = allow_unisolated_shell self._environment_pool_factory = environment_pool_factory + self._environment_catalog = environment_catalog + self.parameters = deepcopy(type(self).parameters) self._children: dict[str, _Child] = {} self._registered = False self._launch_lock = asyncio.Lock() + def _catalog(self) -> dict[str, Any]: + if self._environment_catalog is not None: + return self._environment_catalog() + return {"default_isolation": "shared", + "strategies": ("shared", "worktree", "docker-group", "docker-session")} + + def to_openai_schema(self) -> dict[str, Any]: + catalog = self._catalog() + self.parameters["properties"]["isolation"].update( + enum=list(catalog["strategies"]), default=catalog["default_isolation"], + description="launch only. Default strategy for child agents. Configured strategies are listed; " + "resources are checked on allocation. Omitted inherits " + catalog["default_isolation"] + ".", + ) + return super().to_openai_schema() + + def _describe(self, runtime: ToolRuntime) -> str: + from opencollab.tools import BuiltinToolName + + parent = self._get_session() + catalog = self._catalog() + shell = parent.agent.find_tool("bash") + isolated = bool(getattr(runtime.environment, "process_isolated", False)) + launch_allowed = runtime.environment is not None and not runtime.environment.revoked and shell is not None and ( + isolated or (self._allow_unisolated_shell and not getattr(shell, "require_process_isolation", False)) + ) + allowed = [tool.name for tool in parent.agent.tools] + return json.dumps({ + "default_isolation": catalog["default_isolation"], + "strategies": [{"name": name, + "description": _ISOLATION_DESCRIPTIONS.get(name, "Configured custom environment provider."), + "availability": "checked_on_allocation"} + for name in catalog["strategies"]], + "launch_allowed_in_current_environment": launch_allowed, + "launch_requirements": "The member needs bash permission and an OS process sandbox or explicit " + "host-shell permission. Its Python must have OpenCollab installed.", + "allowed_child_tools": allowed, + "builtin_child_tools": [name for name in allowed if name in get_args(BuiltinToolName)], + "entry": "An async function main(ctx, args), or a named entry, returning a JSON-serializable value.", + "example": "async def main(ctx, args):\n" + " return await ctx.parallel([\n" + " lambda task=task: ctx.agent(task, tools=[])\n" + " for task in args['tasks']\n" + " ])\n", + "tools": "Import builtin_tools from opencollab.tools to construct names from builtin_child_tools. " + "Child tool names " + "must come from allowed_child_tools and retain the member's limits. Individual " + "ctx.agent calls can select isolation from the configured strategies.", + "lifecycle": "launch returns an id. Use wait or result with that id to collect the output. " + "status observes progress; stop cancels the owned run. Tokens share the Team budget.", + }, ensure_ascii=False) + async def execute_with_runtime(self, params: dict[str, Any], runtime: ToolRuntime) -> str: + if params["action"] == "describe": + return self._describe(runtime) parent = self._get_session() if not self._registered: parent.own_runtime(self.aclose, reserve_tokens=self._reserved_tokens) diff --git a/opencollab/bootstrap/context_builder.py b/opencollab/bootstrap/context_builder.py index 137c3b6a..042e283a 100644 --- a/opencollab/bootstrap/context_builder.py +++ b/opencollab/bootstrap/context_builder.py @@ -117,6 +117,7 @@ def __init__( *, skill_store: SkillStorePort | None = None, project_context: str | None = None, + prebuilt_team: bool = False, ): self._team = team_cfg self._cfg = cfg @@ -124,6 +125,7 @@ def __init__( self._skill_store: SkillStorePort = skill_store or NullSkillStore() # Empty project content contributes no startup source. self._project_context = project_context or None + self._prebuilt_team = prebuilt_team def build_plan( self, @@ -307,21 +309,27 @@ def _tool_limits_for(self, role: RoleConfig) -> dict[str, dict[str, int]]: def _team_section(self, role_name: str, role: RoleConfig) -> str: topo = self._team.topology - # The permissive default carries generic guidance in the base prompt; - # the dynamic section only adds value for an explicit topology graph. - if topo.allow_all: + tools = set(role.tools) + if not (tools & COORDINATION_TOOL_NAMES): return "" - targets = sorted(topo.edges.get(role_name, frozenset())) - if not targets or not (set(role.tools) & COORDINATION_TOOL_NAMES): - return "" - lines = ["## Your team", "", "Roles you may spawn or message:"] - lines += [f"- {t}" for t in targets] - if {"team_status", "message_agent"} & set(role.tools): - lines += [ - "", - "Use `team_status` to list live agents (with their ids) and " - "`message_agent` to send an async message to one.", - ] + targets = sorted(set(self._team.roles) - {role_name} if topo.allow_all + else topo.edges.get(role_name, frozenset())) + lines = ["## Your team", "", "Configured roles you may address:"] + for target in targets: + description = " ".join(self._team.role_for(target).prompt.split())[:240] + lines.append(f"- {target}: {description}") + if not targets: + lines.append("No other configured role is reachable from this role.") + if self._prebuilt_team: + lines.append("All configured agents are already seated. New agents cannot be spawned in this run.") + elif "spawn_agent" in tools: + lines.append("Use `spawn_agent` to delegate to a role; include the task and relevant context.") + if topo.allow_all: + lines.append("The open topology also permits custom role names, which use the generic role settings.") + if "message_agent" in tools: + lines.append("Use `message_agent` with to_role to contact an existing teammate asynchronously.") + if "team_status" in tools: + lines.append("Use `team_status` to inspect the live roster, agent ids and execution states.") return "\n".join(lines) diff --git a/opencollab/bootstrap/prompts/analyst.md b/opencollab/bootstrap/prompts/analyst.md index 3d9bb160..2141ad32 100644 --- a/opencollab/bootstrap/prompts/analyst.md +++ b/opencollab/bootstrap/prompts/analyst.md @@ -6,8 +6,9 @@ changes of a successfully completed direct Coder with `adopt_agent`. - **A question** ("what does X do", "where is Y handled"): answer it yourself from `file_read` and `grep`. Delegating a question is the wrong answer to it. -- **An underspecified change**: `ask_user` before you plan. A guessed - requirement costs a full coder-and-tester round to discover. +- **An underspecified change**: inspect the available context and use `ask_user` + when that tool is available. Report a required unresolved decision instead of + inventing the requirement. - **A change to the code**: plan it, then delegate. Never delegate a task you have not understood yourself. diff --git a/opencollab/bootstrap/scheduler_factory.py b/opencollab/bootstrap/scheduler_factory.py index 05342d91..9d1e8149 100644 --- a/opencollab/bootstrap/scheduler_factory.py +++ b/opencollab/bootstrap/scheduler_factory.py @@ -198,6 +198,7 @@ def build_scheduler( ), team_cfg=team_cfg, environment_pool_factory=getattr(worktree_pool, "child_pool", None), + environment_catalog=getattr(worktree_pool, "describe", None), lead_workspace=ctx.workspace, lead_environment=environment, interactive=interactive, diff --git a/opencollab/bootstrap/session_factory.py b/opencollab/bootstrap/session_factory.py index 3ae8c6c1..8116bf71 100644 --- a/opencollab/bootstrap/session_factory.py +++ b/opencollab/bootstrap/session_factory.py @@ -479,6 +479,7 @@ def __init__( *, team_cfg: TeamConfig | None = None, environment_pool_factory: Callable[..., Any] | None = None, + environment_catalog: Callable[[str], dict[str, Any]] | None = None, lead_workspace: str | None = None, lead_environment: Environment | None = None, interactive: bool = False, @@ -497,6 +498,7 @@ def __init__( ) self._team = team_cfg or default_team_config() self._environment_pool_factory = environment_pool_factory + self._environment_catalog = environment_catalog self._workflows = None if any("run_workflow" in role.tools for role in self._team.roles.values()): from opencollab.bootstrap.workflow_runtime import discover_workflows @@ -615,6 +617,7 @@ def _fresh_context_builder(self) -> ContextBuilder: self._cfg, skill_store=skill_store, project_context=project_context, + prebuilt_team=self._prebuilt_roster, ) def build_spawn_session( @@ -698,6 +701,9 @@ def _authored_workflow_tool( return AuthoredWorkflowTool( get_session, self._cfg, self._save_dir, environment_pool_factory=self._environment_pool_factory, + environment_catalog=( + lambda: self._environment_catalog(get_session().agent.name) + ) if self._environment_catalog is not None else None, allow_unisolated_shell=self._unisolated_shell_allowed(seated_at_start=seated_at_start), ) diff --git a/opencollab/bootstrap/single2_prompt.py b/opencollab/bootstrap/single2_prompt.py index 6bdf7c85..87c34d7e 100644 --- a/opencollab/bootstrap/single2_prompt.py +++ b/opencollab/bootstrap/single2_prompt.py @@ -27,29 +27,28 @@ ## Command Execution Rules - This session provides `bash`, `file_read`, `file_write`, `apply_patch`, `git_diff`, and `grep`. - Run project tests through `bash`. + The supplied tool definitions describe this session's available operations and parameters. + When `bash` is available, run project tests through it. Use read-only tools for inspection-only tasks. 1. You call one or more of the available tools while working. 2. The system executes your tool calls and returns their results. 3. You inspect the results before choosing your next action. - Each working response should include at least one actual function call. + When tools are available, each working response should include at least one actual function call. You may make multiple independent tool calls in one response. A final response without tool calls ends the agent session. Text describing a tool call does not execute it. - Example of a correct working response: + Example when `grep` and `file_read` are available: - Assistant text (optional): I will locate the Builder implementation and inspect the workspace. + Assistant text (optional): I will locate the Builder implementation. Function call: `grep` with arguments {"pattern": "class Builder", "path": "."} - Function call: `bash` with arguments {"command": "ls -la"} Send the function calls through the tool interface, not as lines of assistant text. ## Environment Details - - You have a shell in the task's execution environment. + - Shell access depends on the supplied tools and the task's execution environment. - Use non-interactive commands; avoid tools that wait for user input. - You may run the project's own test runner and create temporary reproduction scripts outside the repository. - Follow the runtime's tool and network permissions. If something is unavailable, use an alternative within them. diff --git a/opencollab/bootstrap/team_workflow.py b/opencollab/bootstrap/team_workflow.py index 82ed4e09..1792b440 100644 --- a/opencollab/bootstrap/team_workflow.py +++ b/opencollab/bootstrap/team_workflow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import inspect import json import tempfile from collections.abc import Callable @@ -26,7 +27,8 @@ class TeamWorkflowTool(Tool): name = "run_workflow" description = ( "Run a registered Workflow in your current workspace and wait for its result. " - "Pass its inputs in args (usually goal). The workflow may compose sub-workflows. " + "Use action=describe with its name to inspect its input instructions before running it. " + "Pass those inputs in args with action=run (the default). The workflow may compose sub-workflows. " "Its tokens are charged to your remaining Team budget." ) disable_outer_timeout = True @@ -42,6 +44,7 @@ def __init__( self.parameters = { "type": "object", "properties": { + "action": {"type": "string", "enum": ["run", "describe"], "default": "run"}, "name": { "type": "string", "enum": [spec.name for spec in registry.list_specs()], @@ -49,16 +52,25 @@ def __init__( f"{spec.name}: {spec.description}" for spec in registry.list_specs() ), }, - "args": {"type": "object", "description": "Workflow inputs, including goal."}, + "args": {"type": "object", "description": "Required for run. Use the inputs reported by describe."}, "budget": {"type": "integer", "minimum": 1}, "concurrency": {"type": "integer", "minimum": 1, "default": 4}, }, - "required": ["name", "args"], + "required": ["name"], "additionalProperties": False, } async def execute_with_runtime(self, params: dict[str, Any], runtime: ToolRuntime) -> str: spec = self._registry.get(params["name"]) + action = params.get("action", "run") + if action == "describe": + return json.dumps({"name": spec.name, "description": spec.description, + "inputs": inspect.getdoc(spec.fn) or spec.description, + "phases": spec.phases}, ensure_ascii=False) + if action != "run": + raise ValueError("action must be run or describe") + if "args" not in params: + return "Error: run requires args. Call action=describe with the workflow name for its input instructions." parent = self._get_session() cancel_event = parent.runner.default_cancel_event or parent.runner._run_cancel_event budget = params.get("budget") diff --git a/opencollab/builtin_workflows/duo.py b/opencollab/builtin_workflows/duo.py index 21e76dbe..af53abc9 100644 --- a/opencollab/builtin_workflows/duo.py +++ b/opencollab/builtin_workflows/duo.py @@ -43,10 +43,13 @@ def _without_owned_evidence(source: str, directory: str) -> str: async def duo(ctx: Any, args: dict[str, Any]) -> dict[str, Any]: """Run Duo using task-oriented prompts and complete candidate evidence. + Supply ``goal`` (or ``description``) with the complete task as text. + Example inputs: {"goal": "Fix the failing parser test"}. ``candidate_evidence_dir`` optionally supplies a host-side parent directory. Each adjudication creates its own retained evidence directory. ``submission_mode="working_tree"`` declares caller-owned patch capture and later submission. The default ``"task"`` follows task-specific delivery. + ``allow_unisolated_shell`` defaults to false and follows the caller's shell policy. """ evidence_parent = args.get("candidate_evidence_dir") evidence_root = _source_evidence_root(ctx, evidence_parent) diff --git a/opencollab/builtin_workflows/reconstruct.py b/opencollab/builtin_workflows/reconstruct.py index d08b9e80..ffc21ec1 100644 --- a/opencollab/builtin_workflows/reconstruct.py +++ b/opencollab/builtin_workflows/reconstruct.py @@ -225,7 +225,16 @@ def _input_key(value: Any) -> str: phases=["probe", "code", "select"], ) async def reconstruct(ctx: Any, args: dict[str, Any]) -> dict[str, Any]: - """Run the active-exploration protocol for one hidden program (see the module docstring).""" + """Reconstruct a hidden program with a caller-provided task integration. + + Required input ``task`` is a single-use register_task() token or an importable + "module:callable" factory returning ReconstructionTask. Optional ``task_args`` + supplies that factory's keyword arguments. The caller must provide the oracle, + workspace and evaluation integration; a natural-language goal alone is insufficient. + Other optional inputs include initial_batch, batch_step, max_iterations, + early_stop, propose_attempts, examples_shown and random_share. The coverage_gate, + memorisation_gate, complexity_gate and domain_check flags default to false. + """ task = resolve_task(args) initial_batch = _positive_int(args, "initial_batch", INITIAL_BATCH) batch_step = _positive_int(args, "batch_step", BATCH_STEP) diff --git a/opencollab/builtin_workflows/weave.py b/opencollab/builtin_workflows/weave.py index e753c572..8dd89926 100644 --- a/opencollab/builtin_workflows/weave.py +++ b/opencollab/builtin_workflows/weave.py @@ -647,6 +647,12 @@ async def weave(ctx: WorkflowContext, args: dict[str, Any]) -> dict[str, Any]: Each group supplies an id and prompt, with optional weight, dependencies, resources and targets. Config fields are optional. Test commands retain parser-backed passing evidence; command probes supply expected_output. + Required input ``groups`` is a nonempty list of those objects. ``check_commands`` + supplies test-command strings or {"command": "...", "expected_output": "..."} + probes; verified delivery requires executed passing checks. Optional ``config`` + supplies WeaveConfig fields and ``state`` supplies saved workflow state. + Example inputs: {"groups": [{"id": "parser", "prompt": "Fix the parser"}], + "check_commands": ["python -m pytest tests/test_parser.py -q"]}. """ values = args.get("groups") if isinstance(values, str) or not isinstance(values, Sequence): diff --git a/tests/agents/test_message_ack_under_serialized_turns.py b/tests/agents/test_message_ack_under_serialized_turns.py index 58431633..710b7599 100644 --- a/tests/agents/test_message_ack_under_serialized_turns.py +++ b/tests/agents/test_message_ack_under_serialized_turns.py @@ -26,12 +26,13 @@ def _send(team) -> str: params = {"to_role": "coder_a", "summary": "fix", "content": "please fix it"} return asyncio.run(tool.execute_with_runtime(params, runtime=SimpleNamespace(aid=0))) -def test_a_serialized_sender_is_told_to_submit_now(): +def test_a_serialized_sender_is_told_how_to_end_its_turn(): out = _send(_Team(serialized=True)) assert out.startswith(ACK) assert "cannot start until you end your turn" in out - assert "call submit now" in out.lower() + assert "final response without tool calls" in out + assert "If the submit tool is available" in out assert "reopens your turn" in out assert "meanwhile" not in out diff --git a/tests/agents/test_single2_profile.py b/tests/agents/test_single2_profile.py index dcebd5d2..8e9775b0 100644 --- a/tests/agents/test_single2_profile.py +++ b/tests/agents/test_single2_profile.py @@ -51,7 +51,7 @@ def test_single2_prompt_obeys_task_permissions_and_preserves_validation(): assert "Keep existing tests unchanged unless the task explicitly requests a test change" in SINGLE2_SYSTEM_PROMPT assert "Do not alter protected validation or obtain withheld reference answers" in SINGLE2_SYSTEM_PROMPT assert "Do not weaken checks to manufacture success" in SINGLE2_SYSTEM_PROMPT - assert "Run project tests through `bash`." in SINGLE2_SYSTEM_PROMPT + assert "When `bash` is available, run project tests through it." in SINGLE2_SYSTEM_PROMPT assert "Follow the runtime's tool and network permissions" in SINGLE2_SYSTEM_PROMPT assert "A final response without tool calls ends the agent session." in SINGLE2_SYSTEM_PROMPT diff --git a/tests/interfaces/test_model_discoverability.py b/tests/interfaces/test_model_discoverability.py new file mode 100644 index 00000000..a63c4d44 --- /dev/null +++ b/tests/interfaces/test_model_discoverability.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import json +import os +import sys +from types import SimpleNamespace + +import pytest + +from opencollab.adapters.env import LocalEnvironment +from opencollab.adapters.llm.types import LLMResponse, Usage +from opencollab.adapters.tools.fs import FileWriteTool +from opencollab.adapters.tools.message import MessageAgentTool +from opencollab.adapters.tools.use_skill import UseSkillTool +from opencollab.application.event_bus import EventBus +from opencollab.application.tool_execution import ToolRuntime +from opencollab.bootstrap.authored_workflow import AuthoredWorkflowTool +from opencollab.bootstrap.context_builder import ContextBuilder, SpawnConfig +from opencollab.bootstrap.team_config import RoleConfig, TeamConfig +from opencollab.domain.skill import SkillManifest +from opencollab.domain.team import Topology +from opencollab.sdk import OpenCollab +from tests.support.environment_test_support import local_environment_factory as local_environment_factory +from tests.support.paths import REPO_ROOT + + +def _config(): + return SpawnConfig(model="gpt-4o", provider="openai", api_key=None, base_url=None, + llm_timeout=60, tracer=None, event_bus=EventBus(), permission_policy=None) + + +class _Model: + def __init__(self, **kwargs): + pass + + def context_window(self): + return 200_000 + + async def close(self): + pass + + +class _Skills: + def list_manifests(self): + return (SkillManifest(name="inspect-query-plan", description="Analyze database query plans."),) + + def get_body(self, name): + return "Inspect the execution plan before changing indexes." if name == "inspect-query-plan" else None + + +async def test_skill_can_be_discovered_and_loaded_from_its_tool_schema(): + tool = UseSkillTool(_Skills()) + schema = tool.to_openai_schema()["function"] + options = schema["parameters"]["properties"]["name"].get("enum", []) + assert options == ["inspect-query-plan"] + assert "query plans" in json.dumps(schema) + result = await tool.execute_with_runtime({"name": options[0]}, ToolRuntime(None, None, None)) + assert result == "Inspect the execution plan before changing indexes." + + +def test_open_topology_reveals_configured_specialists(): + team = TeamConfig(entry="lead", roles={ + "lead": RoleConfig(prompt="Choose a specialist for the task.", tools=["spawn_agent"]), + "query_planner": RoleConfig(prompt="Analyze database query plans.", tools=["file_read"]), + }, topology=Topology(allow_all=True)) + agent = ContextBuilder(team, _config()).build_agent("lead", scheduler=object()) + assert "query_planner" in agent.system_prompt + assert "Analyze database query plans" in agent.system_prompt + + +def test_message_only_role_context_describes_only_granted_tools(): + team = TeamConfig(entry="lead", roles={ + "lead": RoleConfig(prompt="Coordinate the team.", tools=["message_agent"]), + "worker": RoleConfig(prompt="Analyze the task.", tools=[]), + }, topology=Topology(edges={"lead": frozenset({"worker"})})) + agent = ContextBuilder(team, _config()).build_agent("lead", scheduler=object()) + assert "message_agent" in agent.system_prompt + assert "team_status" not in agent.system_prompt + assert "spawn" not in agent.system_prompt.lower() + + +async def test_serialized_message_explains_how_to_yield_without_submit(): + class Scheduler: + turns_serialized = True + + async def send_message(self, *args): + return "Message queued." + + tool = MessageAgentTool(Scheduler()) + result = await tool.execute_with_runtime( + {"to_aid": 1, "summary": "Inspect", "content": "Inspect the query plan."}, + ToolRuntime(None, None, None, aid=0), + ) + assert "final response without tool calls" in result + assert "Call submit now" not in result + + +async def test_restricted_file_writer_advertises_an_executable_mode(local_environment_factory, tmp_path): + target = tmp_path / "value.txt" + target.write_text("before\n") + environment = local_environment_factory(str(tmp_path)) + tool = FileWriteTool(allow_create=False) + modes = tool.to_openai_schema()["function"]["parameters"]["properties"]["mode"]["enum"] + assert modes == ["str_replace"] + result = await tool.execute_with_runtime( + {"path": "value.txt", "mode": modes[0], "old_str": "before", "new_str": "after"}, + ToolRuntime(environment, None, None), + ) + assert "content changed" in result + assert target.read_text() == "after\n" + + +async def test_read_only_agent_prompt_does_not_claim_unavailable_shell(tmp_path): + observed = {} + + class Model(_Model): + async def complete(self, messages, **kwargs): + observed.update(system=messages[0]["content"], + tools=[t["function"]["name"] for t in kwargs["tools"]]) + return LLMResponse(content="Reviewed", finish_reason="stop", + usage=Usage(input_tokens=10, output_tokens=5)) + + result = await OpenCollab(tmp_path, model="gpt-4o", provider="openai").agent( + "Review the existing source.", tools="read", llm=Model(), trace=False, + ) + assert result.output == "Reviewed" + assert "bash" not in observed["tools"] + assert "This session provides `bash`" not in observed["system"] + assert "You have a shell" not in observed["system"] + + +def test_authored_workflow_tool_offers_discovery_before_execution(): + parent = SimpleNamespace(agent=SimpleNamespace(name="writer")) + tool = AuthoredWorkflowTool(lambda: parent, _config(), None) + schema = tool.to_openai_schema()["function"] + assert "describe" in schema["parameters"]["properties"]["action"]["enum"] + assert "worktree" in schema["parameters"]["properties"]["isolation"].get("enum", []) + + +def test_file_writer_schema_tracks_inherited_restrictions_without_changing_other_instances(): + restricted, unrestricted = FileWriteTool(), FileWriteTool() + restricted.allow_create = False + assert restricted.to_openai_schema()["function"]["parameters"]["properties"]["mode"]["enum"] == ["str_replace"] + assert unrestricted.to_openai_schema()["function"]["parameters"]["properties"]["mode"]["enum"] == [ + "create", "str_replace", + ] + + +async def test_prebuilt_context_directs_members_to_existing_teammates(tmp_path, monkeypatch): + from opencollab.bootstrap import container + + team = tmp_path / "team.yaml" + team.write_text("entry: lead\nroles:\n lead:\n prompt: Coordinate.\n" + " tools: [spawn_agent, message_agent]\n worker:\n" + " prompt: Analyze the task.\n tools: []\ntopology:\n lead: [worker]\n") + + class Model(_Model): + async def complete(self, messages, **kwargs): + prompt = "\n".join(item["content"] for item in messages if item["role"] == "system") + assert "already seated" in prompt + assert "New agents cannot be spawned" in prompt + assert "Use `spawn_agent`" not in prompt + assert "message_agent" in prompt + return _reply("Ready") + + monkeypatch.setattr(container, "LLMClient", Model) + result = await OpenCollab(tmp_path, model="gpt-4o").team( + "Inspect the configured team", config=team, prebuild_team=True, use_worktrees=False, trace=False, + ) + assert result.output == "Ready" + + +def _call(name, arguments): + return {"id": "call", "type": "function", "function": { + "name": name, "arguments": json.dumps(arguments), + }} + + +def _reply(content=None, tool=None): + return LLMResponse(content=content, tool_calls=[tool] if tool else [], + finish_reason="tool_calls" if tool else "stop", usage=Usage(input_tokens=10, output_tokens=5)) + + +async def test_team_discovers_workflow_inputs_before_executing(tmp_path, monkeypatch): + from opencollab.bootstrap import container + + workflows = tmp_path / "workflows" + workflows.mkdir() + (workflows / "multiply.py").write_text('''from opencollab.workflows import workflow + +@workflow +async def multiply(ctx, args): + """Required input factor is an integer. Example args: {"factor": 21}.""" + return args["factor"] * 2 +''') + team = tmp_path / "team.yaml" + team.write_text("entry: lead\nroles:\n lead:\n prompt: Find the workflow inputs.\n tools: [run_workflow]\n") + observed = [] + + class Model(_Model): + async def complete(self, messages, **kwargs): + replies = [item["content"] for item in messages if item["role"] == "tool"] + if not replies: + schema = kwargs["tools"][0]["function"]["parameters"] + assert "describe" in schema["properties"]["action"]["enum"] + assert "multiply" in schema["properties"]["name"]["enum"] + return _reply(tool=_call("run_workflow", {"name": "multiply", "action": "describe"})) + value = json.loads(replies[-1]) + observed.append(value) + if len(replies) == 1: + assert '"factor": 21' in value["inputs"] + return _reply(tool=_call("run_workflow", {"name": "multiply", "args": {"factor": 21}})) + assert value["output"] == 42 + return _reply("Collected 42") + + monkeypatch.setattr(container, "LLMClient", Model) + result = await OpenCollab(tmp_path, model="gpt-4o").team( + "Run the described workflow", config=team, trace=False, use_worktrees=False, + ) + assert result.output == "Collected 42" + assert len(observed) == 2 + assert observed[-1]["tokens"] == 0 + + +@pytest.mark.parametrize("configured", [False, True]) +async def test_team_describes_configured_vm_strategies_without_allocating_them(tmp_path, monkeypatch, configured): + from opencollab.bootstrap import container + + team = tmp_path / "team.yaml" + team.write_text("entry: lead\nroles:\n lead:\n prompt: Inspect the environment.\n" + " tools: [bash, manage_workflow]\n") + descriptions = [] + + def provider(**kwargs): + pytest.fail("Describing configured strategies must not allocate a resource") + + class Model(_Model): + async def complete(self, messages, **kwargs): + replies = [item["content"] for item in messages if item["role"] == "tool"] + tool = next(item["function"] for item in kwargs["tools"] if item["function"]["name"] == "manage_workflow") + assert ("vm-session" in tool["parameters"]["properties"]["isolation"]["enum"]) is configured + if not replies: + return _reply(tool=_call("manage_workflow", {"action": "describe"})) + value = json.loads(replies[-1]) + descriptions.append(value) + assert value["default_isolation"] == "shared" + assert ("vm-session" in [row["name"] for row in value["strategies"]]) is configured + assert all(row["availability"] == "checked_on_allocation" for row in value["strategies"]) + assert value["launch_allowed_in_current_environment"] is False + assert value["builtin_child_tools"] == ["bash"] + return _reply("Inspected") + + monkeypatch.setattr(container, "LLMClient", Model) + result = await OpenCollab(tmp_path, model="gpt-4o", environment_providers=( + {"vm-session": provider} if configured else {} + )).team("Describe the configured execution environment", config=team, trace=False, isolation="shared") + assert result.output == "Inspected" + assert len(descriptions) == 1 + + +async def test_member_learns_authoring_interface_and_runs_generated_source(tmp_path, monkeypatch): + from opencollab.bootstrap import container + + monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) + monkeypatch.setenv("OPENCOLLAB_API_USAGE_LOG", os.devnull) + team = tmp_path / "team.yaml" + team.write_text("entry: lead\nroles:\n lead:\n prompt: Author a workflow.\n" + " tools: [bash, file_write, manage_workflow]\n") + observed = {} + + class Model(_Model): + async def complete(self, messages, **kwargs): + if not messages[0]["content"].startswith("Author a workflow."): + return _reply("child result") + replies = [item["content"] for item in messages if item["role"] == "tool"] + if not replies: + return _reply(tool=_call("manage_workflow", {"action": "describe"})) + if len(replies) == 1: + value = json.loads(replies[-1]) + assert value["launch_allowed_in_current_environment"] is True + observed["source"] = value["example"] + return _reply(tool=_call("file_write", { + "mode": "create", "path": "generated.py", "content": value["example"], + })) + if len(replies) == 2: + return _reply(tool=_call("manage_workflow", { + "action": "launch", "source": "generated.py", "python": sys.executable, + "args": {"tasks": ["first", "second"]}, "isolation": "shared", + })) + if len(replies) == 3: + return _reply(tool=_call("manage_workflow", {"action": "wait", "id": json.loads(replies[-1])["id"]})) + observed["result"] = json.loads(replies[-1]) + assert observed["result"]["status"] == "completed" + assert observed["result"]["result"]["output"] == ["child result", "child result"], observed["result"] + return _reply("Collected") + + monkeypatch.setattr(container.LLMClient, "complete", Model.complete) + environment = LocalEnvironment(str(tmp_path)) + try: + client = OpenCollab(tmp_path, model="gpt-4o", environment=environment, + api_key="offline-test") # pragma: allowlist secret + result = await client.team( + "Write and execute the discovered example", config=team, trace=False, + use_worktrees=False, allow_unisolated_shell=True, + ) + assert result.output == "Collected" + assert (tmp_path / "generated.py").read_text() == observed["source"] + assert observed["result"]["tokens"] == 30 + finally: + await environment.cleanup() + + +def test_headless_analyst_instructions_make_human_questions_conditional(): + from opencollab.bootstrap.team_config import default_team_config + + team = default_team_config() + agent = ContextBuilder(team, _config()).build_agent(team.entry, scheduler=object()) + assert "ask_user" not in {tool.name for tool in agent.tools} + assert "when that tool is available" in agent.system_prompt + assert "Report a required unresolved decision" in agent.system_prompt + + +@pytest.mark.parametrize(("name", "required_terms"), [ + ("duo", ("goal", "description", "submission_mode")), + ("weave", ("groups", "id", "prompt", "check_commands", "expected_output")), + ("reconstruct", ("task", "module:callable", "task_args")), +]) +async def test_builtin_workflow_inputs_are_available_without_running_it(name, required_terms): + from opencollab.bootstrap.team_workflow import TeamWorkflowTool + from opencollab.builtin_workflows import get_builtin_workflows + + def parent(): + pytest.fail("Describing a workflow must not open or charge a parent session") + + tool = TeamWorkflowTool(parent, get_builtin_workflows(), _config(), None) + value = json.loads(await tool.execute_with_runtime({"action": "describe", "name": name}, None)) + assert all(term in value["inputs"] for term in required_terms) + + +@pytest.mark.parametrize("protocol", ["chat_completions", "responses", "anthropic"]) +def test_provider_adapters_retain_discovered_options(protocol): + from opencollab.adapters.llm.anthropic_provider import _convert_tool + from opencollab.adapters.llm.responses_provider import _responses_tools + from opencollab.adapters.llm.tool_contracts import normalize_function_tools + + original = UseSkillTool(_Skills()).to_openai_schema() + if protocol == "anthropic": + parameters = _convert_tool(original)["input_schema"] + elif protocol == "responses": + parameters = _responses_tools([original])[0]["parameters"] + else: + parameters = normalize_function_tools([original])[0]["function"]["parameters"] + assert parameters == original["function"]["parameters"] + assert parameters["properties"]["name"]["enum"] == ["inspect-query-plan"] + + +def test_skill_catalog_refresh_preserves_existing_schema_constraints(): + store = _Skills() + tool = UseSkillTool(store) + tool.parameters = {**tool.parameters, "additionalProperties": False} + assert tool.to_openai_schema()["function"]["parameters"]["additionalProperties"] is False + store.list_manifests = lambda: (SkillManifest(name="new-skill", description="A new operation."),) + assert tool.to_openai_schema()["function"]["parameters"]["properties"]["name"]["enum"] == ["new-skill"] diff --git a/tests/workflows/test_team_serialized_feedback.py b/tests/workflows/test_team_serialized_feedback.py index 5d0a5919..cd865c1b 100644 --- a/tests/workflows/test_team_serialized_feedback.py +++ b/tests/workflows/test_team_serialized_feedback.py @@ -87,7 +87,8 @@ async def send_message(self, *args): if serialized: assert "cannot start until you end your turn" in output - assert "call submit now" in output.lower() + assert "final response without tool calls" in output + assert "If the submit tool is available" in output assert "reopens your turn" in output else: assert output == f"Message queued to aid 1. {_DELIVERY_NOTE}" From c50e7cd74b5e94ea3a980a363c617b4cdf05781e Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 18:12:23 +0800 Subject: [PATCH 10/15] docs: report model-facing capability audit --- docs/llm-discoverability.md | 87 +++++++++++++++++++++++++++++++ docs/llm-discoverability.zh-CN.md | 57 ++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 docs/llm-discoverability.md create mode 100644 docs/llm-discoverability.zh-CN.md diff --git a/docs/llm-discoverability.md b/docs/llm-discoverability.md new file mode 100644 index 00000000..a8d2a181 --- /dev/null +++ b/docs/llm-discoverability.md @@ -0,0 +1,87 @@ +# Model-facing capability audit + +[Chinese](llm-discoverability.zh-CN.md) + +This October 24, 2026 audit follows a capability from its implementation through +the role configuration, model-visible instructions, tool schema, execution, and +returned observations. It covers the runtime features already merged through +PR 244 and the VM execution series through PR 247. + +The audit identifies eight families of defects. They include missing discovery +information and instructions that advertise operations unavailable in the current +configuration. Fixes derive model-facing information from the configuration that +actually supplies the tools and execution environments. + +## Confirmed findings and repairs + +| ID | Normal trigger and consequence | Repair | Executable evidence | +| --- | --- | --- | --- | +| D1 | A Team member can launch an authored Workflow with an isolation string, but receives neither the configured choices nor the authoring interface. Selecting a VM or custom provider requires knowledge outside the request. | `manage_workflow` advertises the configured isolation names and default. Its `describe` action returns strategy semantics, inherited tool permissions, launch prerequisites, and an executable authoring example. | A public Team call inspects configured VM names without allocating a guest. Another call discovers the example, writes the source, starts the real Python child, and collects two agent results. | +| D2 | `run_workflow` exposes names but describes every input as an object usually containing a goal. Weave requires groups and checks; Reconstruct requires a task integration. | `run_workflow(action="describe", name=...)` returns the callable's input instructions and phases. Built-in instructions state their required inputs and representative calls. | A public Team discovers a custom function's input and executes it successfully. All three built-ins expose their required input terms without executing their bodies or spending model tokens. | +| D3 | An open topology omits the configured role catalog. Restricted roles can be told to use coordination tools they lack, while prebuilt teams receive spawning guidance even though their seats already exist. | Team context lists reachable configured roles with concise duties, mentions granted coordination tools, and explains the prebuilt roster. | Tests capture the public model request for a prebuilt Team, inspect an open topology, and check a message-only role. | +| D4 | A caller can supply `UseSkillTool` to an Agent or Workflow without a Team context builder. The tool requires a catalog name but never supplies the catalog. | The tool's own schema carries current skill names and descriptions. An empty catalog is identified explicitly. | A test discovers a skill name solely from the schema and loads its exact body. Catalog refresh and protocol conversion preserve the choices. | +| D5 | `FileWriteTool(allow_create=False)` still advertises whole-file creation. Child-run inheritance can also restrict an already-created tool after construction. | The exported schema reflects the current permission and advertises targeted replacement only when creation is disabled. Runtime write checks remain in place. | A test selects the advertised mode and performs a real replacement. A separate test tightens a tool after creation and confirms other instances retain their own modes. | +| D6 | Base/Single2's fixed prompt claims six tools and a shell even for `tools="read"` or caller-supplied tool sets. | The profile refers to the supplied tool definitions, makes shell instructions conditional, and preserves its task, safety, and delivery instructions. | A public read-only Agent request contains the actual read tools and no unconditional claim of shell availability. | +| D7 | A headless Analyst is told to call `ask_user` before planning, although that tool is removed when there is no human input channel. | Clarification guidance uses the tool when available and describes how to report a required unresolved decision. | A headless default Analyst has conditional instructions and no unavailable question tool. | +| D8 | A serialized Team's message acknowledgement requires `submit`, even when the sender has no such tool. The sender needs to finish its turn before the recipient can run. | The acknowledgement explains that a final response without tool calls yields the turn, with `submit` as an available-tool alternative. | Message execution returns an actionable instruction for a sender with no submit tool. Existing serialized scheduler tests continue to exercise delivery after the sender yields. | + +## Audited surfaces + +The inventory contains 19 core model-callable tools and four example-specific +tools. Tool wrappers that preserve another tool's interface were checked at the +delegation boundary. + +| Surface | Scope and outcome | +| --- | --- | +| Workspace tools | Bash, file reads, file edits, patch application, Git diff, and commit adoption. Parameter names were compared with the arguments consumed by execution. D5 covers configured edit permissions. | +| Team coordination | Spawn, spawn with review, messages, roster lookup, child adoption, and submit. D3 and D8 cover runtime-specific guidance. Existing topology enforcement and asynchronous result delivery are retained. | +| Human input and skills | Question-tool availability and skill catalog delivery. D4 and D7 cover the gaps. Skill bodies continue to be returned unchanged. | +| Workflow entry points | Registered and member-authored Workflows, discovery, input guidance, isolation selection, lifecycle actions, and result collection. D1 and D2 cover the gaps. | +| Structured completion | `structured_output` and `submit_findings` already publish their schemas and receive explicit terminal instructions from the runtime. Existing validation and forced-submission behavior are retained. | +| Agent instructions | Analyst, Coder, Tester, generic role, Base/Single2, and Workflow role composition. D3, D6, and D7 address the configuration-dependent contradictions. | +| Context and transport | Context assembly, skill refresh, workflow discovery wrappers, and Chat Completions, Responses, and Anthropic tool conversion. The adapters preserve the new names and descriptions. | +| Bundled workflows | Duo, Weave, and Reconstruct expose input instructions through discovery. Their solving, selection, and verification algorithms remain unchanged. | +| IO2Code example | `query_oracle` and `check_candidate` publish task-specific input formats and return query or validation feedback. Active and passive modes supply their corresponding instructions and tools. | +| ARC-Bench example | `checkpoint` and `run_acceptance` expose states, scopes, and argument shapes. Requirement identities are supplied by the task and progress context. Existing acceptance semantics are retained. | + +## Using the discovery interfaces + +Call `manage_workflow` with `action="describe"` before authoring a child. The +response contains the configured strategy names, inherited default, permitted +child tool names, the subset constructible with `opencollab.tools.builtin_tools`, +and the callable entry shape. The example can be written to a Python file and +launched with an `args` object containing `tasks`. + +Strategy availability is reported as `checked_on_allocation`. A configured Docker +provider or VM resource describes an execution option; the provider performs +connection, capacity, and allocation checks when the option is used. Discovery +performs no hardware probe and acquires no container or VM. The current launch +permission reflects the member's Bash permission and execution environment. + +Call `run_workflow` with `action="describe"` and a registered name to inspect its +inputs. Then invoke the same tool with `args` and the default `run` action. Custom +Workflow authors put caller-facing input instructions in the function docstring; +discovery retains them through the module wrapper. + +## Configuration boundaries + +Model routes, request capacity, environment provisioning, and runtime defaults +remain programmer-owned settings. This change exposes information needed to use +existing agent actions. A new role-specific environment authorization policy or +a live hardware inventory would require its own behavior and API design. + +Configured strategies continue through the existing execution checks. Child tools +retain the parent tool restrictions. Describing a capability leaves models, +budgets, hardware allocation, and candidate state unchanged. + +## Verification + +The focused regressions are in +[`test_model_discoverability.py`](../tests/interfaces/test_model_discoverability.py). +They use scripted model responses to drive real SDK sessions, tool execution, +and an owned Python child process. Provider conversion tests inspect the outgoing +schemas for Chat Completions, Responses, and Anthropic. + +Run them with `uv run pytest -q tests/interfaces/test_model_discoverability.py`. +The pull request records the full-suite, architecture, dependency, packaging, +and CI results for its final revision. diff --git a/docs/llm-discoverability.zh-CN.md b/docs/llm-discoverability.zh-CN.md new file mode 100644 index 00000000..87aba5e2 --- /dev/null +++ b/docs/llm-discoverability.zh-CN.md @@ -0,0 +1,57 @@ +# 模型能力告知审计 + +[English](llm-discoverability.md) + +这次审计沿着功能实现、角色配置、模型实际收到的提示与工具定义、调用执行、结果返回逐段检查。范围覆盖已经通过 PR 244 合入的运行能力,以及 PR 247 所在的虚拟机支持系列。 + +确认的问题分为八类,既包括已经实现的功能缺少发现入口,也包括提示要求模型使用当前配置中没有的操作。修复从实际配置生成模型需要的信息。 + +## 确认的问题与修复 + +| 编号 | 正常触发方式与影响 | 修复内容 | 执行依据 | +| --- | --- | --- | --- | +| D1 | Team 成员可以给自编 Workflow 填写隔离字符串,却没有收到已配置选项和编写接口说明,选择 VM 或自定义环境需要额外知识 | `manage_workflow` 展示实际注册的隔离名字与默认值,增加 `describe`,返回含义、继承工具权限、启动条件和可执行范例 | 公开 Team 调用可以发现 VM 选项且没有申请机器,另一个调用根据返回范例写入源码、启动真实 Python 子进程并收回两个 Agent 的结果 | +| D2 | `run_workflow` 只列出名字,输入提示笼统地说通常传 goal,Weave 实际需要任务组与验证命令,Reconstruct 需要任务集成 | 增加按名字读取输入说明的 `describe`,补齐三个内置 Workflow 的必需输入和用法 | 公开 Team 先读取自定义函数的输入说明再成功执行,三个内置 Workflow 都可在零模型调用下查询说明 | +| D3 | 开放拓扑遗漏已配置角色目录,受限角色可能被要求调用未授权的协调工具,预建 Team 又收到继续创建成员的提示 | 上下文列出可达角色及简短职责,只提及已配置的协调工具,并说明预建成员已经存在 | 检查开放拓扑、仅能投信的角色,以及公开 SDK 发出的预建 Team 模型请求 | +| D4 | 单 Agent 或 Workflow 可以直接使用技能工具,但缺少 Team 的上下文构造器时,模型拿不到技能名字与用途 | 技能工具自身的 schema 带上当前目录,空目录也明确说明 | 从 schema 发现技能并加载原文,另行检查目录更新和三种协议转换 | +| D5 | 禁止创建文件时,文件工具仍向模型展示 create 模式,子运行继承权限后也可能出现相同矛盾 | 工具导出的 schema 根据当前权限展示可执行模式,原写入检查继续生效 | 从展示的模式选择操作并完成真实文本替换,验证运行中收紧权限及实例相互独立 | +| D6 | Base 与 Single2 固定声称提供六个工具和 Shell,实际只读配置或自定义工具集合可能没有这些能力 | 根据实际工具定义使用操作,Shell 指令改为有条件使用,保留任务、安全和交付要求 | 捕获公开只读 Agent 的模型请求,确认工具和提示一致 | +| D7 | 无人工输入渠道的 Analyst 仍被要求先调用 ask_user,该工具实际已被移除 | 提示在工具可用时提问,对必需但尚未解决的决定说明缺项 | 检查默认无人工输入 Analyst 的工具集合与提示 | +| D8 | 串行 Team 的投信回执要求调用 submit,但发送者可能没有该工具,接收者又需要等待发送者结束当前轮次 | 明确可以用没有工具调用的最终回复结束轮次,submit 作为工具存在时的另一种方式 | 对没有 submit 的发送者执行投信,验证回执可以指导其让出执行机会 | + +## 扫描范围 + +清单覆盖 19 个核心模型工具和 4 个示例专用工具。对于复用其他工具接口的包装器,核对其转发行为。 + +| 模块 | 检查内容与结果 | +| --- | --- | +| 工作区工具 | Bash、文件读取与修改、补丁、Git diff、提交采纳,逐项对照声明参数和执行实际读取的参数,配置权限问题归入 D5 | +| Team 协作 | 创建成员、带审核的创建、投信、成员状态、子任务采纳和提交,配置相关提示问题归入 D3 与 D8,原拓扑约束及异步交付继续保留 | +| 人工输入与技能 | 提问工具的可用性、技能目录和正文交付,问题归入 D4 与 D7,技能正文继续原样返回 | +| Workflow 入口 | 已注册与成员自编 Workflow 的发现、输入、隔离选择、生命周期和结果收集,问题归入 D1 与 D2 | +| 结构化完成 | structured_output 与 submit_findings 已提供 schema,运行时也有明确的最终提交指令,原验证行为继续保留 | +| Agent 提示 | Analyst、Coder、Tester、通用角色、Base、Single2 和 Workflow 角色组合,配置相关矛盾归入 D3、D6、D7 | +| 上下文与模型协议 | 上下文构造、技能更新、Workflow 发现包装,以及 Chat Completions、Responses、Anthropic 的工具转换,新增选项经过转换后仍完整 | +| 内置 Workflow | Duo、Weave、Reconstruct 的输入说明通过发现入口提供,解题、选择和验证算法保持原样 | +| IO2Code 示例 | query_oracle 与 check_candidate 提供任务输入格式和执行反馈,主动与被动模式分别配置对应提示和工具 | +| ARC-Bench 示例 | checkpoint 与 run_acceptance 提供状态、范围和参数,任务与进度上下文提供需求编号,原验收行为继续保留 | + +## 修复后的使用方式 + +成员编写 Workflow 前可以调用 `manage_workflow` 的 `describe`。返回信息包括实际注册的隔离选项、继承默认值、子 Agent 可用工具、可通过公开工具工厂构造的子集,以及函数入口和范例。模型可以把范例写成 Python 文件,再传入包含 tasks 的参数对象运行。 + +环境选项的状态明确写为分配时检查。已配置的 Docker 后端或虚拟机资源表示存在相应执行选项,连接、容量和实际分配由执行时的后端检查。发现操作不会启动容器或领取虚拟机。当前启动权限依据成员的 Bash 权限和执行环境计算。 + +调用已注册 Workflow 前,可以给 `run_workflow` 传入名字和 `describe` 动作,读取该函数的输入说明,再带 args 执行。自定义 Workflow 将调用者需要的输入说明放进函数 docstring,模块发现包装会保留这些信息。 + +## 程序配置边界 + +模型路由、请求并发额度、环境供应和运行默认值继续由程序员配置。这次修复补充现有 Agent 操作所需的发现信息。按角色限制隔离方式的授权策略,以及实时硬件目录,需要各自设计行为和接口。 + +环境申请继续经过已有检查,子工具继续继承父工具限制。查询说明保持模型调用、预算、硬件分配和候选状态不变。 + +## 验证方式 + +定向回归位于 [test_model_discoverability.py](../tests/interfaces/test_model_discoverability.py)。测试使用脚本化模型响应驱动真实 SDK、工具执行与受管理的 Python 子进程,并检查三种模型协议中的工具定义。 + +运行命令为 `uv run pytest -q tests/interfaces/test_model_discoverability.py`。修复 PR 会记录最终提交对应的完整测试、架构检查、依赖检查、安装包和 CI 结果。 From 009c849329ce120088c9db0d059554b2a6ea8b41 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 18:21:21 +0800 Subject: [PATCH 11/15] fix(context): preserve private role prompts during discovery --- docs/llm-discoverability.md | 2 +- docs/llm-discoverability.zh-CN.md | 2 +- opencollab/bootstrap/context_builder.py | 6 ++---- tests/interfaces/test_model_discoverability.py | 3 ++- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/llm-discoverability.md b/docs/llm-discoverability.md index a8d2a181..3e602fbe 100644 --- a/docs/llm-discoverability.md +++ b/docs/llm-discoverability.md @@ -18,7 +18,7 @@ actually supplies the tools and execution environments. | --- | --- | --- | --- | | D1 | A Team member can launch an authored Workflow with an isolation string, but receives neither the configured choices nor the authoring interface. Selecting a VM or custom provider requires knowledge outside the request. | `manage_workflow` advertises the configured isolation names and default. Its `describe` action returns strategy semantics, inherited tool permissions, launch prerequisites, and an executable authoring example. | A public Team call inspects configured VM names without allocating a guest. Another call discovers the example, writes the source, starts the real Python child, and collects two agent results. | | D2 | `run_workflow` exposes names but describes every input as an object usually containing a goal. Weave requires groups and checks; Reconstruct requires a task integration. | `run_workflow(action="describe", name=...)` returns the callable's input instructions and phases. Built-in instructions state their required inputs and representative calls. | A public Team discovers a custom function's input and executes it successfully. All three built-ins expose their required input terms without executing their bodies or spending model tokens. | -| D3 | An open topology omits the configured role catalog. Restricted roles can be told to use coordination tools they lack, while prebuilt teams receive spawning guidance even though their seats already exist. | Team context lists reachable configured roles with concise duties, mentions granted coordination tools, and explains the prebuilt roster. | Tests capture the public model request for a prebuilt Team, inspect an open topology, and check a message-only role. | +| D3 | An open topology omits the configured role catalog. Restricted roles can be told to use coordination tools they lack, while prebuilt teams receive spawning guidance even though their seats already exist. | Team context lists reachable configured role names, mentions granted coordination tools, and explains the prebuilt roster. Each role retains its own private prompt. | Tests capture the public model request for a prebuilt Team, inspect an open topology, check a message-only role, and ensure another role's prompt stays private. | | D4 | A caller can supply `UseSkillTool` to an Agent or Workflow without a Team context builder. The tool requires a catalog name but never supplies the catalog. | The tool's own schema carries current skill names and descriptions. An empty catalog is identified explicitly. | A test discovers a skill name solely from the schema and loads its exact body. Catalog refresh and protocol conversion preserve the choices. | | D5 | `FileWriteTool(allow_create=False)` still advertises whole-file creation. Child-run inheritance can also restrict an already-created tool after construction. | The exported schema reflects the current permission and advertises targeted replacement only when creation is disabled. Runtime write checks remain in place. | A test selects the advertised mode and performs a real replacement. A separate test tightens a tool after creation and confirms other instances retain their own modes. | | D6 | Base/Single2's fixed prompt claims six tools and a shell even for `tools="read"` or caller-supplied tool sets. | The profile refers to the supplied tool definitions, makes shell instructions conditional, and preserves its task, safety, and delivery instructions. | A public read-only Agent request contains the actual read tools and no unconditional claim of shell availability. | diff --git a/docs/llm-discoverability.zh-CN.md b/docs/llm-discoverability.zh-CN.md index 87aba5e2..955eb9dd 100644 --- a/docs/llm-discoverability.zh-CN.md +++ b/docs/llm-discoverability.zh-CN.md @@ -12,7 +12,7 @@ | --- | --- | --- | --- | | D1 | Team 成员可以给自编 Workflow 填写隔离字符串,却没有收到已配置选项和编写接口说明,选择 VM 或自定义环境需要额外知识 | `manage_workflow` 展示实际注册的隔离名字与默认值,增加 `describe`,返回含义、继承工具权限、启动条件和可执行范例 | 公开 Team 调用可以发现 VM 选项且没有申请机器,另一个调用根据返回范例写入源码、启动真实 Python 子进程并收回两个 Agent 的结果 | | D2 | `run_workflow` 只列出名字,输入提示笼统地说通常传 goal,Weave 实际需要任务组与验证命令,Reconstruct 需要任务集成 | 增加按名字读取输入说明的 `describe`,补齐三个内置 Workflow 的必需输入和用法 | 公开 Team 先读取自定义函数的输入说明再成功执行,三个内置 Workflow 都可在零模型调用下查询说明 | -| D3 | 开放拓扑遗漏已配置角色目录,受限角色可能被要求调用未授权的协调工具,预建 Team 又收到继续创建成员的提示 | 上下文列出可达角色及简短职责,只提及已配置的协调工具,并说明预建成员已经存在 | 检查开放拓扑、仅能投信的角色,以及公开 SDK 发出的预建 Team 模型请求 | +| D3 | 开放拓扑遗漏已配置角色目录,受限角色可能被要求调用未授权的协调工具,预建 Team 又收到继续创建成员的提示 | 上下文列出可达角色名字,只提及已配置的协调工具,并说明预建成员已经存在,各成员保留自己的私有提示 | 检查开放拓扑、仅能投信的角色、公开 SDK 发出的预建 Team 模型请求,以及其他角色提示的隔离 | | D4 | 单 Agent 或 Workflow 可以直接使用技能工具,但缺少 Team 的上下文构造器时,模型拿不到技能名字与用途 | 技能工具自身的 schema 带上当前目录,空目录也明确说明 | 从 schema 发现技能并加载原文,另行检查目录更新和三种协议转换 | | D5 | 禁止创建文件时,文件工具仍向模型展示 create 模式,子运行继承权限后也可能出现相同矛盾 | 工具导出的 schema 根据当前权限展示可执行模式,原写入检查继续生效 | 从展示的模式选择操作并完成真实文本替换,验证运行中收紧权限及实例相互独立 | | D6 | Base 与 Single2 固定声称提供六个工具和 Shell,实际只读配置或自定义工具集合可能没有这些能力 | 根据实际工具定义使用操作,Shell 指令改为有条件使用,保留任务、安全和交付要求 | 捕获公开只读 Agent 的模型请求,确认工具和提示一致 | diff --git a/opencollab/bootstrap/context_builder.py b/opencollab/bootstrap/context_builder.py index 042e283a..cc094081 100644 --- a/opencollab/bootstrap/context_builder.py +++ b/opencollab/bootstrap/context_builder.py @@ -312,12 +312,10 @@ def _team_section(self, role_name: str, role: RoleConfig) -> str: tools = set(role.tools) if not (tools & COORDINATION_TOOL_NAMES): return "" - targets = sorted(set(self._team.roles) - {role_name} if topo.allow_all + targets = sorted(self._team.roles if topo.allow_all else topo.edges.get(role_name, frozenset())) lines = ["## Your team", "", "Configured roles you may address:"] - for target in targets: - description = " ".join(self._team.role_for(target).prompt.split())[:240] - lines.append(f"- {target}: {description}") + lines += [f"- {target}" for target in targets] if not targets: lines.append("No other configured role is reachable from this role.") if self._prebuilt_team: diff --git a/tests/interfaces/test_model_discoverability.py b/tests/interfaces/test_model_discoverability.py index a63c4d44..106811bc 100644 --- a/tests/interfaces/test_model_discoverability.py +++ b/tests/interfaces/test_model_discoverability.py @@ -65,7 +65,8 @@ def test_open_topology_reveals_configured_specialists(): }, topology=Topology(allow_all=True)) agent = ContextBuilder(team, _config()).build_agent("lead", scheduler=object()) assert "query_planner" in agent.system_prompt - assert "Analyze database query plans" in agent.system_prompt + assert "- lead" in agent.system_prompt + assert "Analyze database query plans" not in agent.system_prompt def test_message_only_role_context_describes_only_granted_tools(): From 8a589615bcae8e0fdea063d07397f808745689f6 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 18:26:17 +0800 Subject: [PATCH 12/15] fix(tools): describe active execution restrictions to the model --- docs/llm-discoverability.md | 5 +-- docs/llm-discoverability.zh-CN.md | 5 +-- .../application/_session_run_completion.py | 15 ++++++++- .../interfaces/test_model_discoverability.py | 32 +++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/docs/llm-discoverability.md b/docs/llm-discoverability.md index 3e602fbe..127fbcee 100644 --- a/docs/llm-discoverability.md +++ b/docs/llm-discoverability.md @@ -7,7 +7,7 @@ the role configuration, model-visible instructions, tool schema, execution, and returned observations. It covers the runtime features already merged through PR 244 and the VM execution series through PR 247. -The audit identifies eight families of defects. They include missing discovery +The audit identifies nine families of defects. They include missing discovery information and instructions that advertise operations unavailable in the current configuration. Fixes derive model-facing information from the configuration that actually supplies the tools and execution environments. @@ -24,6 +24,7 @@ actually supplies the tools and execution environments. | D6 | Base/Single2's fixed prompt claims six tools and a shell even for `tools="read"` or caller-supplied tool sets. | The profile refers to the supplied tool definitions, makes shell instructions conditional, and preserves its task, safety, and delivery instructions. | A public read-only Agent request contains the actual read tools and no unconditional claim of shell availability. | | D7 | A headless Analyst is told to call `ask_user` before planning, although that tool is removed when there is no human input channel. | Clarification guidance uses the tool when available and describes how to report a required unresolved decision. | A headless default Analyst has conditional instructions and no unavailable question tool. | | D8 | A serialized Team's message acknowledgement requires `submit`, even when the sender has no such tool. The sender needs to finish its turn before the recipient can run. | The acknowledgement explains that a final response without tool calls yields the turn, with `submit` as an available-tool alternative. | Message execution returns an actionable instruction for a sender with no submit tool. Existing serialized scheduler tests continue to exercise delivery after the sender yields. | +| D9 | A headless local session advertises a Bash or adoption tool as executable even when that tool requires an OS process sandbox and the current environment has none. | The outgoing tool description states the current execution restriction. The execution check remains authoritative, and the tool instance is unchanged. | A public Agent avoids the blocked operation after reading the actual schema. The authorized local-shell control runs a real command; a direct blocked call still refuses execution. | ## Audited surfaces @@ -33,7 +34,7 @@ delegation boundary. | Surface | Scope and outcome | | --- | --- | -| Workspace tools | Bash, file reads, file edits, patch application, Git diff, and commit adoption. Parameter names were compared with the arguments consumed by execution. D5 covers configured edit permissions. | +| Workspace tools | Bash, file reads, file edits, patch application, Git diff, and commit adoption. Parameter names were compared with the arguments consumed by execution. D5 and D9 cover configured edit and execution permissions. | | Team coordination | Spawn, spawn with review, messages, roster lookup, child adoption, and submit. D3 and D8 cover runtime-specific guidance. Existing topology enforcement and asynchronous result delivery are retained. | | Human input and skills | Question-tool availability and skill catalog delivery. D4 and D7 cover the gaps. Skill bodies continue to be returned unchanged. | | Workflow entry points | Registered and member-authored Workflows, discovery, input guidance, isolation selection, lifecycle actions, and result collection. D1 and D2 cover the gaps. | diff --git a/docs/llm-discoverability.zh-CN.md b/docs/llm-discoverability.zh-CN.md index 955eb9dd..7c7cb511 100644 --- a/docs/llm-discoverability.zh-CN.md +++ b/docs/llm-discoverability.zh-CN.md @@ -4,7 +4,7 @@ 这次审计沿着功能实现、角色配置、模型实际收到的提示与工具定义、调用执行、结果返回逐段检查。范围覆盖已经通过 PR 244 合入的运行能力,以及 PR 247 所在的虚拟机支持系列。 -确认的问题分为八类,既包括已经实现的功能缺少发现入口,也包括提示要求模型使用当前配置中没有的操作。修复从实际配置生成模型需要的信息。 +确认的问题分为九类,既包括已经实现的功能缺少发现入口,也包括提示要求模型使用当前配置中没有的操作。修复从实际配置生成模型需要的信息。 ## 确认的问题与修复 @@ -18,6 +18,7 @@ | D6 | Base 与 Single2 固定声称提供六个工具和 Shell,实际只读配置或自定义工具集合可能没有这些能力 | 根据实际工具定义使用操作,Shell 指令改为有条件使用,保留任务、安全和交付要求 | 捕获公开只读 Agent 的模型请求,确认工具和提示一致 | | D7 | 无人工输入渠道的 Analyst 仍被要求先调用 ask_user,该工具实际已被移除 | 提示在工具可用时提问,对必需但尚未解决的决定说明缺项 | 检查默认无人工输入 Analyst 的工具集合与提示 | | D8 | 串行 Team 的投信回执要求调用 submit,但发送者可能没有该工具,接收者又需要等待发送者结束当前轮次 | 明确可以用没有工具调用的最终回复结束轮次,submit 作为工具存在时的另一种方式 | 对没有 submit 的发送者执行投信,验证回执可以指导其让出执行机会 | +| D9 | 无人值守的本地 Session 可能展示 Bash 或采纳工具,但工具要求进程沙箱,当前环境又没有满足这一条件 | 发给模型的工具说明明确当前执行限制,原执行检查继续生效,工具实例保持原样 | 公开 Agent 从工具定义得知限制,授权本地 Shell 的对照能执行真实命令,直接调用受限工具仍被拒绝 | ## 扫描范围 @@ -25,7 +26,7 @@ | 模块 | 检查内容与结果 | | --- | --- | -| 工作区工具 | Bash、文件读取与修改、补丁、Git diff、提交采纳,逐项对照声明参数和执行实际读取的参数,配置权限问题归入 D5 | +| 工作区工具 | Bash、文件读取与修改、补丁、Git diff、提交采纳,逐项对照声明参数和执行实际读取的参数,配置权限问题归入 D5 与 D9 | | Team 协作 | 创建成员、带审核的创建、投信、成员状态、子任务采纳和提交,配置相关提示问题归入 D3 与 D8,原拓扑约束及异步交付继续保留 | | 人工输入与技能 | 提问工具的可用性、技能目录和正文交付,问题归入 D4 与 D7,技能正文继续原样返回 | | Workflow 入口 | 已注册与成员自编 Workflow 的发现、输入、隔离选择、生命周期和结果收集,问题归入 D1 与 D2 | diff --git a/opencollab/application/_session_run_completion.py b/opencollab/application/_session_run_completion.py index c55d1aa2..7f696175 100644 --- a/opencollab/application/_session_run_completion.py +++ b/opencollab/application/_session_run_completion.py @@ -242,7 +242,20 @@ def clear_pending_step(self) -> None: self.state.pending_step_latency = None def build_tool_schemas(self) -> list[dict] | None: - return self.agent.tool_schemas() or None + schemas = self.agent.tool_schemas() + environment = getattr(self.tool_execution, "environment", None) + if environment is not None and not getattr(environment, "process_isolated", False): + blocked = {tool.name for tool in self.agent.tools + if getattr(tool, "require_process_isolation", False) is True} + schemas = [ + {**schema, "function": {**schema["function"], "description": ( + "Unavailable in the current execution environment: this tool requires an OS process sandbox. " + "Use another available tool or report that an isolated execution environment is required. " + + schema["function"].get("description", "") + )}} if schema["function"]["name"] in blocked else schema + for schema in schemas + ] + return schemas or None def _write_only_tool_schemas(self, tools: list[dict] | None) -> list[dict] | None: return self._tool_schemas_with_names(tools, _WRITE_TOOLS) diff --git a/tests/interfaces/test_model_discoverability.py b/tests/interfaces/test_model_discoverability.py index 106811bc..e7d1e3ae 100644 --- a/tests/interfaces/test_model_discoverability.py +++ b/tests/interfaces/test_model_discoverability.py @@ -362,3 +362,35 @@ def test_skill_catalog_refresh_preserves_existing_schema_constraints(): assert tool.to_openai_schema()["function"]["parameters"]["additionalProperties"] is False store.list_manifests = lambda: (SkillManifest(name="new-skill", description="A new operation."),) assert tool.to_openai_schema()["function"]["parameters"]["properties"]["name"]["enum"] == ["new-skill"] + + +@pytest.mark.parametrize("requires_sandbox", [True, False]) +async def test_model_is_told_when_shell_policy_blocks_the_current_environment( + tmp_path, local_environment_factory, requires_sandbox, +): + from opencollab.adapters.tools.bash import BashTool + + env = local_environment_factory(str(tmp_path)) + tool = BashTool(require_process_isolation=requires_sandbox) + original = tool.to_openai_schema() + + class Model(_Model): + async def complete(self, messages, **kwargs): + description = kwargs["tools"][0]["function"]["description"] + assert ("Unavailable in the current execution environment" in description) is requires_sandbox + if requires_sandbox: + return _reply("An isolated execution environment is required") + replies = [item["content"] for item in messages if item["role"] == "tool"] + if not replies: + return _reply(tool=_call("bash", {"command": "printf ready"})) + assert "ready" in replies[-1] + return _reply("Executed") + + result = await OpenCollab(tmp_path, model="gpt-4o", environment=env).agent( + "Run the shell probe if available", tools=[tool], llm=Model(), trace=False, + ) + assert result.output == ("An isolated execution environment is required" if requires_sandbox else "Executed") + assert tool.to_openai_schema() == original + if requires_sandbox: + refusal = await tool.execute_with_runtime({"command": "printf forbidden"}, ToolRuntime(env, None, None)) + assert "does not provide an OS process sandbox" in refusal From 4fe8af269e66e27eeaa4efb36860429f6cc2b96b Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 18:38:13 +0800 Subject: [PATCH 13/15] fix(context): describe the effective inherited isolation --- opencollab/adapters/session_environments.py | 11 +++++++---- opencollab/bootstrap/session_factory.py | 4 ++-- tests/interfaces/test_model_discoverability.py | 7 +++++-- tests/runtime/test_context_assembly_golden.py | 8 ++++---- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/opencollab/adapters/session_environments.py b/opencollab/adapters/session_environments.py index 39405c23..1b0373fc 100644 --- a/opencollab/adapters/session_environments.py +++ b/opencollab/adapters/session_environments.py @@ -57,14 +57,17 @@ def default_isolation(self) -> str: def strategy_names(self) -> tuple[str, ...]: return tuple(dict.fromkeys(("shared", "worktree", *self._providers))) - def describe(self, role: str) -> dict[str, Any]: - return {"default_isolation": self._roles.get(role) or self._default, + def _child_isolation(self, environment: EnvironmentPort, role: str) -> str: + source_isolation = self._source_isolation if environment is self._source else None + return self._roles.get(role) or source_isolation or self._default + + def describe(self, environment: EnvironmentPort, *, role: str) -> dict[str, Any]: + return {"default_isolation": self._child_isolation(environment, role), "strategies": self.strategy_names} def child_pool(self, environment: EnvironmentPort, *, role: str) -> SessionEnvironmentPool: - source_isolation = self._source_isolation if environment is self._source else None return SessionEnvironmentPool( - self._workspace, isolation=self._roles.get(role) or source_isolation or self._default, + self._workspace, isolation=self._child_isolation(environment, role), base_environment=environment, providers=self._providers, ) diff --git a/opencollab/bootstrap/session_factory.py b/opencollab/bootstrap/session_factory.py index 8116bf71..66e02f84 100644 --- a/opencollab/bootstrap/session_factory.py +++ b/opencollab/bootstrap/session_factory.py @@ -479,7 +479,7 @@ def __init__( *, team_cfg: TeamConfig | None = None, environment_pool_factory: Callable[..., Any] | None = None, - environment_catalog: Callable[[str], dict[str, Any]] | None = None, + environment_catalog: Callable[..., dict[str, Any]] | None = None, lead_workspace: str | None = None, lead_environment: Environment | None = None, interactive: bool = False, @@ -702,7 +702,7 @@ def _authored_workflow_tool( get_session, self._cfg, self._save_dir, environment_pool_factory=self._environment_pool_factory, environment_catalog=( - lambda: self._environment_catalog(get_session().agent.name) + lambda: self._environment_catalog(get_session().env, role=get_session().agent.name) ) if self._environment_catalog is not None else None, allow_unisolated_shell=self._unisolated_shell_allowed(seated_at_start=seated_at_start), ) diff --git a/tests/interfaces/test_model_discoverability.py b/tests/interfaces/test_model_discoverability.py index e7d1e3ae..a3f17915 100644 --- a/tests/interfaces/test_model_discoverability.py +++ b/tests/interfaces/test_model_discoverability.py @@ -224,7 +224,10 @@ async def complete(self, messages, **kwargs): @pytest.mark.parametrize("configured", [False, True]) -async def test_team_describes_configured_vm_strategies_without_allocating_them(tmp_path, monkeypatch, configured): +@pytest.mark.parametrize("isolation", [None, "shared"]) +async def test_team_describes_configured_vm_strategies_without_allocating_them( + tmp_path, monkeypatch, configured, isolation, +): from opencollab.bootstrap import container team = tmp_path / "team.yaml" @@ -254,7 +257,7 @@ async def complete(self, messages, **kwargs): monkeypatch.setattr(container, "LLMClient", Model) result = await OpenCollab(tmp_path, model="gpt-4o", environment_providers=( {"vm-session": provider} if configured else {} - )).team("Describe the configured execution environment", config=team, trace=False, isolation="shared") + )).team("Describe the configured execution environment", config=team, trace=False, isolation=isolation) assert result.output == "Inspected" assert len(descriptions) == 1 diff --git a/tests/runtime/test_context_assembly_golden.py b/tests/runtime/test_context_assembly_golden.py index 86b2b528..3ea9133a 100644 --- a/tests/runtime/test_context_assembly_golden.py +++ b/tests/runtime/test_context_assembly_golden.py @@ -49,11 +49,11 @@ def _team() -> TeamConfig: _TEAM_SECTION = ( "## Your team\n" "\n" - "Roles you may spawn or message:\n" + "Configured roles you may address:\n" "- coder\n" - "\n" - "Use `team_status` to list live agents (with their ids) and " - "`message_agent` to send an async message to one." + "Use `spawn_agent` to delegate to a role; include the task and relevant context.\n" + "Use `message_agent` with to_role to contact an existing teammate asynchronously.\n" + "Use `team_status` to inspect the live roster, agent ids and execution states." ) _SKILLS_SECTION = ( "## Skills\n" From 3b6906d32d51aa5b754c163955cbfe900501a2c3 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 18:38:13 +0800 Subject: [PATCH 14/15] docs: use an English translation link in the VM example --- examples/vm-authored-workflow/README.md | 2 +- examples/vm-authored-workflow/README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/vm-authored-workflow/README.md b/examples/vm-authored-workflow/README.md index 478ff6f4..a9d75514 100644 --- a/examples/vm-authored-workflow/README.md +++ b/examples/vm-authored-workflow/README.md @@ -1,6 +1,6 @@ # VM member-authored Workflow -[中文](README.zh-CN.md) +[Chinese](README.zh-CN.md) Prepare two existing Linux VMs using the [VM guide](../../docs/virtual-machines.md). Install the same OpenCollab revision on the controller and both guests, then save diff --git a/examples/vm-authored-workflow/README.zh-CN.md b/examples/vm-authored-workflow/README.zh-CN.md index 69fec5a0..d04d3139 100644 --- a/examples/vm-authored-workflow/README.zh-CN.md +++ b/examples/vm-authored-workflow/README.zh-CN.md @@ -1,6 +1,6 @@ # VM 成员自编 Workflow -[English](README.md) +[英文版](README.md) 按照 [VM 使用指南](../../docs/virtual-machines.zh-CN.md) 准备两台现有 Linux VM。 在控制端和两台来宾中安装相同版本的 OpenCollab,再参照 From 6adcb785cb916976ce1d31e55906054bf81c110d Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 18:39:32 +0800 Subject: [PATCH 15/15] docs(examples): use an English translation link label --- examples/vm-authored-workflow/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/vm-authored-workflow/README.md b/examples/vm-authored-workflow/README.md index 478ff6f4..a9d75514 100644 --- a/examples/vm-authored-workflow/README.md +++ b/examples/vm-authored-workflow/README.md @@ -1,6 +1,6 @@ # VM member-authored Workflow -[中文](README.zh-CN.md) +[Chinese](README.zh-CN.md) Prepare two existing Linux VMs using the [VM guide](../../docs/virtual-machines.md). Install the same OpenCollab revision on the controller and both guests, then save