diff --git a/config.example.yaml b/config.example.yaml index 22154b35..5e9db4f4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -68,6 +68,15 @@ agent: max_turns: 50 # Max agentic turns per request max_concurrent: 32 # Max concurrent agent sessions background_agent_permissions: true # Background sub-agents (Agent run_in_background) get the same tool permissions as foreground; false denies their Write/Edit/Bash + # Agent teams (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). The CLI gates the + # SendMessage tool behind this flag while the Agent tool advertises it + # regardless ("use SendMessage with to: '' to continue this agent"), so + # with it off the model is told to resume sub-agents with a tool that does + # not exist. On: sub-agents are resumable with their context intact, and + # teammates can message each other. Teammates stay opt-in per turn and cost + # a full context window each; the CLI cannot restore in-process teammates + # when a session's client is recycled (idle timeout, restart, crash retry). + agent_teams: true # First-prompt rewrite — the web UI can refine the opening message of a # new chat with a fast model, preview it, and send only after approval. prompt_rewrite: diff --git a/docs/config.md b/docs/config.md index 762f3483..82454915 100644 --- a/docs/config.md +++ b/docs/config.md @@ -247,7 +247,7 @@ A reload is always explicit. Two things cause one: | `telegram.dm_policy`, `.stream_mode` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; `allowed_users` does not follow it (see the restart table) | | `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table | | `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below | -| **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other | +| **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other | All of that is reloaded together, and the response says what happened to each piece: `POST /api/config/reload` returns `ok`, a per-subsystem `detail`, and an @@ -907,6 +907,7 @@ from any working directory: | `agent.max_concurrent` | int | `32` | Max concurrent agent sessions | | `agent.cache_ttl` | string | `"5m"` | Prompt-cache write TTL policy: `5m` (status quo), `1h` (always request the 1-hour TTL), or `auto` (per session at client-build time: sparse-cadence sessions — persistent crons, wakeup loops, spaced chats — get `1h`; dense sessions stay on `5m`). Per-cron-job override via `cache_ttl` in jobs.yaml. See `nerve/agent/cache_policy.py` | | `agent.cache_ttl_excluded_models` | list | `[]` | Model-name substrings that never request the 1h TTL | +| `agent.agent_teams` | bool | `true` | Set `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` for the CLI subprocess, which registers the `SendMessage` tool. The Agent tool advertises `SendMessage` for resuming a sub-agent whether or not the flag is set, so with it off the model reaches for a tool that does not exist. Nerve loads no settings files (`setting_sources=[]`), so the env dict is the flag's only route in. Teammates stay opt-in per turn and cost a full context window each; the CLI cannot restore in-process teammates when a session's client is recycled (idle timeout, restart, crash retry) | | `agent.prompt_rewrite.enabled` | bool | `true` | Offer the first-prompt rewrite feature in the web UI (per-user toggle lives in the composer) | | `agent.prompt_rewrite.model` | string | `""` | Model for prompt rewriting (empty = `agent.model`, the chat model) | | `agent.prompt_rewrite.max_tokens` | int | `1024` | Max tokens for the rewritten prompt | diff --git a/nerve/agent/backends/claude.py b/nerve/agent/backends/claude.py index e8578dc4..8a823d59 100644 --- a/nerve/agent/backends/claude.py +++ b/nerve/agent/backends/claude.py @@ -611,6 +611,14 @@ def _build_env(self, cache_ttl: str = "5m") -> dict[str, str]: # wakeup timing (PostToolUse capture + cron-service sweep). The # tool itself stays available (this flag only gates the firing). env["CLAUDE_CODE_DISABLE_CRON"] = "1" + # Agent teams: gates the CLI's ``SendMessage`` tool. Without it the + # Agent tool still tells the model to resume sub-agents via + # SendMessage — in its own description and in every spawn result — + # so the model reaches for a tool that was never registered. Nerve + # loads no settings files (``setting_sources=[]``), so this env dict + # is the flag's only route into the CLI. See agent.agent_teams. + if config.agent.agent_teams: + env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = "1" if config.provider.is_bedrock: env["CLAUDE_CODE_USE_BEDROCK"] = "1" if config.provider.aws_region: diff --git a/nerve/config.py b/nerve/config.py index 7db36d3b..ca040eea 100644 --- a/nerve/config.py +++ b/nerve/config.py @@ -871,6 +871,22 @@ class AgentConfig: # grants the permission. Set False to restore the CLI default (background # sub-agent writes denied; build/write agents must then run in foreground). background_agent_permissions: bool = True + # Agent teams (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). The CLI gates the + # `SendMessage` tool behind this flag, and without it the Agent tool still + # advertises SendMessage in its own description and in every spawn result + # ("use SendMessage with to: '' to continue this agent") — so the model + # is told to resume sub-agents with a tool that does not exist, tries, and + # fails. Enabling it makes SendMessage real: sub-agents become resumable + # with their context intact, and teammates can message each other. + # Nerve loads no settings files (setting_sources=[]), so the flag can only + # reach the CLI through the env dict built in the claude backend. + # Teammates are opt-in per turn — the model still has to spawn them — and + # they cost a full context window each. Note the CLI cannot restore + # in-process teammates on resume: a session whose client is recycled (idle + # timeout, restart, crash retry) comes back without them, and the lead may + # message teammates that no longer exist. Set False to restore the CLI + # default (no SendMessage, no teams). + agent_teams: bool = True prompt_rewrite: PromptRewriteConfig = field(default_factory=PromptRewriteConfig) @property @@ -908,6 +924,7 @@ def from_dict(cls, d: dict) -> AgentConfig: ), cli_idle_timeout_seconds=d.get("cli_idle_timeout_seconds", 900), background_agent_permissions=d.get("background_agent_permissions", True), + agent_teams=d.get("agent_teams", True), prompt_rewrite=PromptRewriteConfig.from_dict(d.get("prompt_rewrite") or {}), ) diff --git a/tests/test_cache_policy.py b/tests/test_cache_policy.py index d3f1701e..52636ab9 100644 --- a/tests/test_cache_policy.py +++ b/tests/test_cache_policy.py @@ -166,7 +166,9 @@ def test_cadence_query_failure_falls_back_to_priors(self): # --------------------------------------------------------------------------- def _make_env_backend( - is_bedrock: bool = False, aliases: dict[str, str] | None = None + is_bedrock: bool = False, + aliases: dict[str, str] | None = None, + agent_teams: bool = True, ) -> ClaudeBackend: config = SimpleNamespace( provider=SimpleNamespace( @@ -175,7 +177,9 @@ def _make_env_backend( ), proxy=SimpleNamespace(enabled=False, host="", port=0), effective_api_key="", - agent=SimpleNamespace(model_aliases=aliases or {}), + agent=SimpleNamespace( + model_aliases=aliases or {}, agent_teams=agent_teams, + ), ) return ClaudeBackend(SimpleNamespace(config=lambda: config)) @@ -202,6 +206,25 @@ def test_build_env_default_is_5m(): assert "ENABLE_PROMPT_CACHING_1H" not in env +# --------------------------------------------------------------------------- +# Agent teams — gates the CLI's SendMessage tool (agent.agent_teams) +# --------------------------------------------------------------------------- + +def test_build_env_agent_teams_enabled_by_default(): + env = _make_env_backend()._build_env() + assert env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] == "1" + + +def test_build_env_agent_teams_disabled_omits_flag(): + env = _make_env_backend(agent_teams=False)._build_env() + assert "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS" not in env + + +def test_build_env_agent_teams_independent_of_provider(): + env = _make_env_backend(is_bedrock=True)._build_env() + assert env["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] == "1" + + # --------------------------------------------------------------------------- # Model-alias env emission (agent.model_aliases → ANTHROPIC_DEFAULT_*_MODEL) # --------------------------------------------------------------------------- diff --git a/tests/test_config_resolution.py b/tests/test_config_resolution.py index e7061211..01de1739 100644 --- a/tests/test_config_resolution.py +++ b/tests/test_config_resolution.py @@ -260,6 +260,23 @@ def test_model_aliases_key_recognized_by_validator(self): assert validate_config_keys(merged) == [] +class TestAgentTeams: + """agent.agent_teams — gates the CLI's SendMessage tool.""" + + def test_enabled_by_default(self): + from nerve.config import AgentConfig + + assert AgentConfig.from_dict({}).agent_teams is True + + def test_can_be_disabled(self): + from nerve.config import AgentConfig + + assert AgentConfig.from_dict({"agent_teams": False}).agent_teams is False + + def test_key_recognized_by_validator(self): + assert validate_config_keys({"agent": {"agent_teams": False}}) == [] + + class TestClaudeModels: """config.claude_models — the composer's selectable Claude model list."""