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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/ENVIRONMENT_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/design/harness-v2-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
49 changes: 49 additions & 0 deletions docs/harness-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
239 changes: 222 additions & 17 deletions sdk/python/agentfield/harness/providers/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
import json
import logging
import os
import re
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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

Expand Down
Loading
Loading