Skip to content

llmagent: discuss graceful finalization at call limits #2306

Description

@Rememorio

Proposal

Background

LLMAgent currently provides two invocation-scoped safety limits:

Limit Current behavior after limit
MaxLLMCalls = N Entering call N+1 emits stop_agent_error; BeforeModel and the model are not called
MaxToolIterations = N The N+1 assistant tool-call round emits flow_error; its tools are not executed

These errors are normally read asynchronously from the event channel after
Runner.Run has returned successfully. This is a reasonable hard-stop
fallback, but some applications may prefer a model-generated final answer that
summarizes the progress made before the limit.

Existing callbacks do not fully solve this:

  • the LLM call limit is checked before BeforeModel;
  • the tool iteration limit is checked before tool callbacks and execution;
  • AfterAgent may append a response, but cannot remove the error already
    emitted;
  • reactively continuing after a rejected tool-call round requires one matching
    tool result for every tool_call_id.

The current LLM counter is incremented before BeforeModel, so a callback
custom response or callback error consumes a call even when the provider is not
called. Provider-internal retries are not counted separately. This proposal
does not attempt to change that existing counting behavior.

Proposal

Discuss an opt-in graceful-finalization policy. The current error behavior
would remain the default.

The proposed runtime behavior is:

Tool limit:
  Nth tool round completes
    -> persist its real tool results
    -> make one final LLM call without tools
    -> end the invocation

LLM call limit:
  the Nth and last allowed LLM call
    -> remove tools and ask for a final answer
    -> end the invocation

The final call is included in MaxLLMCalls; it is not a free extra call.
Finalization is consumed at most once. If the model still returns tool calls
without receiving tool declarations, the invocation stops instead of entering
another recovery loop.

There are several possible API shapes.

Option A: one static instruction option

agent := llmagent.New(
	"assistant",
	llmagent.WithMaxToolIterations(5),
	llmagent.WithMaxLLMCalls(6),
	llmagent.WithCallLimitFinalization(
		"Do not call tools. Use the available information to answer.",
	),
)

Possible declaration:

func WithCallLimitFinalization(instruction string) Option

An empty instruction could select a framework default.

Advantages:

  • one new public symbol;
  • easy to understand and document;
  • enough for applications that configure one agent per language or scenario.

Trade-offs:

  • the instruction cannot vary per invocation;
  • adding a resolver later would introduce a second, partially overlapping
    option.

Option B: use an instruction resolver

type CallLimitReason string

const (
	CallLimitReasonLLMCalls       CallLimitReason = "llm_calls"
	CallLimitReasonToolIterations CallLimitReason = "tool_iterations"
)

type CallLimitFinalizationArgs struct {
	Invocation *agent.Invocation
	Reason     CallLimitReason
	Limit      int
}

type CallLimitFinalizationResolver func(
	ctx context.Context,
	args *CallLimitFinalizationArgs,
) (string, error)

func WithCallLimitFinalizationResolver(
	resolver CallLimitFinalizationResolver,
) Option

Example:

llmagent.WithCallLimitFinalizationResolver(
	func(
		ctx context.Context,
		args *llmagent.CallLimitFinalizationArgs,
	) (string, error) {
		return instructionForInvocation(args.Invocation, args.Reason), nil
	},
)

Advantages:

  • supports localization and invocation-specific instructions;
  • exposes whether finalization was caused by the LLM or tool limit;
  • avoids another API change if dynamic instructions are already expected.

Trade-offs:

  • adds several public types for a relatively small capability;
  • requires contracts for nil resolvers, empty strings, resolver errors, and
    panics;
  • most users may only return a constant string.

If selected, a resolver error should probably fall back to the existing
terminal limit error rather than retrying or silently using another prompt.

Option C: one configuration object

type CallLimitFinalizationConfig struct {
	Instruction             string
	Resolver                CallLimitFinalizationResolver
	FinalizeOnLLMCalls      bool
	FinalizeOnToolIteration bool
}

func WithCallLimitFinalization(
	config CallLimitFinalizationConfig,
) Option

Advantages:

  • one entry point can support static or dynamic instructions;
  • LLM and tool limit behavior can be enabled independently;
  • leaves room for future policy fields.

Trade-offs:

  • larger API and more invalid combinations;
  • may be premature before independent policies are required;
  • zero-value and precedence semantics must be specified.

Option D: reactive tool-limit recovery

Instead of preventing the rejected N+1 tool-call turn, add a handler that
returns a synthetic tool result:

llmagent.WithToolIterationLimitRecovery(
	func(ctx context.Context, args *ToolLimitArgs) (string, error) {
		return "The tool-call limit has been reached.", nil
	},
)

The framework would create one tool result for every rejected tool_call_id,
persist those results, and then make one tool-free finalization call.

Advantages:

  • the model explicitly sees that its requested tools were rejected;
  • preserves the semantic of allowing N full tool rounds before reacting.

Trade-offs:

  • requires protocol rules for parallel and deferred tools;
  • needs persistence ordering, callback, tracing, and error contracts;
  • consumes one rejected LLM turn plus the finalization call;
  • needs an explicit recursion guard.

This is a different and more complex contract than proactive finalization. It
could be added later if there is a concrete need for model-visible synthetic
tool results.

Budget interaction

MaxLLMCalls should remain the outer hard budget:

MaxToolIterations = 5
MaxLLMCalls = 6

allows five tool rounds and one finalization call.

If both limits are 5, the fifth LLM call must be used for finalization, so at
most four tool rounds can complete. Making an uncounted sixth call would weaken
the contract of MaxLLMCalls.

Suggested initial direction

Option A is the smallest useful API if a static instruction is sufficient.
Option B is preferable only if per-invocation localization or context is a
known requirement. Option C appears premature, and Option D should remain a
follow-up unless synthetic tool results are required from the beginning.

Regardless of API shape:

  • existing behavior remains unchanged unless explicitly enabled;
  • tools are removed after request processors and BeforeModel;
  • request-level forced tool-selection fields are cleared on a best-effort
    basis;
  • a successful finalization produces a normal final response, not a limit
    error;
  • finalization provider/callback failures use the existing error path.

Goals

  • Provide an opt-in model-generated final answer at configured call limits.
  • Keep MaxLLMCalls a strict upper bound.
  • Avoid dangling or rejected tool-call turns in the proactive path.
  • Keep finalization bounded to one call.
  • Preserve existing defaults and event behavior.
  • Select the smallest public API that supports known requirements.

Non-Goals

  • Change the existing counter semantics.
  • Count provider-internal retries separately.
  • Unify flow_error and stop_agent_error.
  • Exempt finalization from MaxLLMCalls.
  • Design a provider-neutral public tool_choice API.

Open Questions

  1. Is a static instruction sufficient, or is a per-invocation resolver needed
    in the first API?
  2. Should one option apply to both limits, or should LLM/tool finalization be
    independently configurable?
  3. Is it acceptable for the last allowed LLM call to take priority when there
    is not enough budget for both the maximum tool rounds and finalization?
  4. Should an empty static instruction use a framework default?
  5. If a resolver is supported, should an empty result use the default prompt
    and should an error fall back to the existing terminal error?
  6. Is proactive finalization sufficient, or is model-visible reactive tool
    recovery required initially?
中文版本(点击展开)

背景

LLMAgent 当前提供两个 invocation 级安全限制:

限制 当前超限行为
MaxLLMCalls = N 进入第 N+1 次调用时发出 stop_agent_error,不会运行 BeforeModel 或调用模型
MaxToolIterations = N N+1 轮 assistant tool calls 发出 flow_error,不会执行工具

这些错误通常是在 Runner.Run 已经成功返回 channel 后,异步从事件流中读取到的。hard stop
作为默认兜底是合理的,但有些应用可能希望模型基于已有进展生成一条最终回答。

现有 callback 不能完整解决:

  • LLM call limit 在 BeforeModel 之前检查;
  • tool iteration limit 在 tool callback 和真实执行之前检查;
  • AfterAgent 可以追加 response,但不能删除已经发出的 error;
  • 如果在被拒绝的 tool-call round 后继续模型循环,必须为每个 tool_call_id 补齐结果。

当前 LLM 计数发生在 BeforeModel 之前,因此 callback custom response 或 error 也会消耗计数,
即使 provider 没有被调用;provider 内部 retry 不单独计数。本提案不改变这一现有语义。

提案

讨论增加一个 opt-in 的 graceful finalization 策略,默认错误行为保持不变:

Tool limit:
  第 N 轮工具完成
    -> 持久化真实结果
    -> 进行一次关闭 tools 的最终 LLM 调用
    -> 结束 invocation

LLM call limit:
  第 N 次也是最后一次合法 LLM 调用
    -> 关闭 tools,要求模型直接回答
    -> 结束 invocation

最终调用正常计入 MaxLLMCalls,不是免费的额外调用;finalization 最多消费一次。如果模型在没有
tool declarations 的情况下仍返回 tool calls,直接终止,不再进入 recovery。

目前可以考虑以下 API 方案。

方案 A:一个静态 instruction Option

agent := llmagent.New(
	"assistant",
	llmagent.WithMaxToolIterations(5),
	llmagent.WithMaxLLMCalls(6),
	llmagent.WithCallLimitFinalization(
		"Do not call tools. Use the available information to answer.",
	),
)
func WithCallLimitFinalization(instruction string) Option

空字符串可以表示使用框架默认 instruction。

优点:

  • 只增加一个公共符号;
  • 容易理解和写文档;
  • 如果每种语言或场景本来就会创建不同 agent,已经够用。

缺点:

  • 不能按 invocation 动态生成;
  • 后续增加 resolver 会出现第二个部分重叠的 Option。

方案 B:使用 instruction resolver

type CallLimitFinalizationArgs struct {
	Invocation *agent.Invocation
	Reason     CallLimitReason
	Limit      int
}

type CallLimitFinalizationResolver func(
	ctx context.Context,
	args *CallLimitFinalizationArgs,
) (string, error)

func WithCallLimitFinalizationResolver(
	resolver CallLimitFinalizationResolver,
) Option

优点:

  • 支持本地化和 invocation 级动态 instruction;
  • resolver 能知道是 LLM 还是 tool limit 触发;
  • 如果动态能力是明确需求,可以避免后续再次扩 API。

缺点:

  • 为较小能力增加多个公共类型;
  • 需要定义 nil、空字符串、error 和 panic 契约;
  • 大部分用户可能只是返回常量。

如果采用 resolver,resolver error 建议回退到现有 terminal limit error,不做 retry,也不静默换
prompt。

方案 C:统一配置对象

type CallLimitFinalizationConfig struct {
	Instruction             string
	Resolver                CallLimitFinalizationResolver
	FinalizeOnLLMCalls      bool
	FinalizeOnToolIteration bool
}

func WithCallLimitFinalization(
	config CallLimitFinalizationConfig,
) Option

优点:

  • 一个入口同时支持静态和动态 instruction;
  • 可以分别控制 LLM/tool limit;
  • 便于未来扩展策略字段。

缺点:

  • API 更大、非法组合更多;
  • 在确认需要独立策略前可能过早;
  • 必须定义零值和字段优先级。

方案 D:reactive tool-limit recovery

llmagent.WithToolIterationLimitRecovery(
	func(ctx context.Context, args *ToolLimitArgs) (string, error) {
		return "The tool-call limit has been reached.", nil
	},
)

模型发出第 N+1 轮 tool calls 后,框架为每个 tool_call_id 生成 synthetic result,等待
持久化,再进行一次无工具 finalization。

优点:

  • 模型能明确看到工具请求被拒绝;
  • 保留“先允许完整 N 轮,再处理超限”的语义。

缺点:

  • 需要定义并行工具、deferred tools、持久化顺序、callback、trace 和 error 契约;
  • 会多消耗一次被拒绝的 LLM turn 和一次 finalization;
  • 必须防止递归 recovery。

这和 proactive finalization 是不同且更复杂的契约。如果没有明确要求模型必须看到 synthetic tool
result,可以以后再加。

预算关系

MaxLLMCalls 应保持为外层硬预算:

MaxToolIterations = 5
MaxLLMCalls = 6

可以完成五轮工具和一次 finalization。

如果两个限制都是 5,第五次 LLM 调用必须用于 finalization,因此最多完成四轮工具。不能免费
增加第六次调用,否则会削弱 MaxLLMCalls 的契约。

当前建议

如果静态 instruction 足够,方案 A 是最小且实用的 API。只有在 invocation 级本地化或动态上下文
是明确需求时,才优先考虑方案 B。方案 C 目前偏早,方案 D 建议留到确实需要 synthetic tool
results 时再做。

无论选哪种 API:

  • 未显式启用时,现有行为不变;
  • 在 request processor 和 BeforeModel 后移除 tools;
  • best-effort 清理 request-level forced tool selection;
  • finalization 成功时产生普通最终回答,不产生 limit error;
  • provider/callback 失败沿用现有 error 路径。

目标

  • 达到限制时可 opt-in 生成最终回答;
  • MaxLLMCalls 继续作为严格上限;
  • proactive 路径不产生 dangling/rejected tool-call turn;
  • finalization 最多调用一次;
  • 保持默认行为兼容;
  • 根据已知需求选择最小公共 API。

非目标

  • 修改现有计数语义;
  • 单独计算 provider 内部 retry;
  • 统一 flow_errorstop_agent_error
  • 让 finalization 豁免 MaxLLMCalls
  • 设计公共 provider-neutral tool_choice API。

开放问题

  1. 首版使用静态 instruction 是否足够,还是必须支持 per-invocation resolver?
  2. 一个 Option 应同时作用于两个限制,还是分别控制 LLM/tool finalization?
  3. 当预算不足时,最后一次 LLM 调用优先用于收尾是否可以接受?
  4. 空静态 instruction 是否应使用框架默认值?
  5. 如果支持 resolver,空结果是否使用默认值、error 是否回退到现有 terminal error?
  6. proactive finalization 是否足够,还是首版就需要模型可见的 reactive tool recovery?

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions