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
9 changes: 9 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<id>' 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:
Expand Down
3 changes: 2 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
8 changes: 8 additions & 0 deletions nerve/agent/backends/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<id>' 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
Expand Down Expand Up @@ -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 {}),
)

Expand Down
27 changes: 25 additions & 2 deletions tests/test_cache_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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))

Expand All @@ -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)
# ---------------------------------------------------------------------------
Expand Down
17 changes: 17 additions & 0 deletions tests/test_config_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading