Skip to content
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ CONTAINER_LLM_MODEL=
# Increase if you see truncated specs/PRDs (default 4096 is often too small)
LLM_MAX_TOKENS=16384

# Maximum LangGraph steps for each host ForgeAgent invocation (default 100).
# A positive forge.agent_recursion_limit Jira project property overrides this.
# Changes to this environment value require a worker restart.
AGENT_RECURSION_LIMIT=100

# =============================================================================
# MCP Servers Configuration
# =============================================================================
Expand Down
38 changes: 38 additions & 0 deletions docs/reference/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,44 @@ repositories:
change_request_mode: direct
```

## Host agent recursion limit

`AGENT_RECURSION_LIMIT` sets the positive global maximum number of LangGraph
steps for each host `ForgeAgent` invocation; it defaults to `100`. A Jira
project can override it with a positive JSON integer in
`forge.agent_recursion_limit`:

```bash
forge project-setup MYPROJ --agent-recursion-limit 150
forge get-config MYPROJ --property forge.agent_recursion_limit
forge project-setup MYPROJ --clear-agent-recursion-limit
```

An absent property or JSON `null` uses the global value, even when
`FORGE_REQUIRE_PROJECT_CONFIG=true`. Other JSON types, zero, and negative
numbers are invalid. Forge reads the property once at the start of each
ticket-scoped host agent run. Temporary Jira read failures receive bounded
retries, then stop the run with an error; Forge does not assume the global
value after a failed read. Runs without a ticket use the global value without
contacting Jira. The limit applies separately to each graph invocation,
including a permitted structured-output fallback or transient retry. It does
not cap wall-clock time or the entire ticket across workflow retries.

`forge get-config MYPROJ --json` shows the raw project property, this CLI
process's global fallback, and the effective value and source. A malformed
property or failed read appears as an error with no claimed effective value.
The CLI may have a different environment from the running worker. Project
changes affect the next host agent run; changing the worker's environment
value requires a worker restart. Container implementation agents use their
own execution limits.

Full configuration output and `--property forge.agent_recursion_limit` fail with
a nonzero exit code if the recursion setting is malformed or cannot be read.
`--models` and queries for unrelated properties (for example,
`--property forge.repos`) do not read or validate the recursion setting, so its
errors and retries cannot block those queries. This also applies to the
`project-config` alias; host agent execution remains fail-closed.

## Proposal review configuration

Projects can opt into GitHub pull-request review for PRDs and specifications.
Expand Down
142 changes: 142 additions & 0 deletions src/forge/agent_recursion_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Resolve the host-agent graph limit from global and Jira project settings."""

import asyncio
import logging
import math
import time
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
from typing import TYPE_CHECKING, Any

import httpx

if TYPE_CHECKING:
from forge.config import Settings
from forge.integrations.jira.client import JiraClient


AGENT_RECURSION_PROPERTY = "forge.agent_recursion_limit"
MAX_READ_ATTEMPTS = 3
READ_ATTEMPT_TIMEOUT = 10.0
READ_TOTAL_BUDGET = 30.0
READ_BACKOFF = (0.5, 1.0)
logger = logging.getLogger(__name__)


def validate_project_agent_recursion_limit(raw: Any, project_key: str) -> int | None:
"""Validate one optional JSON project override without applying fallback."""
if raw is None:
return None
if type(raw) is not int or raw < 1:
raise ValueError(f"{AGENT_RECURSION_PROPERTY} for {project_key} must be a positive integer")
return raw


def _monotonic() -> float:
return time.monotonic()


async def _sleep(delay: float) -> None:
await asyncio.sleep(delay)


async def _await_with_timeout(awaitable: Any, timeout: float) -> Any:
return await asyncio.wait_for(awaitable, timeout=timeout)


def _retry_after_seconds(value: str | None) -> float | None:
if value is None:
return None
try:
delay = float(value)
except ValueError:
try:
retry_date = parsedate_to_datetime(value)
if retry_date.tzinfo is None:
retry_date = retry_date.replace(tzinfo=UTC)
delay = max(0.0, (retry_date - datetime.now(UTC)).total_seconds())
except (TypeError, ValueError):
return None
return delay if math.isfinite(delay) and delay >= 0 else None


async def read_agent_recursion_property(jira: "JiraClient", project_key: str) -> Any | None:
"""Read one project property with bounded transport retries only."""
deadline = _monotonic() + READ_TOTAL_BUDGET
for attempt in range(1, MAX_READ_ATTEMPTS + 1):
remaining = deadline - _monotonic()
if remaining <= 0:
raise RuntimeError(
f"Reading {AGENT_RECURSION_PROPERTY} for {project_key} exceeded the time budget"
)
retry_after = None
try:
return await _await_with_timeout(
jira.get_project_property(project_key, AGENT_RECURSION_PROPERTY),
min(READ_ATTEMPT_TIMEOUT, remaining),
)
except asyncio.CancelledError:
raise
except httpx.HTTPStatusError as error:
status = error.response.status_code
if status == 404:
return None
if status != 429 and status < 500:
raise RuntimeError(
f"Reading {AGENT_RECURSION_PROPERTY} for {project_key} failed: HTTP {status}"
) from None
reason = f"HTTP {status}"
retry_after = _retry_after_seconds(error.response.headers.get("Retry-After"))
except (httpx.TransportError, TimeoutError) as error:
reason = "timeout" if isinstance(error, TimeoutError) else "transport error"
except (ValueError, KeyError, TypeError):
raise ValueError(
f"Malformed {AGENT_RECURSION_PROPERTY} response for {project_key}"
) from None

if attempt == MAX_READ_ATTEMPTS:
raise RuntimeError(
f"Reading {AGENT_RECURSION_PROPERTY} for {project_key} failed "
f"after {attempt} attempts: {reason}"
) from None
remaining = deadline - _monotonic()
if remaining <= 0:
raise RuntimeError(
f"Reading {AGENT_RECURSION_PROPERTY} for {project_key} exceeded the time budget"
) from None
delay = retry_after if retry_after is not None else READ_BACKOFF[attempt - 1]
if delay >= remaining:
raise RuntimeError(
f"Reading {AGENT_RECURSION_PROPERTY} for {project_key} exceeded the retry budget"
) from None
await _sleep(delay)
raise AssertionError("unreachable")


async def resolve_agent_recursion_limit_for_project(
settings: "Settings",
project_key: str | None,
*,
jira: "JiraClient | None" = None,
) -> int:
"""Resolve one host invocation's effective graph limit."""
owned_jira = None
if not project_key:
limit = settings.agent_recursion_limit
source = "global"
else:
if jira is None:
from forge.integrations.jira.client import JiraClient

owned_jira = JiraClient(settings=settings)
jira = owned_jira
try:
raw = await read_agent_recursion_property(jira, project_key)
override = validate_project_agent_recursion_limit(raw, project_key)
finally:
if owned_jira is not None:
await owned_jira.close()
limit = settings.agent_recursion_limit if override is None else override
source = "global" if override is None else "project"
logger.info("Host agent recursion limit=%s source=%s project=%s", limit, source, project_key)
return limit
Loading
Loading