diff --git a/.env.example b/.env.example index 91739b1..66c8692 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ AGENTCI_PLAN_MODEL=openai/gpt-5.6-sol AGENTCI_PLAN_VARIANT= AGENTCI_IMPLEMENT_MODEL=openai/gpt-5.6-sol AGENTCI_IMPLEMENT_VARIANT= +AGENTCI_EXPLORE_MODEL=openai/gpt-5.6-luna +AGENTCI_EXPLORE_VARIANT=low AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna AGENTCI_RESEARCH_VARIANT=high # Optional; Context7 works without a key at lower rate limits. diff --git a/Dockerfile b/Dockerfile index 862986e..e313304 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,6 +65,8 @@ RUN chmod 0755 \ && OPENCODE_CONFIG=/etc/opencode/opencode.json \ OPENCODE_DISABLE_PROJECT_CONFIG=1 \ OPENCODE_PURE=1 \ + AGENTCI_EXPLORE_MODEL=openai/gpt-5.6-luna \ + AGENTCI_EXPLORE_VARIANT=low \ AGENTCI_RESEARCH_MODEL=openai/gpt-5.6-luna \ AGENTCI_RESEARCH_VARIANT=high \ CONTEXT7_API_KEY= \ diff --git a/README.md b/README.md index 8740886..ffae856 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,9 @@ has a connected provider. The worker leaves jobs queued while the runtime is una Models use OpenCode's `provider/model` format. Planning, implementation, and research can use different providers. Optional `AGENTCI_PLAN_VARIANT` and `AGENTCI_IMPLEMENT_VARIANT` values are passed directly to OpenCode for providers that support variants. `AGENTCI_RESEARCH_VARIANT` -configures the research subagent and defaults to `high`. +configures the research subagent and defaults to `high`. `AGENTCI_EXPLORE_MODEL` and +`AGENTCI_EXPLORE_VARIANT` configure OpenCode's explore agent and default to +`openai/gpt-5.6-luna` with `low`. `AGENTCI_OPENCODE_VERSION` controls the npm version or range installed into the image and defaults to `^1`. The build verifies that the resolved version is still OpenCode 1.x and prints it. Compose @@ -75,10 +77,10 @@ are root-owned and read-only, and external skill discovery is disabled, so an ag instructions for later repositories. CodeGraph, Context7, and `gh_grep` are configured as MCP servers. Set `AGENTCI_CONTEXT7_API_KEY` to raise Context7 rate limits. -The `research` subagent has the same unrestricted permissions as every other agent. Its prompt -asks it to focus on external evidence, but this is guidance rather than an isolation boundary. -Agent CI initializes or refreshes CodeGraph before every turn and locally excludes `.codegraph/` -from Git. +The `research` subagent can only use Exa web search, Context7, and the `gh_grep` public-code search +MCP. All filesystem, shell, editing, task, and other tools are denied for that agent. Exa is enabled +with `OPENCODE_ENABLE_EXA=1`. Agent CI initializes or refreshes CodeGraph before every parent turn +and locally excludes `.codegraph/` from Git. ### Security boundary diff --git a/compose.yaml b/compose.yaml index 1c84ae2..2f0d1e0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -23,6 +23,8 @@ services: AGENTCI_PLAN_VARIANT: ${AGENTCI_PLAN_VARIANT:-} AGENTCI_IMPLEMENT_MODEL: ${AGENTCI_IMPLEMENT_MODEL:-openai/gpt-5.6-sol} AGENTCI_IMPLEMENT_VARIANT: ${AGENTCI_IMPLEMENT_VARIANT:-} + AGENTCI_EXPLORE_MODEL: ${AGENTCI_EXPLORE_MODEL:-openai/gpt-5.6-luna} + AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low} AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna} AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high} AGENTCI_PLAN_REVIEW_ROUNDS: ${AGENTCI_PLAN_REVIEW_ROUNDS:-4} @@ -63,11 +65,14 @@ services: OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1" OPENCODE_DISABLE_CLAUDE_CODE: "1" OPENCODE_DISABLE_AUTOUPDATE: "1" + OPENCODE_ENABLE_EXA: "1" OPENCODE_PURE: "1" OPENCODE_SERVER_USERNAME: ${OPENCODE_SERVER_USERNAME:-opencode} OPENCODE_SERVER_PASSWORD_FILE: /run/secrets/opencode_server_password AGENTCI_GITEA_URL: ${AGENTCI_GITEA_URL:-http://gitea:3000} AGENTCI_TEA_CONFIG_HOME: /run/agentci + AGENTCI_EXPLORE_MODEL: ${AGENTCI_EXPLORE_MODEL:-openai/gpt-5.6-luna} + AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low} AGENTCI_RESEARCH_MODEL: ${AGENTCI_RESEARCH_MODEL:-openai/gpt-5.6-luna} AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high} CONTEXT7_API_KEY: ${AGENTCI_CONTEXT7_API_KEY:-} diff --git a/opencode/opencode.json b/opencode/opencode.json index bb82f5f..34fded6 100644 --- a/opencode/opencode.json +++ b/opencode/opencode.json @@ -16,6 +16,8 @@ "permission": "allow" }, "explore": { + "model": "{env:AGENTCI_EXPLORE_MODEL}", + "variant": "{env:AGENTCI_EXPLORE_VARIANT}", "permission": "allow" }, "research": { @@ -23,7 +25,12 @@ "mode": "subagent", "model": "{env:AGENTCI_RESEARCH_MODEL}", "variant": "{env:AGENTCI_RESEARCH_VARIANT}", - "permission": "allow", + "permission": { + "*": "deny", + "websearch": "allow", + "context7_*": "allow", + "gh_grep_*": "allow" + }, "prompt": "Research external, current, or unfamiliar technical facts for the parent agent. Use Context7 for library documentation, gh_grep for public-code examples, and web search for primary sources or broader verification. Prefer authoritative sources, report links, distinguish facts from inference, and return a concise evidence-focused summary. Do not include secrets or proprietary source in external queries." } }, diff --git a/src/agentci/config.py b/src/agentci/config.py index f128699..f982343 100644 --- a/src/agentci/config.py +++ b/src/agentci/config.py @@ -36,6 +36,8 @@ class Settings(BaseSettings): plan_variant: str | None = None implement_model: str = "openai/gpt-5.6-sol" implement_variant: str | None = None + explore_model: str = "openai/gpt-5.6-luna" + explore_variant: str = "low" research_model: str = "openai/gpt-5.6-luna" research_variant: str = "high" plan_review_rounds: int = Field(default=4, ge=1, le=20) @@ -53,7 +55,7 @@ class Settings(BaseSettings): def strip_url(cls, value: str) -> str: return value.rstrip("/") - @field_validator("plan_model", "implement_model", "research_model") + @field_validator("plan_model", "implement_model", "explore_model", "research_model") @classmethod def validate_opencode_model(cls, value: str) -> str: provider, separator, model = value.partition("/") diff --git a/src/agentci/container.py b/src/agentci/container.py index 3dcb153..d058de6 100644 --- a/src/agentci/container.py +++ b/src/agentci/container.py @@ -59,6 +59,7 @@ async def build_container(settings: Settings) -> Container: required_models=( (settings.plan_model, settings.plan_variant), (settings.implement_model, settings.implement_variant), + (settings.explore_model, settings.explore_variant), (settings.research_model, settings.research_variant), ), timeout_seconds=settings.turn_timeout_seconds, diff --git a/tests/test_config.py b/tests/test_config.py index b27fdfc..9661146 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -32,6 +32,12 @@ def test_defaults_research_variant_to_high() -> None: assert settings.research_variant == "high" +def test_defaults_explore_agent_to_luna_low() -> None: + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.explore_model == "openai/gpt-5.6-luna" + assert settings.explore_variant == "low" + + def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> None: monkeypatch.setenv("AGENTCI_INSTALL_SCRIPTS", "python,dotnet") @@ -40,7 +46,9 @@ def test_reads_comma_delimited_install_scripts_from_environment(monkeypatch) -> assert settings.install_scripts == ["python", "dotnet"] -@pytest.mark.parametrize("field", ["plan_model", "implement_model", "research_model"]) +@pytest.mark.parametrize( + "field", ["plan_model", "implement_model", "explore_model", "research_model"] +) def test_requires_provider_qualified_opencode_models(field: str) -> None: with pytest.raises(ValidationError, match="provider/model"): Settings(_env_file=None, **{field: "model-only"}) # type: ignore[call-arg] diff --git a/tests/test_opencode_deployment.py b/tests/test_opencode_deployment.py index 36ada5a..68006f7 100644 --- a/tests/test_opencode_deployment.py +++ b/tests/test_opencode_deployment.py @@ -2,17 +2,25 @@ import json from pathlib import Path -def test_config_grants_all_agents_unrestricted_permissions() -> None: +def test_config_grants_parent_agents_access_and_restricts_research() -> None: root = Path(__file__).parents[1] config = json.loads((root / "opencode" / "opencode.json").read_text()) assert config["permission"] == "allow" assert all( config["agent"][name]["permission"] == "allow" - for name in ("build", "plan", "general", "explore", "research") + for name in ("build", "plan", "general", "explore") ) + assert config["agent"]["research"]["permission"] == { + "*": "deny", + "websearch": "allow", + "context7_*": "allow", + "gh_grep_*": "allow", + } assert config["mcp"]["codegraph"]["command"] == ["codegraph", "serve", "--mcp"] assert config["mcp"]["context7"]["url"] == "https://mcp.context7.com/mcp" + assert config["agent"]["explore"]["model"] == "{env:AGENTCI_EXPLORE_MODEL}" + assert config["agent"]["explore"]["variant"] == "{env:AGENTCI_EXPLORE_VARIANT}" assert config["agent"]["research"]["variant"] == "{env:AGENTCI_RESEARCH_VARIANT}" @@ -28,6 +36,8 @@ def test_compose_removes_codex_sandbox_exceptions() -> None: assert "HOME: /etc/opencode/home" in compose assert "OPENCODE_DISABLE_EXTERNAL_SKILLS" in compose assert "OPENCODE_DISABLE_DEFAULT_PLUGINS" not in compose + assert 'OPENCODE_ENABLE_EXA: "1"' in compose + assert "AGENTCI_EXPLORE_VARIANT: ${AGENTCI_EXPLORE_VARIANT:-low}" in compose assert "AGENTCI_RESEARCH_VARIANT: ${AGENTCI_RESEARCH_VARIANT:-high}" in compose assert compose.count("/run/agentci:mode=1777") == 2 assert "AGENTCI_OPENCODE_VERSION: ${AGENTCI_OPENCODE_VERSION:-^1}" in compose