Repository navigation
feat(agent): add run limits for spend, steps, and duration - #9329
waleedlatif1 wants to merge 1 commit into
Conversation
Adds a "Limit run" switch to the Agent block's advanced settings with max spend (USD), max steps, and max duration, and a choice of what happens when the agent still wants tools past a limit: return a final answer without tools (default) or fail the block. A new stopReason output reports whether a limit stopped the run. A step is one model response whose tool calls run. Limits are enforced at the two seams every provider already shares, so no provider tool loop changes: prepareConversationGeneration turns the agent's tools off in each protocol's own final-answer shape once a limit is reached, and executeProviderTool refuses tools past a limit. Calls that offer no agent tools (structured-output passes, Bedrock's forced structured_output tool) are never limited, so a run that answers within its limits is unaffected. One budget spans the selected model and its fallbacks; spend counts each attempt's own billed cost, excluding what it carried in from earlier attempts. Refusals are not journaled, so a retry with a fresh budget runs the call. Also renames isConversationContextError to isPermanentGenerationError, since it now covers the run-limit and usage-limit stops, and shares the billed user-stop construction between both stops.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
[Medium impact] The change affects Agent runs across the supported provider paths. Fix spend accounting for pending tools before merging. Findings
SummaryAdds optional Agent limits for spend, tool steps, and duration.
Review was based on code inspection; no tests or live provider calls were run.
|
| if (settledCost && !this.settledCosts.has(settledCost)) { | ||
| this.settledCosts.set(settledCost, settledCost.total) | ||
| } |
There was a problem hiding this comment.
Retried tools escape the budget
With memory enabled, a retry can execute pending paid tools before its first model call. continuePendingConversationCalls runs them, then session.getUsage() adds their new charges to settledCost. Here, beginModelCall saves that entire total as carriedIn, so spend() excludes those charges. A pending tool can exhaust the retry's budget, yet the model still gets tools and starts more paid work.
Record the attempt's starting cost before continuing pending tools, or track their new charges separately.
There was a problem hiding this comment.
3 issues found across 29 files
Confidence score: 3/5
- In
run-limits.ts, a continued Agent turn’s journaled model spend doesn’t consumemaxCost, so the turn can exceed its budget. Count that spend once, excluding carry-in costs. - In
conversation-generation.ts, compaction can spend model budget afterapplyRunLimitsruns, and the outer generation proceeds without another check. Recheck run and usage limits after compaction. - In
handler.ts, auto-routing charges are added after the run-limit check, so the agent can exceedmaxRunCost. IncludeautoRouting.billableRoutingCostin the run budget.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/providers/run-limits.ts">
<violation number="1" location="apps/sim/providers/run-limits.ts:128">
P2: `spend()` treats every preloaded `settledCost` as fallback carry-in, so a continued Agent turn's journaled model spend never consumes `maxCost`. Count the journaled model portion once while excluding only costs carried from earlier retry or fallback attempts.</violation>
</file>
<file name="apps/sim/providers/conversation-generation.ts">
<violation number="1" location="apps/sim/providers/conversation-generation.ts:298">
P2: `applyRunLimits` runs before conversation compaction, but compaction can spend model budget and the outer generation is not checked again afterward. Recheck the run and usage limits after compaction before sending the final payload, and propagate the shared run-limit state to the summary request.</violation>
</file>
<file name="apps/sim/executor/handlers/agent/handler.ts">
<violation number="1" location="apps/sim/executor/handlers/agent/handler.ts:524">
P2: Auto-model routing charges are added to `agent.cost` after the run-limit budget is enforced, so `maxRunCost` can be exceeded without stopping the agent. Include `autoRouting.billableRoutingCost` in the same run budget.</violation>
</file>
Heads up: you’re close to your flex budget. Increase your flex budget so reviews don’t pause.
Turn on auto-fix | Re-trigger cubic
| /** Spend since each attempt began; a fallback or retry carries in journaled cost already counted. */ | ||
| private spend(): number { | ||
| let total = 0 | ||
| for (const [cost, carriedIn] of this.settledCosts) total += cost.total - carriedIn |
There was a problem hiding this comment.
P2: spend() treats every preloaded settledCost as fallback carry-in, so a continued Agent turn's journaled model spend never consumes maxCost. Count the journaled model portion once while excluding only costs carried from earlier retry or fallback attempts.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/providers/run-limits.ts, line 128:
<comment>`spend()` treats every preloaded `settledCost` as fallback carry-in, so a continued Agent turn's journaled model spend never consumes `maxCost`. Count the journaled model portion once while excluding only costs carried from earlier retry or fallback attempts.</comment>
<file context>
@@ -0,0 +1,202 @@
+ /** Spend since each attempt began; a fallback or retry carries in journaled cost already counted. */
+ private spend(): number {
+ let total = 0
+ for (const [cost, carriedIn] of this.settledCosts) total += cost.total - carriedIn
+ return total
+ }
</file context>
| ): Promise<T> { | ||
| const runtime = getConversationRequestContext(request) | ||
| await assertRunWithinUsageLimit(runtime) | ||
| const payload = applyRunLimits(runtime, request, protocol, requestedPayload) |
There was a problem hiding this comment.
P2: applyRunLimits runs before conversation compaction, but compaction can spend model budget and the outer generation is not checked again afterward. Recheck the run and usage limits after compaction before sending the final payload, and propagate the shared run-limit state to the summary request.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/providers/conversation-generation.ts, line 298:
<comment>`applyRunLimits` runs before conversation compaction, but compaction can spend model budget and the outer generation is not checked again afterward. Recheck the run and usage limits after compaction before sending the final payload, and propagate the shared run-limit state to the summary request.</comment>
<file context>
@@ -288,10 +291,11 @@ function nativeSummaryMessage(summary: Message, protocol: ConversationProtocol):
): Promise<T> {
const runtime = getConversationRequestContext(request)
await assertRunWithinUsageLimit(runtime)
+ const payload = applyRunLimits(runtime, request, protocol, requestedPayload)
if (!runtime?.agentConversation) return payload
request.abortSignal?.throwIfAborted()
</file context>
| } | ||
|
|
||
| const streamingConfig = this.getStreamingConfig(ctx, block) | ||
| const runLimits = this.createRunLimits(filteredInputs) |
There was a problem hiding this comment.
P2: Auto-model routing charges are added to agent.cost after the run-limit budget is enforced, so maxRunCost can be exceeded without stopping the agent. Include autoRouting.billableRoutingCost in the same run budget.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At apps/sim/executor/handlers/agent/handler.ts, line 524:
<comment>Auto-model routing charges are added to `agent.cost` after the run-limit budget is enforced, so `maxRunCost` can be exceeded without stopping the agent. Include `autoRouting.billableRoutingCost` in the same run budget.</comment>
<file context>
@@ -519,6 +521,7 @@ export class AgentBlockHandler implements BlockHandler {
}
const streamingConfig = this.getStreamingConfig(ctx, block)
+ const runLimits = this.createRunLimits(filteredInputs)
const agentConversation =
filteredInputs.memoryType &&
</file context>
Summary
Adds a Limit run switch to the Agent block (advanced) so long, multi-step tool-using agents can be bounded by max spend (USD), max steps, and max duration (seconds). When a limit is reached chooses between Return best answer (default: one more call with tools off) and Fail block. A new
stopReasonoutput (completed·spend_limit·step_limit·time_limit) appears when the switch is on.A step is one model response whose tool calls run. A limit only acts when the agent wants to keep using tools, so a run that answers within its limits is unaffected, and provider follow-up calls (structured-output passes, retries without reasoning summaries) never count.
How it works
No provider tool loop changes. Enforcement sits at the two seams every provider already shares:
prepareConversationGeneration(before every model call): once a limit is reached, the call is sent in that protocol's own existing final-answer shape. Chat-completions dropstools/tool_choice/parallel_tool_calls(Z.ai, Meta, Kimi rejecttool_choice: 'none'). Responses drops tools and setsnone. Anthropic sets{ type: 'none' }. Gemini drops tools. Bedrock relaxes toautoand leaves its own forcedstructured_outputtool alone.executeProviderTool(every tool call): counts steps and refuses tools past a limit. A provider that cannot refuse tools (Bedrock, Ollama) gets one more chance, then the run fails, so it always terminates.AgentRunLimitsper block execution spans the selected model and its fallbacks. Spend counts each attempt's own billed cost, excluding cost carried in from earlier attempts (journaled turns). Each Retry on fail try starts a fresh budget. Refusals are not journaled, so a retry runs the call.RunLimitErroris non-retryable: no fallback, no block retry, billed for what ran.Also renames
isConversationContextError→isPermanentGenerationError(it now covers the usage-limit and run-limit stops), and extractsbilledUserStopshared by both stops.Product update (if relevant)
Builders can cap what a single Agent run may spend, how many tool steps it takes, and how long it keeps working, and branch on
<agent.stopReason>. Off by default; existing workflows are unchanged. Docs: Agent block → Advanced settings → Limit run.Type of Change
Testing
providers/run-limits.test.ts(18 tests) was written failure-mode first through the real hook and tool seams. Every guard was mutation-checked: removing each of the 13 guards turns its test red.providers/**,executor/handlers/agent/**, the agent block, and the block executor.check:unused-exports,comment-hygiene,utils,source-conventions,test-patterns,file-names,boundaries,tool-registry-boundary, the other boundary checks, and the block-registry check all pass.type-checkand the full suite are left to CI.Reviewer focus:
providers/run-limits.ts(the step/stop state machine and the per-protocol tools-off shapes), and the refusal placement inproviders/runtime-context.ts.Known, by design:
<agent.cost>, so models Sim does not bill (BYOK, self-hosted) count only tool costs.Checklist
Screenshots/Videos