Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions autogpt_platform/backend/backend/copilot/baseline/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,15 @@
# Set to hold background tasks to prevent garbage collection
_background_tasks: set[asyncio.Task[Any]] = set()

# Maximum number of tool-call rounds before forcing a text response.
_MAX_TOOL_ROUNDS = 30
# Hint appended on the last tool round so the model wraps up with a summary
# instead of issuing another tool call that gets cut off cold. The shared
# ``tool_call_loop`` drops ``tools`` on the last iteration (see util/tool_call_loop.py),
# so the model is forced to produce text and always finishes naturally.
_LAST_ITERATION_HINT = (
"You have reached the tool-call budget for this turn. Do not call any "
"more tools — produce a final text response summarizing what you did, "
"what remains, and how the user can continue the work in the next turn."
)

# Max seconds to wait for transcript upload in the finally block before
# letting it continue as a background task (tracked in _background_tasks).
Expand Down Expand Up @@ -1744,13 +1751,15 @@ async def _run_tool_call_loop() -> None:
# but the holder is typed non-optional after the preflight guard
# above.
try:
max_tool_rounds = config.agent_max_turns
async for loop_result in tool_call_loop(
messages=openai_messages,
tools=tools,
llm_call=_bound_llm_caller,
execute_tool=_bound_tool_executor,
update_conversation=_bound_conversation_updater,
max_iterations=_MAX_TOOL_ROUNDS,
max_iterations=max_tool_rounds,
last_iteration_message=_LAST_ITERATION_HINT,
):
loop_result_holder[0] = loop_result
# Inject any messages the user queued while the turn was
Expand All @@ -1771,7 +1780,7 @@ async def _run_tool_call_loop() -> None:
# get picked up at the start of the next turn.
is_final_yield = (
loop_result.finished_naturally
or loop_result.iterations >= _MAX_TOOL_ROUNDS
or loop_result.iterations >= max_tool_rounds
)
if is_final_yield:
continue
Expand Down Expand Up @@ -1894,14 +1903,16 @@ def _trim_openai_on_rollback(_session_anchor: int) -> None:
await loop_task
loop_result = loop_result_holder[0]
if loop_result and not loop_result.finished_naturally:
limit_msg = (
f"Exceeded {_MAX_TOOL_ROUNDS} tool-call rounds "
"without a final response."
)
logger.error("[Baseline] %s", limit_msg)
yield StreamError(
errorText=limit_msg,
code="baseline_tool_round_limit",
# Budget reached without a natural finish. ``tool_call_loop``
# drops ``tools`` on the last iteration so the model is forced
# to produce text, but a non-compliant model (or one whose final
# text-only response still got truncated) can still land here.
# End the turn gracefully — the user already saw streamed output
# and a red error would just prompt them to retry the same work.
logger.warning(
"[Baseline] Hit %d-round tool budget without natural finish; "
"ending turn gracefully",
loop_result.iterations,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
except Exception as e:
_stream_error = True
Expand Down
16 changes: 10 additions & 6 deletions autogpt_platform/backend/backend/copilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,18 @@ class ChatConfig(BaseSettings):
"overloaded). The SDK automatically retries with this cheaper model. "
"Empty string disables the fallback (no --fallback-model flag passed to CLI).",
)
claude_agent_max_turns: int = Field(
default=50,
agent_max_turns: int = Field(
default=100,
ge=1,
le=10000,
description="Maximum number of agentic turns (tool-use loops) per query. "
"Prevents runaway tool loops from burning budget. "
"Changed from 1000 to 50 in SDK 0.1.58 upgrade — override via "
"CHAT_CLAUDE_AGENT_MAX_TURNS env var if your workflows need more.",
validation_alias=AliasChoices(
"CHAT_AGENT_MAX_TURNS",
"CHAT_CLAUDE_AGENT_MAX_TURNS",
),
description="Maximum number of tool-call rounds per turn — applies to "
"both the baseline and Claude Agent SDK paths. Prevents runaway tool "
"loops from burning budget. Override via CHAT_AGENT_MAX_TURNS env var "
"(legacy CHAT_CLAUDE_AGENT_MAX_TURNS still accepted).",
)
claude_agent_max_budget_usd: float = Field(
default=10.0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def test_fallback_model_default(self):

def test_max_turns_default(self):
cfg = _make_config()
assert cfg.claude_agent_max_turns == 50
assert cfg.agent_max_turns == 100

def test_max_budget_usd_default(self):
cfg = _make_config()
Expand Down Expand Up @@ -506,21 +506,21 @@ class TestConfigValidators:

def test_max_turns_rejects_zero(self):
with pytest.raises(ValidationError):
_make_config(claude_agent_max_turns=0)
_make_config(agent_max_turns=0)

def test_max_turns_rejects_negative(self):
with pytest.raises(ValidationError):
_make_config(claude_agent_max_turns=-1)
_make_config(agent_max_turns=-1)

def test_max_turns_rejects_above_10000(self):
with pytest.raises(ValidationError):
_make_config(claude_agent_max_turns=10001)
_make_config(agent_max_turns=10001)

def test_max_turns_accepts_boundary_values(self):
cfg_low = _make_config(claude_agent_max_turns=1)
assert cfg_low.claude_agent_max_turns == 1
cfg_high = _make_config(claude_agent_max_turns=10000)
assert cfg_high.claude_agent_max_turns == 10000
cfg_low = _make_config(agent_max_turns=1)
assert cfg_low.agent_max_turns == 1
cfg_high = _make_config(agent_max_turns=10000)
assert cfg_high.agent_max_turns == 10000

def test_max_budget_rejects_zero(self):
with pytest.raises(ValidationError):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1034,7 +1034,7 @@ def _make_sdk_patches(
active_e2b_api_key=None,
use_e2b_sandbox=False,
claude_agent_max_transient_retries=1,
claude_agent_max_turns=1000,
agent_max_turns=1000,
claude_agent_max_budget_usd=100.0,
claude_agent_max_thinking_tokens=0,
claude_agent_thinking_effort=None,
Expand Down
2 changes: 1 addition & 1 deletion autogpt_platform/backend/backend/copilot/sdk/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3321,7 +3321,7 @@ def _on_stderr(line: str) -> None:
"fallback_model": _resolve_fallback_model(),
# max_turns: hard cap on agentic tool-use loops per query to
# prevent runaway execution from burning budget.
"max_turns": config.claude_agent_max_turns,
"max_turns": config.agent_max_turns,
# max_budget_usd: per-query spend ceiling enforced by the CLI.
"max_budget_usd": config.claude_agent_max_budget_usd,
}
Expand Down
11 changes: 7 additions & 4 deletions autogpt_platform/backend/backend/util/tool_call_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,10 @@ async def tool_call_loop(
while max_iterations < 0 or iteration < max_iterations:
iteration += 1

# On last iteration, add a hint to finish. Only copy the list
# when the hint needs to be appended to avoid per-iteration overhead
# on long conversations.
# On last iteration, add a hint to finish and drop tools so the
# model is forced to produce a final text response instead of
# issuing another tool call that would get cut off cold. Only
# copy the message list when we actually need to mutate it.
is_last = (
last_iteration_message
and max_iterations > 0
Expand All @@ -216,11 +217,13 @@ async def tool_call_loop(
iteration_messages.append(
{"role": "system", "content": last_iteration_message}
)
iteration_tools: Sequence[Any] = []
else:
iteration_messages = messages
iteration_tools = tools

# Call LLM
response = await llm_call(iteration_messages, tools)
response = await llm_call(iteration_messages, iteration_tools)
total_prompt_tokens += response.prompt_tokens
total_completion_tokens += response.completion_tokens

Expand Down
12 changes: 9 additions & 3 deletions autogpt_platform/backend/backend/util/tool_call_loop_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,13 +415,16 @@ def update_conversation(

@pytest.mark.asyncio
async def test_last_iteration_message_appended():
"""On the final iteration, last_iteration_message should be appended."""
"""On the final iteration, last_iteration_message should be appended
and ``tools`` should be empty so the model is forced to produce text."""
captured_messages: list[list[dict[str, Any]]] = []
captured_tools: list[Sequence[Any]] = []

async def llm_call(
messages: list[dict[str, Any]], tools: Sequence[Any]
) -> LLMLoopResponse:
captured_messages.append(list(messages))
captured_tools.append(tools)
return _make_response(
tool_calls=[LLMToolCall(id="tc_1", name="get_weather", arguments="{}")]
)
Expand Down Expand Up @@ -452,14 +455,17 @@ def update_conversation(
):
pass

# First iteration: no extra message
# First iteration: no extra message, tools available
assert len(captured_messages[0]) == 1
# Second (last) iteration: should have the hint appended
assert list(captured_tools[0]) == list(TOOL_DEFS)
# Second (last) iteration: hint appended, tools dropped (forces the
# model to produce a text response instead of another tool call).
last_call_msgs = captured_messages[1]
assert any(
m.get("role") == "system" and "Please finish now." in m.get("content", "")
for m in last_call_msgs
)
assert list(captured_tools[1]) == []


@pytest.mark.asyncio
Expand Down
Loading