From 5391992e65184070b0b5a52947daba9a90412bf5 Mon Sep 17 00:00:00 2001 From: ddbaron Date: Mon, 31 Aug 2026 10:56:34 -0500 Subject: [PATCH] feat(sdk/python): isolate OpenCode harness runs --- README.md | 2 + docs/ENVIRONMENT_VARIABLES.md | 2 + docs/design/harness-v2-design.md | 2 +- docs/harness-providers.md | 49 +++ .../agentfield/harness/providers/opencode.py | 239 ++++++++++++- .../tests/test_harness_provider_opencode.py | 334 +++++++++++++++++- 6 files changed, 606 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index fa43e6bd4..457c9bb40 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,8 @@ Two examples already run at this load. The [deep-research engine](https://agentf | Tool access control | `tools=["Read", "Write", "Bash"]` | | Environment injection | `env={"KEY": "value"}` | | System prompt override | `system_prompt="..."` | +| OpenCode per-run configuration | Preserves caller `OPENCODE_CONFIG_CONTENT` while applying the harness overlay | +| OpenCode prompt compatibility | `AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT=1` enables the opt-in inline rollback | | Multi-layer output recovery | Cosmetic repair → retry → full retry | #### Connector API (Fleet Management) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 127e803e6..8d0b8613d 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -253,6 +253,8 @@ On SIGTERM or a graceful `POST /shutdown`, the node immediately stops heartbeats For Kubernetes, set `terminationGracePeriodSeconds` to a value greater than the shutdown budget. This leaves time for the terminal callback and normal process teardown after the reasoner drain. `app.serve()` owns the production uvicorn-aware signal lifecycle. +Python OpenCode harness runs use the generated per-run agent configuration by default. Set `AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT=1` to opt into the legacy inline system-prompt transport for rollback or compatibility testing. This does not change authentication handling or add credentials to the prompt. + ### MiniMax video generation - `MINIMAX_API_KEY`: API key used by the Python SDK's MiniMax media provider. diff --git a/docs/design/harness-v2-design.md b/docs/design/harness-v2-design.md index 2f00a7204..e87c72286 100644 --- a/docs/design/harness-v2-design.md +++ b/docs/design/harness-v2-design.md @@ -289,7 +289,7 @@ Runner (ALL providers): ### 5.3 Prompt Suffix -Appended to the **end** of the user prompt (recency bias — models weight the end of input most heavily). Not in system prompt — CLI wrappers for Gemini/OpenCode may not expose system prompt control. +Appended to the **end** of the user prompt (recency bias — models weight the end of input most heavily). OpenCode's Python adapter configures a non-blank system prompt through its per-run `OPENCODE_CONFIG_CONTENT` agent overlay by default; `AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT=1` is an opt-in compatibility rollback that transports it inline. Other CLI wrappers may not expose system prompt control. See [the provider contract](../harness-providers.md#opencode-standalone-runs-python) for the OpenCode-specific behavior. ``` {user's actual task prompt} diff --git a/docs/harness-providers.md b/docs/harness-providers.md index 0204761c0..eff3495c4 100644 --- a/docs/harness-providers.md +++ b/docs/harness-providers.md @@ -217,6 +217,55 @@ Pi and OMP accept the same OpenRouter model strings in every SDK, for example The `#` separator is safe in model ids: `:` belongs to OpenRouter suffixes like `:free`, and `@` to Vertex-style ids, but no provider uses `#`. +### OpenCode standalone runs (Python) + +The Python OpenCode adapter uses `opencode run` for each call; it does not use +`opencode serve` or attach to a session. By default, it selects the fixed +`agentfield-harness` agent with `--agent` and supplies that agent through the +child process's `OPENCODE_CONFIG_CONTENT` environment variable. The generated +overlay sets `$schema` to `https://opencode.ai/config.json`, selects +`agentfield-harness` as the default agent, and fixes its mode to `primary` with +`steps` set to `500`. It does not modify shared OpenCode configuration files. + +The generated overlay is deep-merged into a caller-provided +`OPENCODE_CONFIG_CONTENT` value, or into the ambient value when no per-call +value is supplied. Unrelated providers, agents, MCP servers, and other +configuration remain intact, while AgentField's generated harness fields take +precedence where they overlap. The overlay contains no provider credentials or +API keys. The supplied configuration must be a JSON object; malformed content +is reported instead of being silently discarded. + +A fixed worker instruction is always included, in the default agent overlay +or in the inline prompt when the rollback is enabled. When a caller supplies a +non-blank `system_prompt`, it precedes that instruction. The resolved base +`model` and `reasoningEffort` are included only when available. +The base model is also passed with `-m`; a resolved `#variant` suffix is +passed exactly once with `--variant`. AgentField `max_turns` remains a runner +limit and is not serialized as OpenCode `steps`. + +OpenCode's initial permission baseline is headless and compatibility-oriented: +the wildcard action is allowed. It is followed by a resource-specific `skill` +denial for `agentfield*`, which prevents the child from loading AgentField +orchestration skills while leaving unrelated skills available. OpenCode +evaluates the last matching permission rule, so this ordering is intentional. +`question` and `task` are also explicitly denied, and no `ask` permission is +generated. The Python OpenCode provider currently accepts the common +`tools` and `permission_mode` options but ignores them; it does not translate +tool names into redundant permission entries, and `permission_mode` does not +select an OpenCode permission mode. The wildcard behavior is not per-role +authorization. + +For an opt-in rollback or compatibility test, set +`AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT=1` (in the per-call environment or +the ambient environment). That path keeps the generated agent selection and +targeted permissions, removes the selected agent's configured system prompt +from the merged configuration, and places the caller system prompt, fixed +worker instruction, and task into one prompt. POSIX sends that prompt as the +positional argument and Windows sends it over stdin. The default path keeps the +task prompt (including the runner's schema instructions, when applicable) as +the only user-facing prompt and configures the system prompt on the generated +agent. + ## Verify Check selected providers in a container or CI job before any paid run: diff --git a/sdk/python/agentfield/harness/providers/opencode.py b/sdk/python/agentfield/harness/providers/opencode.py index c3fa17aa6..5eecab873 100644 --- a/sdk/python/agentfield/harness/providers/opencode.py +++ b/sdk/python/agentfield/harness/providers/opencode.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import logging import os import re @@ -37,6 +38,189 @@ re.compile(r"\bAPIError\b"), ) +_OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json" +_OPENCODE_AGENT_NAME = "agentfield-harness" +_AGENTFIELD_WORKER_INSTRUCTION = ( + "You are an AgentField-launched worker. Complete the assigned prompt directly. " + "Do not invoke AgentField orchestration, the `af` CLI, `swe-planner.plan`, " + "or delegate work back to AgentField." +) +_OPENCODE_INLINE_SYSTEM_PROMPT_ENV = "AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT" +_TRUE_ENV_VALUES = frozenset(("1", "true", "yes", "on")) + + +def _opencode_permissions() -> dict[str, object]: + """Build the fixed headless permission baseline for the harness agent. + + The wildcard allow preserves the existing compatibility behavior. The + AgentField skill rule is narrower than disabling skills entirely: it + prevents an AgentField-launched worker from loading AgentField + orchestration skills while leaving unrelated user and project skills + available. OpenCode uses the last matching rule, so this rule follows the + wildcard allow. HarnessConfig.tools and permission_mode are intentionally + not translated until OpenCode has a strict per-tool authorization mode. + """ + # Headless runs must never wait for an interactive response. Keep this + # baseline independent of permission_mode: question and task are always + # denied, while the wildcard keeps the current permissive tool behavior. + return { + "*": "allow", + "skill": {"agentfield*": "deny"}, + "question": "deny", + "task": "deny", + } + + +def _agent_system_prompt(options: dict[str, object]) -> str: + system_prompt = options.get("system_prompt") + caller_prompt = system_prompt.strip() if isinstance(system_prompt, str) else "" + return ( + f"{caller_prompt}\n\n{_AGENTFIELD_WORKER_INSTRUCTION}" + if caller_prompt + else _AGENTFIELD_WORKER_INSTRUCTION + ) + + +def _inline_system_prompt_enabled(options: dict[str, object]) -> bool: + env_value = options.get("env") + if isinstance(env_value, dict) and _OPENCODE_INLINE_SYSTEM_PROMPT_ENV in env_value: + value = env_value[_OPENCODE_INLINE_SYSTEM_PROMPT_ENV] + else: + value = os.environ.get(_OPENCODE_INLINE_SYSTEM_PROMPT_ENV, "") + return isinstance(value, str) and value.strip().lower() in _TRUE_ENV_VALUES + + +def _deep_merge_config( + base: dict[str, object], overlay: dict[str, object] +) -> dict[str, object]: + """Merge nested OpenCode objects, with the generated overlay taking precedence.""" + merged = dict(base) + for key, value in overlay.items(): + existing = merged.get(key) + if isinstance(existing, dict) and isinstance(value, dict): + merged[key] = _deep_merge_config(existing, value) + else: + merged[key] = value + return merged + + +def _normalize_agentfield_harness_config( + config: dict[str, object], *, strip_system_prompt: bool +) -> None: + agents = config.get("agent") + selected_agent = ( + agents.get(_OPENCODE_AGENT_NAME) if isinstance(agents, dict) else None + ) + if not isinstance(selected_agent, dict): + return + + if strip_system_prompt: + selected_agent.pop("prompt", None) + + permission = selected_agent.get("permission") + if not isinstance(permission, dict): + return + + # OpenCode evaluates the last matching rule. Keep the generated wildcard + # first, caller-specific rules in the middle, and AgentField's targeted + # denials last even when the caller already supplied permission entries. + ordered: dict[str, object] = {} + if "*" in permission: + ordered["*"] = permission["*"] + for key, value in permission.items(): + if key not in {"*", "skill", "question", "task"}: + ordered[key] = value + + if "skill" in permission: + skill = permission["skill"] + if isinstance(skill, dict): + ordered_skill = { + key: value for key, value in skill.items() if key != "agentfield*" + } + if "agentfield*" in skill: + ordered_skill["agentfield*"] = skill["agentfield*"] + ordered["skill"] = ordered_skill + else: + ordered["skill"] = skill + if "question" in permission: + ordered["question"] = permission["question"] + if "task" in permission: + ordered["task"] = permission["task"] + selected_agent["permission"] = ordered + + +def _merge_opencode_config_content( + existing_content: Optional[str], + overlay_content: str, + *, + strip_system_prompt: bool = False, +) -> str: + """Merge the AgentField overlay into caller-provided OpenCode JSON.""" + if not existing_content or not existing_content.strip(): + return overlay_content + + try: + existing = json.loads(existing_content) + overlay = json.loads(overlay_content) + except json.JSONDecodeError as exc: + raise ValueError( + "OPENCODE_CONFIG_CONTENT must be valid JSON when supplied to the " + "AgentField OpenCode provider" + ) from exc + + if not isinstance(existing, dict): + raise ValueError( + "OPENCODE_CONFIG_CONTENT must contain a JSON object when supplied " + "to the AgentField OpenCode provider" + ) + if not isinstance(overlay, dict): # pragma: no cover - internal invariant + raise ValueError("AgentField OpenCode overlay must contain a JSON object") + + merged = _deep_merge_config(existing, overlay) + _normalize_agentfield_harness_config( + merged, strip_system_prompt=strip_system_prompt + ) + return json.dumps(merged, ensure_ascii=False) + + +def _inline_opencode_prompt(prompt: str, options: dict[str, object]) -> str: + return ( + "SYSTEM INSTRUCTIONS:\n" + f"{_agent_system_prompt(options)}\n\n" + "---\n\n" + f"USER REQUEST:\n{prompt}" + ) + + +def _build_opencode_config_content( + options: dict[str, object], + model_value: Optional[str], + variant_value: Optional[str], + *, + include_system_prompt: bool = True, +) -> str: + """Serialize the per-run OpenCode agent overlay.""" + agent: dict[str, object] = { + "mode": "primary", + "steps": 500, + "permission": _opencode_permissions(), + } + if include_system_prompt: + agent["prompt"] = _agent_system_prompt(options) + if model_value: + agent["model"] = model_value + if variant_value: + agent["reasoningEffort"] = variant_value + + return json.dumps( + { + "$schema": _OPENCODE_CONFIG_SCHEMA, + "default_agent": _OPENCODE_AGENT_NAME, + "agent": {_OPENCODE_AGENT_NAME: agent}, + }, + ensure_ascii=False, + ) + def _prompt_via_stdin() -> bool: """Whether to hand the prompt to opencode over stdin instead of argv. @@ -212,6 +396,13 @@ async def _execute_impl(self, prompt: str, options: dict[str, object]) -> RawRes cmd = [self._bin, "run"] cmd.extend(["--format", "json"]) + # Select a deterministic per-run agent. Its configuration is supplied + # through OPENCODE_CONFIG_CONTENT below rather than a shared file. The + # inline opt-out changes only the system-prompt transport so the same + # targeted permissions and model settings remain active. + inline_system_prompt = _inline_system_prompt_enabled(options) + cmd.extend(["--agent", _OPENCODE_AGENT_NAME]) + # --dir is the project root the agent may read and write. project_dir is # the canonical field; fall back to cwd when it is unset. Previously cwd # took precedence, so a nested cwd under a shared project_dir made @@ -237,22 +428,6 @@ async def _execute_impl(self, prompt: str, options: dict[str, object]) -> RawRes # response. opencode in non-TTY mode proceeds without permission # prompting, so no flag is needed. See agentfield#582. - # Handle system prompt - prepend to user prompt since OpenCode - # has no native --system-prompt flag - effective_prompt = prompt - system_prompt = options.get("system_prompt") - if isinstance(system_prompt, str) and system_prompt.strip(): - effective_prompt = ( - f"SYSTEM INSTRUCTIONS:\n{system_prompt.strip()}\n\n" - f"---\n\nUSER REQUEST:\n{prompt}" - ) - - # Prompt is a positional arg to `opencode run` (not -p) on POSIX; on - # Windows it goes over stdin instead (see _prompt_via_stdin). - prompt_via_stdin = _prompt_via_stdin() - if not prompt_via_stdin: - cmd.append(effective_prompt) - env: Dict[str, str] = {} env_value = options.get("env") if isinstance(env_value, dict): @@ -262,7 +437,37 @@ async def _execute_impl(self, prompt: str, options: dict[str, object]) -> RawRes if isinstance(key, str) and isinstance(value, str) } - # Model is passed via -m flag on the run subcommand (see above) + # Keep caller credentials and configuration in place. The per-call + # value follows normal subprocess precedence over the ambient one; the + # generated harness overlay is then merged into that value. Inline mode + # removes the selected agent's configured prompt after merging so the + # system instructions travel through the legacy prompt path instead. + existing_config = env.get("OPENCODE_CONFIG_CONTENT") + if existing_config is None: + existing_config = os.environ.get("OPENCODE_CONFIG_CONTENT") + env["OPENCODE_CONFIG_CONTENT"] = _merge_opencode_config_content( + existing_config, + _build_opencode_config_content( + options, + model_value, + variant_value, + include_system_prompt=not inline_system_prompt, + ), + strip_system_prompt=inline_system_prompt, + ) + + if inline_system_prompt: + # The fixed AgentField instruction remains present in this + # compatibility and rollback path. + effective_prompt = _inline_opencode_prompt(prompt, options) + else: + # The system prompt belongs to the selected agent, so the task + # remains the only user-facing prompt sent to OpenCode. + effective_prompt = prompt + + prompt_via_stdin = _prompt_via_stdin() + if not prompt_via_stdin: + cmd.append(effective_prompt) cwd: Optional[str] = None diff --git a/sdk/python/tests/test_harness_provider_opencode.py b/sdk/python/tests/test_harness_provider_opencode.py index f6d88bb26..d5f64101d 100644 --- a/sdk/python/tests/test_harness_provider_opencode.py +++ b/sdk/python/tests/test_harness_provider_opencode.py @@ -2,6 +2,7 @@ # pyright: reportMissingImports=false +import json from typing import Any from unittest.mock import patch @@ -64,12 +65,31 @@ async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None "run", "--format", "json", + "--agent", + "agentfield-harness", "--dir", "/tmp/work", "hello", ] assert captured["env"]["A"] == "1" assert "XDG_DATA_HOME" in captured["env"] + overlay = json.loads(captured["env"]["OPENCODE_CONFIG_CONTENT"]) + assert overlay["default_agent"] == "agentfield-harness" + assert overlay["agent"]["agentfield-harness"] == { + "mode": "primary", + "steps": 500, + "permission": { + "*": "allow", + "skill": {"agentfield*": "deny"}, + "question": "deny", + "task": "deny", + }, + "prompt": ( + "You are an AgentField-launched worker. Complete the assigned prompt directly. " + "Do not invoke AgentField orchestration, the `af` CLI, `swe-planner.plan`, " + "or delegate work back to AgentField." + ), + } # Note: cwd is None because we use --dir in command instead of cwd param assert raw.is_error is False assert raw.result == "final text" @@ -78,6 +98,300 @@ async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None assert raw.messages == [] +@pytest.mark.asyncio +async def test_opencode_overlay_configures_agent_and_run_options( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict[str, Any] = {} + + async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None): + _ = cwd, timeout, input_text + captured["cmd"] = cmd + captured["env"] = env + return "ok\n", "", 0 + + monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli) + + task = "inspect the repository" + provider = OpenCodeProvider() + await provider.execute( + task, + { + "model": "openai/gpt-5#low", + "variant": "max", + "system_prompt": " Work autonomously. ", + "tools": ["Read", "Write", "Edit", "Glob", "Grep", "Bash"], + "permission_mode": "plan", + "max_turns": 7, + "env": {"OPENAI_API_KEY": "secret", "DEPLOYMENT": "local"}, + }, + ) + + overlay = json.loads(captured["env"]["OPENCODE_CONFIG_CONTENT"]) + agent = overlay["agent"]["agentfield-harness"] + assert overlay["$schema"] == "https://opencode.ai/config.json" + assert overlay["default_agent"] == "agentfield-harness" + assert agent["mode"] == "primary" + assert agent["steps"] == 500 + assert agent["prompt"].startswith("Work autonomously.\n\n") + assert "Do not invoke AgentField orchestration" in agent["prompt"] + assert "the `af` CLI" in agent["prompt"] + assert agent["model"] == "openai/gpt-5" + assert agent["reasoningEffort"] == "max" + assert agent["permission"] == { + "*": "allow", + "skill": {"agentfield*": "deny"}, + "question": "deny", + "task": "deny", + } + permission_keys = list(agent["permission"]) + assert permission_keys.index("*") < permission_keys.index("skill") + assert agent["permission"]["skill"] == {"agentfield*": "deny"} + assert "ask" not in agent["permission"] + assert "max_turns" not in agent + + assert captured["cmd"] == [ + "opencode", + "run", + "--format", + "json", + "--agent", + "agentfield-harness", + "-m", + "openai/gpt-5", + "--variant", + "max", + task, + ] + assert captured["cmd"].count("--variant") == 1 + assert "SYSTEM INSTRUCTIONS:" not in captured["cmd"] + assert captured["env"]["OPENAI_API_KEY"] == "secret" + assert captured["env"]["DEPLOYMENT"] == "local" + + +@pytest.mark.asyncio +async def test_opencode_overlay_merges_per_call_config_without_clobbering( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict[str, Any] = {} + + async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None): + _ = cmd, cwd, timeout, input_text + captured["env"] = env + return "ok\n", "", 0 + + monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli) + monkeypatch.setenv( + "OPENCODE_CONFIG_CONTENT", + json.dumps({"provider": {"ambient": {"model": "ambient"}}}), + ) + caller_config = json.dumps( + { + "provider": { + "openai": {"options": {"timeout": 45}}, + "keep-me": {"npm": "custom/provider"}, + }, + "agent": { + "custom-agent": {"prompt": "preserve this agent"}, + "agentfield-harness": { + "permission": {"read": "deny", "skill": {"other*": "allow"}}, + "temperature": 0.2, + }, + }, + "mcp": {"local": {"type": "local", "command": ["tool"]}}, + } + ) + + await OpenCodeProvider().execute( + "hello", + { + "model": "openai/gpt-5", + "env": {"OPENCODE_CONFIG_CONTENT": caller_config}, + }, + ) + + merged = json.loads(captured["env"]["OPENCODE_CONFIG_CONTENT"]) + assert merged["provider"]["openai"]["options"]["timeout"] == 45 + assert "ambient" not in merged["provider"] + assert merged["provider"]["keep-me"] == {"npm": "custom/provider"} + assert merged["mcp"]["local"]["command"] == ["tool"] + assert merged["agent"]["custom-agent"] == {"prompt": "preserve this agent"} + generated_agent = merged["agent"]["agentfield-harness"] + assert generated_agent["model"] == "openai/gpt-5" + assert generated_agent["temperature"] == 0.2 + assert generated_agent["permission"]["read"] == "deny" + assert generated_agent["permission"]["skill"] == { + "other*": "allow", + "agentfield*": "deny", + } + assert generated_agent["permission"]["*"] == "allow" + assert generated_agent["permission"]["question"] == "deny" + assert generated_agent["permission"]["task"] == "deny" + permission_keys = list(generated_agent["permission"]) + assert permission_keys.index("*") < permission_keys.index("skill") + assert permission_keys.index("skill") < permission_keys.index("question") + assert permission_keys.index("question") < permission_keys.index("task") + assert list(generated_agent["permission"]["skill"]) == [ + "other*", + "agentfield*", + ] + + +@pytest.mark.asyncio +async def test_opencode_overlay_merges_ambient_config( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict[str, Any] = {} + + async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None): + _ = cmd, cwd, timeout, input_text + captured["env"] = env + return "ok\n", "", 0 + + monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli) + monkeypatch.setenv( + "OPENCODE_CONFIG_CONTENT", + json.dumps({"provider": {"custom": {"options": {"baseURL": "http://local"}}}}), + ) + + await OpenCodeProvider().execute("hello", {}) + + merged = json.loads(captured["env"]["OPENCODE_CONFIG_CONTENT"]) + assert merged["provider"]["custom"]["options"]["baseURL"] == "http://local" + assert merged["default_agent"] == "agentfield-harness" + + +@pytest.mark.asyncio +async def test_opencode_overlay_rejects_malformed_config( + monkeypatch: pytest.MonkeyPatch, +): + async def fake_run_cli(*_args, **_kwargs): + raise AssertionError("the subprocess must not start") + + monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli) + + with pytest.raises(ValueError, match="OPENCODE_CONFIG_CONTENT must be valid JSON"): + await OpenCodeProvider().execute( + "hello", + {"env": {"OPENCODE_CONFIG_CONTENT": "{not-json"}}, + ) + + +@pytest.mark.asyncio +async def test_opencode_inline_system_prompt_rollback_preserves_config( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict[str, Any] = {} + + async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None): + captured["cmd"] = cmd + captured["env"] = env + captured["input_text"] = input_text + return "ok\n", "", 0 + + monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli) + caller_config = json.dumps( + {"provider": {"custom": {"model": "keep", "apiKey": "test-secret"}}} + ) + + await OpenCodeProvider().execute( + "complete the task", + { + "system_prompt": " caller instructions ", + "env": { + "AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT": "1", + "OPENCODE_CONFIG_CONTENT": caller_config, + }, + }, + ) + + assert captured["cmd"][captured["cmd"].index("--agent") + 1] == ( + "agentfield-harness" + ) + assert captured["cmd"][-1] == ( + "SYSTEM INSTRUCTIONS:\n" + "caller instructions\n\n" + "You are an AgentField-launched worker. Complete the assigned prompt directly. " + "Do not invoke AgentField orchestration, the `af` CLI, `swe-planner.plan`, " + "or delegate work back to AgentField.\n\n" + "---\n\n" + "USER REQUEST:\ncomplete the task" + ) + merged = json.loads(captured["env"]["OPENCODE_CONFIG_CONTENT"]) + assert merged["provider"]["custom"] == { + "model": "keep", + "apiKey": "test-secret", + } + assert merged["agent"]["agentfield-harness"]["permission"] == { + "*": "allow", + "skill": {"agentfield*": "deny"}, + "question": "deny", + "task": "deny", + } + assert "prompt" not in merged["agent"]["agentfield-harness"] + assert "test-secret" not in captured["cmd"][-1] + + +@pytest.mark.asyncio +async def test_opencode_inline_system_prompt_uses_stdin_for_large_windows_prompt( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict[str, Any] = {} + + async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None): + captured["cmd"] = cmd + captured["input_text"] = input_text + return "ok\n", "", 0 + + monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli) + monkeypatch.setattr( + "agentfield.harness.providers.opencode._prompt_via_stdin", lambda: True + ) + large_prompt = "request-" + ("x" * 65536) + + await OpenCodeProvider().execute( + large_prompt, + {"env": {"AGENTFIELD_OPENCODE_INLINE_SYSTEM_PROMPT": "true"}}, + ) + + assert captured["cmd"][captured["cmd"].index("--agent") + 1] == ( + "agentfield-harness" + ) + assert large_prompt in captured["input_text"] + assert "You are an AgentField-launched worker" in captured["input_text"] + assert all(large_prompt not in part for part in captured["cmd"]) + + +@pytest.mark.asyncio +async def test_opencode_overlay_preserves_large_system_prompt( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict[str, Any] = {} + + async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None): + captured["cmd"] = cmd + captured["env"] = env + _ = cwd, timeout, input_text + return "ok\n", "", 0 + + monkeypatch.setattr("agentfield.harness.providers.opencode.run_cli", fake_run_cli) + large_system_prompt = "system-" + ("x" * 65536) + + await OpenCodeProvider().execute( + "request", + {"system_prompt": large_system_prompt}, + ) + + overlay = json.loads(captured["env"]["OPENCODE_CONFIG_CONTENT"]) + assert overlay["agent"]["agentfield-harness"]["prompt"] == ( + f"{large_system_prompt}\n\n" + "You are an AgentField-launched worker. Complete the assigned prompt directly. " + "Do not invoke AgentField orchestration, the `af` CLI, `swe-planner.plan`, " + "or delegate work back to AgentField." + ) + assert captured["cmd"][-1] == "request" + + @pytest.mark.asyncio async def test_opencode_provider_returns_helpful_binary_not_found_error( monkeypatch: pytest.MonkeyPatch, @@ -143,6 +457,8 @@ async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None "run", "--format", "json", + "--agent", + "agentfield-harness", "-m", "openai/gpt-5", "hello", @@ -497,6 +813,7 @@ async def capture_cmd( # Must use `run` subcommand assert captured_cmd[1] == "run", "Must use 'opencode run' subcommand (v1.4+)" assert "--format" in captured_cmd, "Must request JSON stream for metrics parsing" + assert captured_cmd[captured_cmd.index("--agent") + 1] == "agentfield-harness" assert "json" in captured_cmd, "Must request JSON output format" # Must NOT use deprecated -p flag assert "-p" not in captured_cmd, "Must not use deprecated -p flag (v1.4+)" @@ -624,11 +941,18 @@ async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None ) assert raw.is_error is False - # The prompt (with the system prompt folded in) went over stdin... - assert "a prompt far too large" in (captured["input_text"] or "") - assert "SYSTEM INSTRUCTIONS:" in (captured["input_text"] or "") + # The task prompt went over stdin without a folded system prompt. + assert captured["input_text"] == "a prompt far too large for a cmd.exe command line" + assert "SYSTEM INSTRUCTIONS:" not in (captured["input_text"] or "") # ...and argv carries only the fixed flags, no positional prompt. - assert captured["cmd"][:4] == ["opencode", "run", "--format", "json"] + assert captured["cmd"][:6] == [ + "opencode", + "run", + "--format", + "json", + "--agent", + "agentfield-harness", + ] assert all("too large" not in part for part in captured["cmd"]) @@ -653,6 +977,8 @@ async def fake_run_cli(cmd, *, env=None, cwd=None, timeout=None, input_text=None "run", "--format", "json", + "--agent", + "agentfield-harness", "-m", "openrouter/z-ai/glm-5.2", "--variant",