From f2246f1d4b4ba5f87952c9215f7fee2405136251 Mon Sep 17 00:00:00 2001 From: Kai Date: Sat, 24 Oct 2026 18:12:23 +0800 Subject: [PATCH 1/6] 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 32c4df4df..39405c238 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 9afca2ac2..ad1ec24b6 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 7bbda0654..b9f16d0c7 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 a81432170..9d4239114 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 4529a8ec1..725bfc45c 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 137c3b6a8..042e283a3 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 3d9bb1603..2141ad32a 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 05342d91c..9d1e81495 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 3ae8c6c1b..8116bf716 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 6bdf7c854..87c34d7ea 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 82ed4e09c..1792b440a 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 21e76dbef..af53abc94 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 d08b9e809..ffc21ec17 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 e753c572d..8dd899265 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 584316332..710b75994 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 dcebd5d28..8e9775b06 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 000000000..a63c4d446 --- /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 5d0a5919f..cd865c1b8 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 2/6] 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 000000000..a8d2a1811 --- /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 000000000..87aba5e26 --- /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 3/6] 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 a8d2a1811..3e602fbe1 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 87aba5e26..955eb9ddc 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 042e283a3..cc094081c 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 a63c4d446..106811bcd 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 4/6] 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 3e602fbe1..127fbcee7 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 955eb9ddc..7c7cb511f 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 c55d1aa25..7f696175e 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 106811bcd..e7d1e3ae2 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 5/6] 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 39405c238..1b0373fcf 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 8116bf716..66e02f848 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 e7d1e3ae2..a3f17915c 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 86b2b5280..3ea9133ac 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 6/6] 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 478ff6f4f..a9d75514d 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 69fec5a0d..d04d3139c 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,再参照