From b7aef15c3b313048b9ec567cad430af9347d3430 Mon Sep 17 00:00:00 2001 From: Auto Date: Sat, 21 Feb 2026 10:23:42 +0200 Subject: [PATCH 1/4] feat: add VISION.md and enforce Claude Agent SDK exclusivity in PR reviews - Create VISION.md establishing AutoForge as a Claude Agent SDK wrapper exclusively, rejecting integrations with other AI SDKs/CLIs/platforms - Update review-pr.md step 6 to make vision deviation a merge blocker (previously informational only) and auto-reject PRs modifying VISION.md - Add .claude/launch.json with backend (uvicorn) and frontend (Vite) dev server configurations Co-Authored-By: Claude Opus 4.6 --- .claude/commands/review-pr.md | 8 ++++---- .claude/launch.json | 17 +++++++++++++++++ VISION.md | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 .claude/launch.json create mode 100644 VISION.md diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index b8b3023e..8a40d9bc 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -55,10 +55,10 @@ Pull request(s): $ARGUMENTS - Reviewing large, unfocused PRs is impractical and error-prone; the review cannot provide adequate assurance for such changes 6. **Vision Alignment Check** - - Read the project's README.md and CLAUDE.md to understand the application's core purpose - - Assess whether this PR aligns with the application's intended functionality - - If the changes deviate significantly from the core vision or add functionality that doesn't serve the application's purpose, note this in the review - - This is not a blocker, but should be flagged for the reviewer's consideration + - **VISION.md protection**: First, check whether the PR diff modifies `VISION.md` in any way (edits, deletions, renames). If it does, **stop the review immediately** — verdict is **DON'T MERGE**. VISION.md is immutable and no PR is permitted to alter it. Explain this to the user and skip all remaining steps. + - Read the project's `VISION.md`, `README.md`, and `CLAUDE.md` to understand the application's core purpose and mandatory architectural constraints + - Assess whether this PR aligns with the vision defined in `VISION.md` + - **Vision deviation is a merge blocker.** If the PR introduces functionality, integrations, or architectural changes that conflict with `VISION.md`, the verdict must be **DON'T MERGE**. This is not negotiable — the vision document takes precedence over any PR rationale. 7. **Safety Assessment** - Provide a review on whether the PR is safe to merge as-is diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..9d1f0565 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "backend", + "runtimeExecutable": "python", + "runtimeArgs": ["-m", "uvicorn", "server.main:app", "--host", "127.0.0.1", "--port", "8888", "--reload"], + "port": 8888 + }, + { + "name": "frontend", + "runtimeExecutable": "cmd", + "runtimeArgs": ["/c", "cd ui && npx vite"], + "port": 5173 + } + ] +} diff --git a/VISION.md b/VISION.md new file mode 100644 index 00000000..3ae6975d --- /dev/null +++ b/VISION.md @@ -0,0 +1,22 @@ +# VISION + +This document defines the mandatory project vision for AutoForge. All contributions must align with these principles. PRs that deviate from this vision will be rejected. This file itself is immutable via PR — any PR that modifies VISION.md will be rejected outright. + +## Claude Agent SDK Exclusivity + +AutoForge is a wrapper around the **Claude Agent SDK**. This is a foundational architectural decision, not a preference. + +**What this means:** + +- AutoForge only supports providers, models, and integrations that work through the Claude Agent SDK. +- We will not integrate with, accommodate, or add support for other AI SDKs, CLIs, or coding agent platforms (e.g., Codex, OpenCode, Aider, Continue, Cursor agents, or similar tools). + +**Why:** + +Each platform has its own approach to MCP tools, skills, context management, and feature integration. Attempting to support multiple agent frameworks creates an unsustainable maintenance burden and dilutes the quality of the core experience. By committing to the Claude Agent SDK exclusively, we can build deep, reliable integration rather than shallow compatibility across many targets. + +**In practice:** + +- PRs adding support for non-Claude agent frameworks will be rejected. +- PRs introducing abstractions designed to make AutoForge "agent-agnostic" will be rejected. +- Alternative API providers (e.g., Vertex AI, AWS Bedrock) are acceptable only when accessed through the Claude Agent SDK's own configuration. From dcdd06e02e46c0649aeeb212361e240573ab862d Mon Sep 17 00:00:00 2001 From: Auto Date: Mon, 23 Feb 2026 12:21:49 +0200 Subject: [PATCH 2/4] fix: handle rate_limit_event crash in chat sessions The Claude CLI sends `rate_limit_event` messages that the SDK's `parse_message()` doesn't recognize, raising `MessageParseError` and crashing all three chat session types (spec, assistant, expand). Changes: - Bump claude-agent-sdk minimum from 0.1.0 to 0.1.39 - Add `check_rate_limit_error()` helper in chat_constants.py that detects rate limits from both MessageParseError data payloads and error message text patterns - Wrap `receive_response()` loops in all three `_query_claude()` methods with retry-on-rate-limit logic (up to 3 retries with backoff) - Gracefully log and skip non-rate-limit MessageParseError instead of crashing the session - Add `rate_limited` message type to frontend TypeScript types and handle it in useSpecChat, useAssistantChat, useExpandChat hooks to show "Rate limited. Retrying in Xs..." system messages Co-Authored-By: Claude Opus 4.6 --- .claude/launch.json | 3 +- requirements-prod.txt | 2 +- requirements.txt | 2 +- server/services/assistant_chat_session.py | 95 +++++--- server/services/chat_constants.py | 40 ++++ server/services/expand_chat_session.py | 85 +++++-- server/services/spec_chat_session.py | 257 ++++++++++++---------- ui/src/hooks/useAssistantChat.ts | 14 ++ ui/src/hooks/useExpandChat.ts | 14 ++ ui/src/hooks/useSpecChat.ts | 14 ++ ui/src/lib/types.ts | 17 ++ 11 files changed, 380 insertions(+), 163 deletions(-) diff --git a/.claude/launch.json b/.claude/launch.json index 9d1f0565..728c68f8 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -13,5 +13,6 @@ "runtimeArgs": ["/c", "cd ui && npx vite"], "port": 5173 } - ] + ], + "autoVerify": true } diff --git a/requirements-prod.txt b/requirements-prod.txt index 05e7f4cc..1b2f0b22 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -1,6 +1,6 @@ # Production runtime dependencies only # For development, use requirements.txt (includes ruff, mypy, pytest) -claude-agent-sdk>=0.1.0,<0.2.0 +claude-agent-sdk>=0.1.39,<0.2.0 python-dotenv>=1.0.0 sqlalchemy>=2.0.0 fastapi>=0.115.0 diff --git a/requirements.txt b/requirements.txt index 5d57a398..f042b4df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -claude-agent-sdk>=0.1.0,<0.2.0 +claude-agent-sdk>=0.1.39,<0.2.0 python-dotenv>=1.0.0 sqlalchemy>=2.0.0 fastapi>=0.115.0 diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index f030aa4b..f666b1be 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -7,6 +7,7 @@ but cannot modify any files. """ +import asyncio import json import logging import os @@ -25,7 +26,12 @@ create_conversation, get_messages, ) -from .chat_constants import ROOT_DIR +from .chat_constants import ( + MAX_CHAT_RATE_LIMIT_RETRIES, + ROOT_DIR, + calculate_rate_limit_backoff, + check_rate_limit_error, +) # Load environment variables from .env file if present load_dotenv() @@ -393,39 +399,66 @@ async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]: full_response = "" - # Stream the response - async for msg in self.client.receive_response(): - msg_type = type(msg).__name__ - - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "TextBlock" and hasattr(block, "text"): - text = block.text - if text: - full_response += text - yield {"type": "text", "content": text} - - elif block_type == "ToolUseBlock" and hasattr(block, "name"): - tool_name = block.name - tool_input = getattr(block, "input", {}) + # Stream the response (with rate-limit retry) + for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1): + try: + async for msg in self.client.receive_response(): + msg_type = type(msg).__name__ + + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + + if block_type == "TextBlock" and hasattr(block, "text"): + text = block.text + if text: + full_response += text + yield {"type": "text", "content": text} + + elif block_type == "ToolUseBlock" and hasattr(block, "name"): + tool_name = block.name + tool_input = getattr(block, "input", {}) + + # Intercept ask_user tool calls -> yield as question message + if tool_name == "mcp__features__ask_user": + questions = tool_input.get("questions", []) + if questions: + yield { + "type": "question", + "questions": questions, + } + continue - # Intercept ask_user tool calls -> yield as question message - if tool_name == "mcp__features__ask_user": - questions = tool_input.get("questions", []) - if questions: yield { - "type": "question", - "questions": questions, + "type": "tool_call", + "tool": tool_name, + "input": tool_input, } - continue - - yield { - "type": "tool_call", - "tool": tool_name, - "input": tool_input, - } + # Completed successfully — break out of retry loop + break + except Exception as exc: + is_rate_limit, retry_secs = check_rate_limit_error(exc) + if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES: + delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt) + logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s") + yield { + "type": "rate_limited", + "retry_in": delay, + "attempt": _attempt + 1, + "max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES, + } + await asyncio.sleep(delay) + await self.client.query(message) + continue + if is_rate_limit: + logger.error("Rate limit retries exhausted for assistant chat") + yield {"type": "error", "content": "Rate limited. Please try again later."} + return + # Non-rate-limit MessageParseError: log and break (don't crash) + if type(exc).__name__ == "MessageParseError": + logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}") + break + raise # Store the complete response in the database if full_response and self.conversation_id: diff --git a/server/services/chat_constants.py b/server/services/chat_constants.py index 0baddd2c..4dfbcd6b 100644 --- a/server/services/chat_constants.py +++ b/server/services/chat_constants.py @@ -9,6 +9,7 @@ imports (``from .chat_constants import API_ENV_VARS``) continue to work. """ +import logging import sys from pathlib import Path from typing import AsyncGenerator @@ -32,6 +33,45 @@ # imports continue to work unchanged. # ------------------------------------------------------------------- from env_constants import API_ENV_VARS # noqa: E402, F401 +from rate_limit_utils import calculate_rate_limit_backoff, is_rate_limit_error, parse_retry_after # noqa: E402, F401 + +logger = logging.getLogger(__name__) + +# ------------------------------------------------------------------- +# Rate-limit handling for chat sessions +# ------------------------------------------------------------------- +MAX_CHAT_RATE_LIMIT_RETRIES = 3 + + +def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]: + """Inspect an exception and determine if it represents a rate-limit. + + Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the + parsed Retry-After value when available, otherwise ``None`` (caller + should use exponential backoff). + + Handles: + - ``MessageParseError`` whose raw *data* dict has + ``type == "rate_limit_event"`` (Claude CLI sends this). + - Any exception whose string representation matches known rate-limit + patterns (via ``rate_limit_utils.is_rate_limit_error``). + """ + exc_str = str(exc) + + # Check for MessageParseError with a rate_limit_event payload + cls_name = type(exc).__name__ + if cls_name == "MessageParseError": + raw_data = getattr(exc, "data", None) + if isinstance(raw_data, dict) and raw_data.get("type") == "rate_limit_event": + retry = parse_retry_after(str(raw_data)) if raw_data else None + return True, retry + + # Fallback: match error text against known rate-limit patterns + if is_rate_limit_error(exc_str): + retry = parse_retry_after(exc_str) + return True, retry + + return False, None async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]: diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index b06e9d85..e305d29b 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -22,7 +22,13 @@ from dotenv import load_dotenv from ..schemas import ImageAttachment -from .chat_constants import ROOT_DIR, make_multimodal_message +from .chat_constants import ( + MAX_CHAT_RATE_LIMIT_RETRIES, + ROOT_DIR, + calculate_rate_limit_backoff, + check_rate_limit_error, + make_multimodal_message, +) # Load environment variables from .env file if present load_dotenv() @@ -298,24 +304,67 @@ async def _query_claude( else: await self.client.query(message) - # Stream the response - async for msg in self.client.receive_response(): - msg_type = type(msg).__name__ - - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "TextBlock" and hasattr(block, "text"): - text = block.text - if text: - yield {"type": "text", "content": text} - - self.messages.append({ - "role": "assistant", - "content": text, - "timestamp": datetime.now().isoformat() + # Stream the response (with rate-limit retry) + for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1): + try: + async for msg in self.client.receive_response(): + msg_type = type(msg).__name__ + + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + + if block_type == "TextBlock" and hasattr(block, "text"): + text = block.text + if text: + yield {"type": "text", "content": text} + + self.messages.append({ + "role": "assistant", + "content": text, + "timestamp": datetime.now().isoformat() + }) + # Completed successfully — break out of retry loop + break + except Exception as exc: + is_rate_limit, retry_secs = check_rate_limit_error(exc) + if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES: + delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt) + logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s") + yield { + "type": "rate_limited", + "retry_in": delay, + "attempt": _attempt + 1, + "max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES, + } + await asyncio.sleep(delay) + # Re-send the query before retrying receive_response + if attachments and len(attachments) > 0: + content_blocks_retry: list[dict[str, Any]] = [] + if message: + content_blocks_retry.append({"type": "text", "text": message}) + for att in attachments: + content_blocks_retry.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": att.mimeType, + "data": att.base64Data, + } }) + await self.client.query(make_multimodal_message(content_blocks_retry)) + else: + await self.client.query(message) + continue + if is_rate_limit: + logger.error("Rate limit retries exhausted for expand chat") + yield {"type": "error", "content": "Rate limited. Please try again later."} + return + # Non-rate-limit MessageParseError: log and break (don't crash) + if type(exc).__name__ == "MessageParseError": + logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}") + break + raise def get_features_created(self) -> int: """Get the total number of features created in this session.""" diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index d3556173..8fdfecb9 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -6,6 +6,7 @@ Uses the create-spec.md skill to guide users through app spec creation. """ +import asyncio import json import logging import os @@ -19,7 +20,13 @@ from dotenv import load_dotenv from ..schemas import ImageAttachment -from .chat_constants import ROOT_DIR, make_multimodal_message +from .chat_constants import ( + MAX_CHAT_RATE_LIMIT_RETRIES, + ROOT_DIR, + calculate_rate_limit_backoff, + check_rate_limit_error, + make_multimodal_message, +) # Load environment variables from .env file if present load_dotenv() @@ -304,117 +311,145 @@ async def _query_claude( # Store paths for the completion message spec_path = None - # Stream the response using receive_response - async for msg in self.client.receive_response(): - msg_type = type(msg).__name__ - - if msg_type == "AssistantMessage" and hasattr(msg, "content"): - # Process content blocks in the assistant message - for block in msg.content: - block_type = type(block).__name__ - - if block_type == "TextBlock" and hasattr(block, "text"): - # Accumulate text and yield it - text = block.text - if text: - current_text += text - yield {"type": "text", "content": text} - - # Store in message history - self.messages.append({ - "role": "assistant", - "content": text, - "timestamp": datetime.now().isoformat() - }) - - elif block_type == "ToolUseBlock" and hasattr(block, "name"): - tool_name = block.name - tool_input = getattr(block, "input", {}) - tool_id = getattr(block, "id", "") - - if tool_name in ("Write", "Edit"): - # File being written or edited - track for verification - file_path = tool_input.get("file_path", "") - - # Track app_spec.txt - if "app_spec.txt" in str(file_path): - pending_writes["app_spec"] = { - "tool_id": tool_id, - "path": file_path - } - logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}") - - # Track initializer_prompt.md - elif "initializer_prompt.md" in str(file_path): - pending_writes["initializer"] = { - "tool_id": tool_id, - "path": file_path - } - logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}") - - elif msg_type == "UserMessage" and hasattr(msg, "content"): - # Tool results - check for write confirmations and errors - for block in msg.content: - block_type = type(block).__name__ - if block_type == "ToolResultBlock": - is_error = getattr(block, "is_error", False) - tool_use_id = getattr(block, "tool_use_id", "") - - if is_error: - content = getattr(block, "content", "Unknown error") - logger.warning(f"Tool error: {content}") - # Clear any pending writes that failed - for key in pending_writes: - pending_write = pending_writes[key] - if pending_write is not None and tool_use_id == pending_write.get("tool_id"): - logger.error(f"{key} write failed: {content}") - pending_writes[key] = None - else: - # Tool succeeded - check which file was written - - # Check app_spec.txt - if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"): - file_path = pending_writes["app_spec"]["path"] - full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path - if full_path.exists(): - logger.info(f"app_spec.txt verified at: {full_path}") - files_written["app_spec"] = True - spec_path = file_path - - # Notify about file write (but NOT completion yet) - yield { - "type": "file_written", - "path": str(file_path) - } - else: - logger.error(f"app_spec.txt not found after write: {full_path}") - pending_writes["app_spec"] = None - - # Check initializer_prompt.md - if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"): - file_path = pending_writes["initializer"]["path"] - full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path - if full_path.exists(): - logger.info(f"initializer_prompt.md verified at: {full_path}") - files_written["initializer"] = True - - # Notify about file write - yield { - "type": "file_written", - "path": str(file_path) - } + # Stream the response using receive_response (with rate-limit retry) + for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1): + try: + async for msg in self.client.receive_response(): + msg_type = type(msg).__name__ + + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + # Process content blocks in the assistant message + for block in msg.content: + block_type = type(block).__name__ + + if block_type == "TextBlock" and hasattr(block, "text"): + # Accumulate text and yield it + text = block.text + if text: + current_text += text + yield {"type": "text", "content": text} + + # Store in message history + self.messages.append({ + "role": "assistant", + "content": text, + "timestamp": datetime.now().isoformat() + }) + + elif block_type == "ToolUseBlock" and hasattr(block, "name"): + tool_name = block.name + tool_input = getattr(block, "input", {}) + tool_id = getattr(block, "id", "") + + if tool_name in ("Write", "Edit"): + # File being written or edited - track for verification + file_path = tool_input.get("file_path", "") + + # Track app_spec.txt + if "app_spec.txt" in str(file_path): + pending_writes["app_spec"] = { + "tool_id": tool_id, + "path": file_path + } + logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}") + + # Track initializer_prompt.md + elif "initializer_prompt.md" in str(file_path): + pending_writes["initializer"] = { + "tool_id": tool_id, + "path": file_path + } + logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}") + + elif msg_type == "UserMessage" and hasattr(msg, "content"): + # Tool results - check for write confirmations and errors + for block in msg.content: + block_type = type(block).__name__ + if block_type == "ToolResultBlock": + is_error = getattr(block, "is_error", False) + tool_use_id = getattr(block, "tool_use_id", "") + + if is_error: + content = getattr(block, "content", "Unknown error") + logger.warning(f"Tool error: {content}") + # Clear any pending writes that failed + for key in pending_writes: + pending_write = pending_writes[key] + if pending_write is not None and tool_use_id == pending_write.get("tool_id"): + logger.error(f"{key} write failed: {content}") + pending_writes[key] = None else: - logger.error(f"initializer_prompt.md not found after write: {full_path}") - pending_writes["initializer"] = None - - # Check if BOTH files are now written - only then signal completion - if files_written["app_spec"] and files_written["initializer"]: - logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion") - self.complete = True - yield { - "type": "spec_complete", - "path": str(spec_path) - } + # Tool succeeded - check which file was written + + # Check app_spec.txt + if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"): + file_path = pending_writes["app_spec"]["path"] + full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path + if full_path.exists(): + logger.info(f"app_spec.txt verified at: {full_path}") + files_written["app_spec"] = True + spec_path = file_path + + # Notify about file write (but NOT completion yet) + yield { + "type": "file_written", + "path": str(file_path) + } + else: + logger.error(f"app_spec.txt not found after write: {full_path}") + pending_writes["app_spec"] = None + + # Check initializer_prompt.md + if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"): + file_path = pending_writes["initializer"]["path"] + full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path + if full_path.exists(): + logger.info(f"initializer_prompt.md verified at: {full_path}") + files_written["initializer"] = True + + # Notify about file write + yield { + "type": "file_written", + "path": str(file_path) + } + else: + logger.error(f"initializer_prompt.md not found after write: {full_path}") + pending_writes["initializer"] = None + + # Check if BOTH files are now written - only then signal completion + if files_written["app_spec"] and files_written["initializer"]: + logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion") + self.complete = True + yield { + "type": "spec_complete", + "path": str(spec_path) + } + # Completed successfully — break out of retry loop + break + except Exception as exc: + is_rate_limit, retry_secs = check_rate_limit_error(exc) + if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES: + delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt) + logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s") + yield { + "type": "rate_limited", + "retry_in": delay, + "attempt": _attempt + 1, + "max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES, + } + await asyncio.sleep(delay) + # Re-send the query before retrying receive_response + await self.client.query(message) + continue + if is_rate_limit: + logger.error("Rate limit retries exhausted for spec chat") + yield {"type": "error", "content": "Rate limited. Please try again later."} + return + # Non-rate-limit MessageParseError: log and break (don't crash) + if type(exc).__name__ == "MessageParseError": + logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}") + break + raise def is_complete(self) -> bool: """Check if spec creation is complete.""" diff --git a/ui/src/hooks/useAssistantChat.ts b/ui/src/hooks/useAssistantChat.ts index cb660f60..86e72f33 100755 --- a/ui/src/hooks/useAssistantChat.ts +++ b/ui/src/hooks/useAssistantChat.ts @@ -269,6 +269,20 @@ export function useAssistantChat({ break; } + case "rate_limited": { + // Show rate limit info as system message + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: "system", + content: `Rate limited. Retrying in ${data.retry_in}s... (attempt ${data.attempt}/${data.max_attempts})`, + timestamp: new Date(), + }, + ]); + break; + } + case "pong": { // Keep-alive response, nothing to do break; diff --git a/ui/src/hooks/useExpandChat.ts b/ui/src/hooks/useExpandChat.ts index be632a54..ca00a68f 100644 --- a/ui/src/hooks/useExpandChat.ts +++ b/ui/src/hooks/useExpandChat.ts @@ -226,6 +226,20 @@ export function useExpandChat({ break } + case 'rate_limited': { + // Show rate limit info as system message + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'system', + content: `Rate limited. Retrying in ${data.retry_in}s... (attempt ${data.attempt}/${data.max_attempts})`, + timestamp: new Date(), + }, + ]) + break + } + case 'pong': { // Keep-alive response, nothing to do break diff --git a/ui/src/hooks/useSpecChat.ts b/ui/src/hooks/useSpecChat.ts index 3bd09bb2..6a3a0189 100644 --- a/ui/src/hooks/useSpecChat.ts +++ b/ui/src/hooks/useSpecChat.ts @@ -322,6 +322,20 @@ export function useSpecChat({ break } + case 'rate_limited': { + // Show rate limit info as system message + setMessages((prev) => [ + ...prev, + { + id: generateId(), + role: 'system', + content: `Rate limited. Retrying in ${data.retry_in}s... (attempt ${data.attempt}/${data.max_attempts})`, + timestamp: new Date(), + }, + ]) + break + } + case 'pong': { // Keep-alive response, nothing to do break diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 504888a4..d4fed179 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -407,6 +407,13 @@ export interface SpecChatResponseDoneMessage { type: 'response_done' } +export interface SpecChatRateLimitedMessage { + type: 'rate_limited' + retry_in: number + attempt: number + max_attempts: number +} + export type SpecChatServerMessage = | SpecChatTextMessage | SpecChatQuestionMessage @@ -416,6 +423,7 @@ export type SpecChatServerMessage = | SpecChatErrorMessage | SpecChatPongMessage | SpecChatResponseDoneMessage + | SpecChatRateLimitedMessage // Image attachment for chat messages export interface ImageAttachment { @@ -501,6 +509,13 @@ export interface AssistantChatPongMessage { type: 'pong' } +export interface AssistantChatRateLimitedMessage { + type: 'rate_limited' + retry_in: number + attempt: number + max_attempts: number +} + export type AssistantChatServerMessage = | AssistantChatTextMessage | AssistantChatToolCallMessage @@ -509,6 +524,7 @@ export type AssistantChatServerMessage = | AssistantChatErrorMessage | AssistantChatConversationCreatedMessage | AssistantChatPongMessage + | AssistantChatRateLimitedMessage // ============================================================================ // Expand Chat Types @@ -532,6 +548,7 @@ export type ExpandChatServerMessage = | SpecChatErrorMessage // Reuse error message type | SpecChatPongMessage // Reuse pong message type | SpecChatResponseDoneMessage // Reuse response_done type + | SpecChatRateLimitedMessage // Reuse rate_limited message type // Bulk feature creation export interface FeatureBulkCreate { From f7868799086e26e578979b879580b2ae06ed2866 Mon Sep 17 00:00:00 2001 From: Auto Date: Mon, 23 Feb 2026 12:22:06 +0200 Subject: [PATCH 3/4] 0.1.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7bb52c08..269eecc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autoforge-ai", - "version": "0.1.13", + "version": "0.1.14", "description": "Autonomous coding agent with web UI - build complete apps with AI", "license": "AGPL-3.0", "bin": { From 49442f0d432b1c302d80caac682874ebe6610730 Mon Sep 17 00:00:00 2001 From: Auto Date: Mon, 23 Feb 2026 12:23:02 +0200 Subject: [PATCH 4/4] version patch --- ui/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 8624a5ed..8d28e5e9 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -56,7 +56,7 @@ }, "..": { "name": "autoforge-ai", - "version": "0.1.13", + "version": "0.1.14", "license": "AGPL-3.0", "bin": { "autoforge": "bin/autoforge.js"