| description | Executor Step 2 guard with three ordered checks on the Context call_chain: max call depth, circular call detection (length >= 2), per-module frequency throttling vs runaway recursion. |
|---|
Type: Implementation guide. Normative spec: PROTOCOL_SPEC §5.7 Context Object (
call_chainfield), §9.1.1 (limits); algorithm A20. This page is the canonical explanation of the guard.
The Call Chain Guard is a safety mechanism that prevents runaway, circular, and abusive module call patterns. It is invoked at Step 2 of the execution pipeline — before module lookup — and performs three sequential checks: call depth limiting, circular call detection, and frequency throttling. These checks protect the system from unbounded recursion, tight-loop abuse, and stack overflow scenarios.
- Evaluate three safety checks in strict order: depth → circular → frequency.
- Reject calls that exceed the configured maximum nesting depth.
- Detect and reject circular call patterns of length ≥ 2 (e.g., A→B→A).
- Track and reject calls where a single module appears more than the configured maximum repeat count in the call chain. Because any repeat with another module in between is already a cycle, this check only ever fires for direct self-recursion (A→A→A→A).
- All three checks operate on the call chain recorded in the
Contextobject. - Both limits have defaults and are set through the Config Bus keys
executor.max_call_depthandexecutor.max_module_repeat(see Configuration). There is no per-call override.
| Constant | Default | Description |
|---|---|---|
DEFAULT_MAX_CALL_DEPTH |
32 | Maximum allowed call chain length |
DEFAULT_MAX_MODULE_REPEAT |
3 | Maximum times a module can appear in one call chain |
The guard_call_chain function performs three checks in order. If any check fails, the corresponding error is raised immediately and subsequent checks are skipped.
!!! note
The call_chain passed to this function already includes module_id at the end, as set by Context.child(). The guard validates the chain in its current state.
Step 0 — Limit floors:
If max_call_depth < 1 or max_module_repeat < 1, raise InvalidInputError with code GENERAL_INVALID_INPUT before inspecting the chain (D-84).
Step 1 — Depth Limit:
Check that len(call_chain) <= max_call_depth. If the chain exceeds the limit, raise CallDepthExceededError. The comparison is strict: a chain of exactly max_call_depth entries passes.
Step 2 — Circular Detection:
Extract the prior chain (everything except the last element). Find the last occurrence of module_id in the prior chain. If found and there are any entries after that occurrence (i.e., other modules were called in between), a cycle is detected. Raise CircularCallError with the offending module_id and the full call_chain.
A repeat with nothing in between is a direct self-call, not a cycle: [A, A] passes this step, [A, B, A] does not.
For example, given chain [A, B, C, A] (where the last A is the current call): the prior chain is [A, B, C], A is found at index 0 with subsequent entries [B, C], so a circular pattern A→B→C→A is detected.
Step 3 — Frequency Throttle:
Count occurrences of module_id in the full call chain. If count > max_module_repeat, raise CallFrequencyExceededError with module_id, count, and max_repeat. Since Step 2 already rejects any repeat separated by another module, the calls that reach this check with count > 1 are direct self-recursion; this step bounds how deep a module may recurse into itself.
=== "Python" ```python from apcore.utils.call_chain import guard_call_chain
def guard_call_chain(
module_id: str,
call_chain: list[str] | tuple[str, ...],
*,
max_call_depth: int = 32,
max_module_repeat: int = 3,
) -> None:
"""
Validate call chain safety (Algorithm A20).
Raises:
InvalidInputError: A limit is below 1 (GENERAL_INVALID_INPUT)
CallDepthExceededError: Chain exceeds max_call_depth
CircularCallError: Circular call pattern detected
CallFrequencyExceededError: Module appears too many times
"""
...
```
=== "TypeScript" ```typescript import { guardCallChain } from "apcore-js";
function guardCallChain(
moduleId: string,
callChain: readonly string[],
maxCallDepth: number = 32,
maxModuleRepeat: number = 3,
): void;
// Throws: InvalidInputError, CallDepthExceededError, CircularCallError, CallFrequencyExceededError
```
=== "Rust" ```rust use apcore::guard_call_chain;
pub fn guard_call_chain(
module_id: &str,
call_chain: &[String],
max_call_depth: usize, // default: 32
max_module_repeat: usize, // default: 3
) -> Result<(), ModuleError>;
```
`guard_call_chain_for_context(&ctx, module_id)` (defaults) and
`guard_call_chain_with_repeat(&ctx, module_id, max_depth, max_module_repeat)` are
thin wrappers that take a `Context<T>` of any services type; the executor's
`call_chain_guard` step uses the latter with the configured limits.
| Error | Code | details keys |
Description |
|---|---|---|---|
CallDepthExceededError |
CALL_DEPTH_EXCEEDED |
depth, max_depth, call_chain |
Chain exceeds maximum depth |
CircularCallError |
CIRCULAR_CALL |
module_id, call_chain |
Circular invocation detected |
CallFrequencyExceededError |
CALL_FREQUENCY_EXCEEDED |
module_id, count, max_repeat, call_chain |
Module called too many times |
InvalidInputError |
GENERAL_INVALID_INPUT |
— | max_call_depth or max_module_repeat is below 1 |
In Rust all four are a ModuleError carrying the corresponding ErrorCode.
Depth limit scenario:
Call chain: [mod.0, mod.1, ..., mod.32] (length 33, max_call_depth=32)
→ CallDepthExceededError (depth=33, max_depth=32)
Call chain: [mod.0, mod.1, ..., mod.31] (length 32, max_call_depth=32)
→ passes (the comparison is strict)
Circular detection scenario:
Call chain: [A, B, C, B] (B is the current call, added by Context.child())
Prior chain: [A, B, C]
B found at index 1, subsequent entries: [C]
→ CircularCallError (B→C→B cycle detected)
Call chain: [A, B, A, B, A, B] (B is the current call)
Prior chain: [A, B, A, B, A]; last B at index 3, subsequent entries: [A]
→ CircularCallError (the cycle is caught before frequency is ever counted)
Frequency throttle scenario (direct self-recursion):
Call chain: [A, B, B, B] (B is the current call; B calls itself)
Prior chain: [A, B, B]; last B at index 2, no entries after it → not circular
B appears 3 times, max_module_repeat = 3 → passes (≤ 3)
Call chain: [A, B, B, B, B] (B is the current call)
Not circular (same reasoning); B appears 4 times > 3
→ CallFrequencyExceededError (module_id=B, count=4, max_repeat=3)
Both limits are Config Bus keys. The executor's call_chain_guard step reads them from the Config it was built with and falls back to the defaults above:
# apcore.yaml
executor:
max_call_depth: 16
max_module_repeat: 2The same keys can be set programmatically before the client is built:
=== "Python" ```python from apcore import APCore, Config
config = Config.from_defaults()
config.set("executor.max_call_depth", 16)
config.set("executor.max_module_repeat", 2)
client = APCore(config=config)
```
=== "TypeScript" ```typescript import { APCore, Config } from "apcore-js";
const config = Config.fromDefaults();
config.set("executor.max_call_depth", 16);
config.set("executor.max_module_repeat", 2);
const client = new APCore({ config });
```
=== "Rust" ```rust use apcore::{APCore, Config}; use serde_json::json;
fn main() {
let mut config = Config::from_defaults();
config.set("executor.max_call_depth", json!(16));
config.set("executor.max_module_repeat", json!(2));
let _client = APCore::with_config(config);
}
```
An Executor constructed directly takes the same Config (Executor(registry, config=config), new Executor({ registry, config }), Executor::new(registry, config)). Valid ranges are 1..1000 for max_call_depth and 1..100 for max_module_repeat (protocol-spec §9.1.1).
The Call Chain Guard is invoked automatically at Step 2 of the Core Execution Engine pipeline. It reads the call_chain from the current Context and validates the target module against the configured limits.
Modules that perform nested calls (calling other modules within their execution) will naturally build up the call chain through Context.child(), which appends the target module ID to the chain.
- Context — Reads
call_chainfrom the execution context. - Error System — Raises
CallDepthExceededError,CircularCallError,CallFrequencyExceededError,InvalidInputError. - Config Bus — Supplies
executor.max_call_depthandexecutor.max_module_repeat.
??? info "Python SDK reference"
The following table is not a protocol requirement — it documents the Python SDK's source layout for implementers/users of apcore-python.
**Source files:**
| File | Purpose |
|------|---------|
| `src/apcore/utils/call_chain.py` | `guard_call_chain()`, constants |
- Depth tests verify rejection at exactly
max_call_depth + 1and acceptance atmax_call_depth. - Circular tests verify detection of 2-node cycles (A→B→A), 3-node cycles (A→B→C→A), and that direct self-calls (A→A) are not treated as cycles.
- Frequency tests verify, on self-recursive chains, rejection at exactly
max_module_repeat + 1and acceptance atmax_module_repeat. - Floor tests verify that a limit below 1 raises
GENERAL_INVALID_INPUT. - Order tests verify that depth is checked before circular, and circular before frequency (a chain that fails both depth and circular should raise
CallDepthExceededError). - Configuration tests verify that
executor.max_call_depth/executor.max_module_repeatset on theConfigoverride the defaults.
Cross-language cases live in conformance/fixtures/call_chain.json.
module_id(str/string/&str, required) — the module about to be calledcall_chain(sequence of str, required) — the current call chain; already includesmodule_idat the end, as set byContext.child()max_call_depth(int, optional, default=DEFAULT_MAX_CALL_DEPTH) — maximum allowed call-chain depthmax_module_repeat(int, optional, default=DEFAULT_MAX_MODULE_REPEAT) — maximum allowed repeat invocations of the same module in the current chain
InvalidInputError(code=GENERAL_INVALID_INPUT)—max_call_depthormax_module_repeatis less than 1 (checked first, D-84)CallDepthExceededError(code=CALL_DEPTH_EXCEEDED)—len(call_chain) > max_call_depthCircularCallError(code=CIRCULAR_CALL)—module_idoccurs earlier in the chain with at least one other module after that occurrence (a cycle of length ≥ 2); direct self-recursion is not circularCallFrequencyExceededError(code=CALL_FREQUENCY_EXCEEDED)—module_idoccurs more thanmax_module_repeattimes in the chain
- On success (guard passes): void/None/() — no return value; raises on violation
- async: false
- thread_safe: true
- pure: true (reads the
call_chainargument, does not mutate)