Skip to content

Commit 472064c

Browse files
authored
Merge pull request #212 from AutoForgeAI/fix/rate-limit-and-version-bump
fix: resolve false-positive rate limit and version bump to 0.1.15
2 parents 9af0f30 + afc2f4a commit 472064c

11 files changed

Lines changed: 283 additions & 388 deletions

agent.py

Lines changed: 58 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -74,46 +74,65 @@ async def run_agent_session(
7474
await client.query(message)
7575

7676
# Collect response text and show tool use
77+
# Retry receive_response() on MessageParseError — the SDK raises this for
78+
# unknown CLI message types (e.g. "rate_limit_event") which kills the async
79+
# generator. The subprocess is still alive so we restart to read remaining
80+
# messages from the buffered channel.
7781
response_text = ""
78-
async for msg in client.receive_response():
79-
msg_type = type(msg).__name__
80-
81-
# Handle AssistantMessage (text and tool use)
82-
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
83-
for block in msg.content:
84-
block_type = type(block).__name__
85-
86-
if block_type == "TextBlock" and hasattr(block, "text"):
87-
response_text += block.text
88-
print(block.text, end="", flush=True)
89-
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
90-
print(f"\n[Tool: {block.name}]", flush=True)
91-
if hasattr(block, "input"):
92-
input_str = str(block.input)
93-
if len(input_str) > 200:
94-
print(f" Input: {input_str[:200]}...", flush=True)
95-
else:
96-
print(f" Input: {input_str}", flush=True)
97-
98-
# Handle UserMessage (tool results)
99-
elif msg_type == "UserMessage" and hasattr(msg, "content"):
100-
for block in msg.content:
101-
block_type = type(block).__name__
102-
103-
if block_type == "ToolResultBlock":
104-
result_content = getattr(block, "content", "")
105-
is_error = getattr(block, "is_error", False)
106-
107-
# Check if command was blocked by security hook
108-
if "blocked" in str(result_content).lower():
109-
print(f" [BLOCKED] {result_content}", flush=True)
110-
elif is_error:
111-
# Show errors (truncated)
112-
error_str = str(result_content)[:500]
113-
print(f" [Error] {error_str}", flush=True)
114-
else:
115-
# Tool succeeded - just show brief confirmation
116-
print(" [Done]", flush=True)
82+
max_parse_retries = 50
83+
parse_retries = 0
84+
while True:
85+
try:
86+
async for msg in client.receive_response():
87+
msg_type = type(msg).__name__
88+
89+
# Handle AssistantMessage (text and tool use)
90+
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
91+
for block in msg.content:
92+
block_type = type(block).__name__
93+
94+
if block_type == "TextBlock" and hasattr(block, "text"):
95+
response_text += block.text
96+
print(block.text, end="", flush=True)
97+
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
98+
print(f"\n[Tool: {block.name}]", flush=True)
99+
if hasattr(block, "input"):
100+
input_str = str(block.input)
101+
if len(input_str) > 200:
102+
print(f" Input: {input_str[:200]}...", flush=True)
103+
else:
104+
print(f" Input: {input_str}", flush=True)
105+
106+
# Handle UserMessage (tool results)
107+
elif msg_type == "UserMessage" and hasattr(msg, "content"):
108+
for block in msg.content:
109+
block_type = type(block).__name__
110+
111+
if block_type == "ToolResultBlock":
112+
result_content = getattr(block, "content", "")
113+
is_error = getattr(block, "is_error", False)
114+
115+
# Check if command was blocked by security hook
116+
if "blocked" in str(result_content).lower():
117+
print(f" [BLOCKED] {result_content}", flush=True)
118+
elif is_error:
119+
# Show errors (truncated)
120+
error_str = str(result_content)[:500]
121+
print(f" [Error] {error_str}", flush=True)
122+
else:
123+
# Tool succeeded - just show brief confirmation
124+
print(" [Done]", flush=True)
125+
126+
break # Normal completion
127+
except Exception as inner_exc:
128+
if type(inner_exc).__name__ == "MessageParseError":
129+
parse_retries += 1
130+
if parse_retries > max_parse_retries:
131+
print(f"Too many unrecognized CLI messages ({parse_retries}), stopping")
132+
break
133+
print(f"Ignoring unrecognized message from Claude CLI: {inner_exc}")
134+
continue
135+
raise # Re-raise to outer except
117136

118137
print("\n" + "-" * 70 + "\n")
119138
return "continue", response_text

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "autoforge-ai",
3-
"version": "0.1.14",
3+
"version": "0.1.15",
44
"description": "Autonomous coding agent with web UI - build complete apps with AI",
55
"license": "AGPL-3.0",
66
"bin": {

server/services/assistant_chat_session.py

Lines changed: 42 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
but cannot modify any files.
88
"""
99

10-
import asyncio
1110
import json
1211
import logging
1312
import os
@@ -27,10 +26,9 @@
2726
get_messages,
2827
)
2928
from .chat_constants import (
30-
MAX_CHAT_RATE_LIMIT_RETRIES,
3129
ROOT_DIR,
32-
calculate_rate_limit_backoff,
3330
check_rate_limit_error,
31+
safe_receive_response,
3432
)
3533

3634
# Load environment variables from .env file if present
@@ -399,66 +397,47 @@ async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]:
399397

400398
full_response = ""
401399

402-
# Stream the response (with rate-limit retry)
403-
for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1):
404-
try:
405-
async for msg in self.client.receive_response():
406-
msg_type = type(msg).__name__
407-
408-
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
409-
for block in msg.content:
410-
block_type = type(block).__name__
411-
412-
if block_type == "TextBlock" and hasattr(block, "text"):
413-
text = block.text
414-
if text:
415-
full_response += text
416-
yield {"type": "text", "content": text}
417-
418-
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
419-
tool_name = block.name
420-
tool_input = getattr(block, "input", {})
421-
422-
# Intercept ask_user tool calls -> yield as question message
423-
if tool_name == "mcp__features__ask_user":
424-
questions = tool_input.get("questions", [])
425-
if questions:
426-
yield {
427-
"type": "question",
428-
"questions": questions,
429-
}
430-
continue
431-
432-
yield {
433-
"type": "tool_call",
434-
"tool": tool_name,
435-
"input": tool_input,
436-
}
437-
# Completed successfully — break out of retry loop
438-
break
439-
except Exception as exc:
440-
is_rate_limit, retry_secs = check_rate_limit_error(exc)
441-
if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES:
442-
delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt)
443-
logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s")
444-
yield {
445-
"type": "rate_limited",
446-
"retry_in": delay,
447-
"attempt": _attempt + 1,
448-
"max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
449-
}
450-
await asyncio.sleep(delay)
451-
await self.client.query(message)
452-
continue
453-
if is_rate_limit:
454-
logger.error("Rate limit retries exhausted for assistant chat")
455-
yield {"type": "error", "content": "Rate limited. Please try again later."}
456-
return
457-
# Non-rate-limit MessageParseError: log and break (don't crash)
458-
if type(exc).__name__ == "MessageParseError":
459-
logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
460-
break
461-
raise
400+
# Stream the response
401+
try:
402+
async for msg in safe_receive_response(self.client, logger):
403+
msg_type = type(msg).__name__
404+
405+
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
406+
for block in msg.content:
407+
block_type = type(block).__name__
408+
409+
if block_type == "TextBlock" and hasattr(block, "text"):
410+
text = block.text
411+
if text:
412+
full_response += text
413+
yield {"type": "text", "content": text}
414+
415+
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
416+
tool_name = block.name
417+
tool_input = getattr(block, "input", {})
418+
419+
# Intercept ask_user tool calls -> yield as question message
420+
if tool_name == "mcp__features__ask_user":
421+
questions = tool_input.get("questions", [])
422+
if questions:
423+
yield {
424+
"type": "question",
425+
"questions": questions,
426+
}
427+
continue
428+
429+
yield {
430+
"type": "tool_call",
431+
"tool": tool_name,
432+
"input": tool_input,
433+
}
434+
except Exception as exc:
435+
is_rate_limit, _ = check_rate_limit_error(exc)
436+
if is_rate_limit:
437+
logger.warning(f"Rate limited: {exc}")
438+
yield {"type": "error", "content": "Rate limited. Please try again later."}
439+
return
440+
raise
462441

463442
# Store the complete response in the database
464443
if full_response and self.conversation_id:

server/services/chat_constants.py

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import logging
1313
import sys
1414
from pathlib import Path
15-
from typing import AsyncGenerator
15+
from typing import Any, AsyncGenerator
1616

1717
# -------------------------------------------------------------------
1818
# Root directory of the autoforge project (repository root).
@@ -33,47 +33,61 @@
3333
# imports continue to work unchanged.
3434
# -------------------------------------------------------------------
3535
from env_constants import API_ENV_VARS # noqa: E402, F401
36-
from rate_limit_utils import calculate_rate_limit_backoff, is_rate_limit_error, parse_retry_after # noqa: E402, F401
36+
from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
3737

3838
logger = logging.getLogger(__name__)
3939

40-
# -------------------------------------------------------------------
41-
# Rate-limit handling for chat sessions
42-
# -------------------------------------------------------------------
43-
MAX_CHAT_RATE_LIMIT_RETRIES = 3
44-
4540

4641
def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
4742
"""Inspect an exception and determine if it represents a rate-limit.
4843
4944
Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
5045
parsed Retry-After value when available, otherwise ``None`` (caller
5146
should use exponential backoff).
52-
53-
Handles:
54-
- ``MessageParseError`` whose raw *data* dict has
55-
``type == "rate_limit_event"`` (Claude CLI sends this).
56-
- Any exception whose string representation matches known rate-limit
57-
patterns (via ``rate_limit_utils.is_rate_limit_error``).
5847
"""
59-
exc_str = str(exc)
60-
61-
# Check for MessageParseError with a rate_limit_event payload
62-
cls_name = type(exc).__name__
63-
if cls_name == "MessageParseError":
64-
raw_data = getattr(exc, "data", None)
65-
if isinstance(raw_data, dict) and raw_data.get("type") == "rate_limit_event":
66-
retry = parse_retry_after(str(raw_data)) if raw_data else None
67-
return True, retry
48+
# MessageParseError = unknown CLI message type (e.g. "rate_limit_event").
49+
# These are informational events, NOT actual rate limit errors.
50+
# The word "rate_limit" in the type name would false-positive the regex.
51+
if type(exc).__name__ == "MessageParseError":
52+
return False, None
6853

69-
# Fallback: match error text against known rate-limit patterns
54+
# For all other exceptions: match error text against known rate-limit patterns
55+
exc_str = str(exc)
7056
if is_rate_limit_error(exc_str):
7157
retry = parse_retry_after(exc_str)
7258
return True, retry
7359

7460
return False, None
7561

7662

63+
async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator:
64+
"""Wrap ``client.receive_response()`` to skip ``MessageParseError``.
65+
66+
The Claude Code CLI may emit message types (e.g. ``rate_limit_event``)
67+
that the installed Python SDK does not recognise, causing
68+
``MessageParseError`` which kills the async generator. The CLI
69+
subprocess is still alive and the SDK uses a buffered memory channel,
70+
so we restart ``receive_response()`` to continue reading remaining
71+
messages without losing data.
72+
"""
73+
max_retries = 50
74+
retries = 0
75+
while True:
76+
try:
77+
async for msg in client.receive_response():
78+
yield msg
79+
return # Normal completion
80+
except Exception as exc:
81+
if type(exc).__name__ == "MessageParseError":
82+
retries += 1
83+
if retries > max_retries:
84+
log.error(f"Too many unrecognized CLI messages ({retries}), stopping")
85+
return
86+
log.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
87+
continue
88+
raise
89+
90+
7791
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
7892
"""Yield a single multimodal user message in Claude Agent SDK format.
7993

0 commit comments

Comments
 (0)