Skip to content
MANTYX.IO

Run guards

Long-running agent loops occasionally get stuck — the model keeps re-issuing the same (toolName, args) batch, or burns its turn budget hammering one expensive tool. MANTYX ships two opt-in run guards that intervene before either failure mode runs the run into the ground:

  • Loop detection — fingerprints every assistant turn that emits tool calls, soft-nudges the model to pivot once it repeats itself, and forces a clean tools-disabled finalise turn if it keeps looping.
  • Tool budgets — per-tool call caps enforced over the lifetime of the run; calls past the cap are intercepted before execution and replaced with a synthetic “budget exceeded — pivot or finalize” tool result.
  • Supervisor — an optional platform LLM judge that periodically reviews the transcript and may inject a steering message or force a tools-disabled finalise turn.

Both loop detection and tool budgets have runtime defaults that always apply (so SDK-driven runs and platform-driven runs behave identically). The supervisor is enabled by default on ephemeral API runs; pass supervisor: false to opt out. You only ever need to touch these when you want to tune thresholds, attach a budget to a custom tool, or change supervisor behaviour.

The pipeline tracks a canonical order-invariant (toolName, args) signature for every assistant turn that emits one or more tool calls. When the same signature repeats consecutively the guard fires.

Threshold Default What happens
consecutiveThreshold 3 The pipeline skips the duplicate batch with a synthetic “you’ve made this exact call before” tool result and prepends a user-style steering nudge (“either deliver a final answer or change strategy”). The model gets the nudge before its next turn and either finalises or pivots.
hardCutoffThreshold 6 The pipeline forces a tools-disabled finalise turn so the run lands cleanly instead of churning forever.

hardCutoffThreshold must be strictly greater than consecutiveThreshold so the soft nudge always gets a chance to land.

// TypeScript
import { MantyxClient } from "@mantyx/sdk";
const result = await client.runAgent({
systemPrompt: "...",
prompt: "...",
loopDetection: {
consecutiveThreshold: 2, // nudge after 2 identical batches
hardCutoffThreshold: 4, // force finalise after 4
},
});
# Python
result = client.run_agent(
system_prompt="...",
prompt="...",
loop_detection={
"consecutiveThreshold": 2,
"hardCutoffThreshold": 4,
},
)
// Go
result, err := client.RunAgent(ctx, mantyx.RunSpec{
SystemPrompt: "...",
Prompt: "...",
LoopDetection: mantyx.LoopDetectionThresholds(2, 4),
})

Pass the literal false (TypeScript / Python) or the LoopDetectionDisabled() sentinel (Go):

await client.runAgent({
systemPrompt: "...",
prompt: "Iterate freely until you converge.",
loopDetection: false, // opt out for this run only
});
client.run_agent(
system_prompt="...",
prompt="Iterate freely until you converge.",
loop_detection=False,
)
client.RunAgent(ctx, mantyx.RunSpec{
SystemPrompt: "...",
Prompt: "Iterate freely until you converge.",
LoopDetection: mantyx.LoopDetectionDisabled(),
})

toolBudgets caps how many times each tool may execute over the lifetime of the run (across every LLM turn). Calls under the cap run normally; calls past the cap are intercepted before execution and the model receives a synthetic “budget exceeded — pivot or finalize” tool result.

Budgets are per-tool, not pooled: hive_search_deals: { maxCalls: 5 } and hive_search_meetings: { maxCalls: 5 } give the agent five of each, not five between them. maxCalls: 0 disables the tool entirely (every attempt returns the synthetic body on the first try) — useful for ad-hoc denylists without rebuilding the agent’s tool surface.

When toolBudgets is omitted MANTYX layers its runtime defaults on top of the spec:

Tool Default maxCalls
recall (workspace memory hybrid search) 4
traverse (memory graph BFS) 3
hive_consult_ontology (per-hive ontology read) 4
hive_search_deals / _meetings / _companies / _people (Sales Hive general search) 5
hive_search_tickets / _conversations / _accounts (Customer Hive general search) 5
hive_search_releases / _issues (Product Hive general search) 5

hive_list_* and hive_get_* are intentionally not capped — agents legitimately call them once per entity-of-interest, which can easily exceed any small cap during normal multi-entity reads. The loop-detection guard catches the pathological “same (name, args) batch over and over” case for that family without needing per-tool caps.

Caller overrides layer on top of the runtime defaults; when both specify a budget for the same tool, the caller’s value wins.

await client.runAgent({
systemPrompt: "...",
prompt: "...",
toolBudgets: {
recall: { maxCalls: 8 }, // raise the default
expensive_api: { maxCalls: 2 }, // cap a custom tool
scary_tool: { maxCalls: 0 }, // disable a tool for this run
},
});
client.run_agent(
system_prompt="...",
prompt="...",
tool_budgets={
"recall": {"maxCalls": 8},
"expensive_api": {"maxCalls": 2},
"scary_tool": {"maxCalls": 0},
},
)
client.RunAgent(ctx, mantyx.RunSpec{
SystemPrompt: "...",
Prompt: "...",
ToolBudgets: mantyx.ToolBudgets{
"recall": {MaxCalls: 8},
"expensive_api": {MaxCalls: 2},
"scary_tool": {MaxCalls: 0},
},
})

Pass an empty (but non-nil) toolBudgets object to start from a clean slate — useful for runs that intentionally want unbounded research:

await client.runAgent({
systemPrompt: "...",
prompt: "Do a deep dive on this customer.",
toolBudgets: {}, // no defaults; no caps
});
client.run_agent(
system_prompt="...",
prompt="Do a deep dive on this customer.",
tool_budgets={},
)
client.RunAgent(ctx, mantyx.RunSpec{
SystemPrompt: "...",
Prompt: "Do a deep dive on this customer.",
ToolBudgets: mantyx.ToolBudgets{}, // empty (non-nil) map
})

The optional run supervisor is a platform LLM judge that periodically reviews the agent’s transcript (reasoning, tool calls, tool results, visible text) and may steer the run:

Verdict What happens
on_track No-op — the run continues.
redirect A steering user message is injected; tools stay available on the next turn.
finalize The next turn is forced tools-disabled so the run lands a clean final answer.

Reviews fire on two triggers:

  • Cadence — every interval LLM calls (default 5 when enabled) at tool-round boundaries (phase: "turn_boundary").
  • Mid-turn reasoning — while a single turn is still streaming reasoning, once the span crosses 3000 characters or 30s (whichever first), a phase: "reasoning" review runs. A redirect / finalize verdict aborts the in-flight turn. Enabled by default; tune or disable with reasoningTrigger.
await client.runAgent({
systemPrompt: "...",
prompt: "...",
supervisor: {
interval: 10,
reasoningTrigger: { chars: 5000, ms: 60000 },
},
});
// only review at tool-round boundaries:
await client.runAgent({
systemPrompt: "...",
prompt: "...",
supervisor: { interval: 10, reasoningTrigger: false },
});
// opt out for this run:
await client.runAgent({ systemPrompt: "...", prompt: "...", supervisor: false });
client.run_agent(
system_prompt="...",
prompt="...",
supervisor={"interval": 10, "reasoningTrigger": {"chars": 5000, "ms": 60000}},
)
client.run_agent(
system_prompt="...",
prompt="...",
supervisor={"interval": 10, "reasoningTrigger": False},
)
client.run_agent(system_prompt="...", prompt="...", supervisor=False)
client.RunAgent(ctx, mantyx.RunSpec{
SystemPrompt: "...",
Prompt: "...",
Supervisor: mantyx.SupervisorInterval(10).DisableReasoningTrigger(),
})
// custom mid-turn trigger:
client.RunAgent(ctx, mantyx.RunSpec{
SystemPrompt: "...",
Prompt: "...",
Supervisor: &mantyx.Supervisor{
Interval: 10,
ReasoningTrigger: &mantyx.ReasoningTrigger{Chars: 5000, Ms: 60000},
},
})
// opt out:
client.RunAgent(ctx, mantyx.RunSpec{..., Supervisor: mantyx.SupervisorDisabled()})

For session-scoped runs the inheritance rules are the same for all three fields:

  • client.createSession({ loopDetection, toolBudgets, supervisor }) (TS) / client.create_session(loop_detection=..., tool_budgets=..., supervisor=...) (Python) / mantyx.SessionSpec{LoopDetection: ..., ToolBudgets: ..., Supervisor: ...} (Go) — sets the session-default applied to every subsequent message run.
  • session.send(prompt, { loopDetection, toolBudgets, supervisor }) (TS) / session.send(prompt, loop_detection=..., tool_budgets=..., supervisor=...) (Python) / session.Send(ctx, prompt, mantyx.WithLoopDetection(...), mantyx.WithToolBudgets(...), mantyx.WithSupervisor(...)) (Go) — optional per-message override; applies to that one run only and does not mutate the session’s stored value.

All three fields are additive: omitting them keeps MANTYX’s runtime defaults; passing the disable sentinel (loopDetection: false or supervisor: false) opts out; passing entries layers caller overrides on top of the defaults.

Every intervention emits a dedicated SSE event so the SDK can render status notes. The synthetic skip + steering nudge / tool-result already ride the normal tool_result and assistant_delta channels — you don’t need to act on these events for the agent loop to keep running. Treat them as observability surface.

// loop-detection guard fired
{
"seq": 9,
"type": "loop_detected",
"data": {
"consecutiveCount": 3, // length of the identical-batch streak
"hardCutoff": false, // false = soft nudge round; true = forced finalise
"tools": ["recall"] // names of the tool calls in the looping batch
}
}
// per-tool budget exceeded
{
"seq": 10,
"type": "tool_budget_exceeded",
"data": {
"tool": "recall", // logical tool name
"maxCalls": 4, // configured cap
"callIndex": 5 // which call (1-indexed) tripped the cap
}
}
// run-supervisor review (fired on every check — on_track included)
{
"seq": 11,
"type": "supervisor",
"data": {
"action": "redirect", // on_track | redirect | finalize
"phase": "turn_boundary", // or "reasoning" for mid-turn reviews
"reason": "Stuck re-querying.",
"redirect": "Answer from the data you already have.", // present when action=redirect
"llmCalls": 10
}
}

A single run may emit any number of these events: zero (well-behaved agents), one or more tool_budget_exceeded events as the model keeps reaching for capped tools, a loop_detected (hardCutoff: false) followed by a second loop_detected (hardCutoff: true) if the model keeps looping past the soft nudge, or periodic supervisor events as the judge reviews the transcript.

await client.runAgent({
systemPrompt: "...",
prompt: "...",
onEvent: (ev) => {
if (ev.type === "loop_detected") {
console.warn(`looping on ${ev.tools.join(", ")}${ev.consecutiveCount})`);
} else if (ev.type === "tool_budget_exceeded") {
console.warn(`tool ${ev.tool} hit cap ${ev.maxCalls} on call #${ev.callIndex}`);
} else if (ev.type === "supervisor") {
const phase = ev.phase ?? "turn_boundary";
console.warn(`supervisor [${phase}] ${ev.action}: ${ev.reason}`);
}
},
});
Constraint Limit
loopDetection.consecutiveThreshold 2 ≤ n ≤ 100
loopDetection.hardCutoffThreshold 3 ≤ n ≤ 100, must be > consecutiveThreshold
toolBudgets max entries 32
toolBudgets[<name>] key length 1..120 chars
toolBudgets[<name>].maxCalls 0 ≤ n ≤ 1000 (functionally unlimited; maxToolTurns: 100 fires first)
supervisor.interval 1 ≤ n ≤ 100 (default 5 when enabled and omitted)
supervisor.reasoningTrigger.chars 1 ≤ n ≤ 50000 (default 3000 when enabled and omitted)
supervisor.reasoningTrigger.ms 1 ≤ n ≤ 600000 (default 30000 when enabled and omitted)

The reference SDKs mirror these checks locally so callers see an early typed error rather than a server round-trip.

  • Streaming — the full SSE event vocabulary, including the loop_detected, tool_budget_exceeded, and supervisor observability events.
  • Wire protocol §8 — canonical spec for the wire shapes (with subsections 8.1 loopDetection and 8.2 toolBudgets).
  • Task planning — opt-in plan field and task_plan SSE events (complements supervisor reviews during planned runs).
  • Agent-runs protocol §4.6 and §4.7 — server-side validation contract and inheritance rules for sessions.