autoforge-ai 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent.py +58 -39
- package/package.json +1 -1
- package/server/main.py +2 -0
- package/server/routers/__init__.py +2 -0
- package/server/routers/scaffold.py +136 -0
- package/server/services/assistant_chat_session.py +42 -63
- package/server/services/chat_constants.py +37 -23
- package/server/services/expand_chat_session.py +27 -63
- package/server/services/spec_chat_session.py +117 -139
- package/ui/dist/assets/index-CX9TqxHJ.css +1 -0
- package/ui/dist/assets/index-DtBG9zqQ.js +96 -0
- package/ui/dist/assets/vendor-utils-CdMnkzGY.js +2 -0
- package/ui/dist/index.html +3 -3
- package/ui/dist/assets/index-Cab_i6Vt.js +0 -95
- package/ui/dist/assets/index-JNM7eUj7.css +0 -1
- package/ui/dist/assets/vendor-utils-BaL7xioT.js +0 -2
package/agent.py
CHANGED
|
@@ -74,46 +74,65 @@ async def run_agent_session(
|
|
|
74
74
|
await client.query(message)
|
|
75
75
|
|
|
76
76
|
# 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.
|
|
77
81
|
response_text = ""
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
print(f"
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
117
136
|
|
|
118
137
|
print("\n" + "-" * 70 + "\n")
|
|
119
138
|
return "continue", response_text
|
package/package.json
CHANGED
package/server/main.py
CHANGED
|
@@ -36,6 +36,7 @@ from .routers import (
|
|
|
36
36
|
features_router,
|
|
37
37
|
filesystem_router,
|
|
38
38
|
projects_router,
|
|
39
|
+
scaffold_router,
|
|
39
40
|
schedules_router,
|
|
40
41
|
settings_router,
|
|
41
42
|
spec_creation_router,
|
|
@@ -169,6 +170,7 @@ app.include_router(filesystem_router)
|
|
|
169
170
|
app.include_router(assistant_chat_router)
|
|
170
171
|
app.include_router(settings_router)
|
|
171
172
|
app.include_router(terminal_router)
|
|
173
|
+
app.include_router(scaffold_router)
|
|
172
174
|
|
|
173
175
|
|
|
174
176
|
# ============================================================================
|
|
@@ -12,6 +12,7 @@ from .expand_project import router as expand_project_router
|
|
|
12
12
|
from .features import router as features_router
|
|
13
13
|
from .filesystem import router as filesystem_router
|
|
14
14
|
from .projects import router as projects_router
|
|
15
|
+
from .scaffold import router as scaffold_router
|
|
15
16
|
from .schedules import router as schedules_router
|
|
16
17
|
from .settings import router as settings_router
|
|
17
18
|
from .spec_creation import router as spec_creation_router
|
|
@@ -29,4 +30,5 @@ __all__ = [
|
|
|
29
30
|
"assistant_chat_router",
|
|
30
31
|
"settings_router",
|
|
31
32
|
"terminal_router",
|
|
33
|
+
"scaffold_router",
|
|
32
34
|
]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scaffold Router
|
|
3
|
+
================
|
|
4
|
+
|
|
5
|
+
SSE streaming endpoint for running project scaffold commands.
|
|
6
|
+
Supports templated project creation (e.g., Next.js agentic starter).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Request
|
|
18
|
+
from fastapi.responses import StreamingResponse
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from .filesystem import is_path_blocked
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
router = APIRouter(prefix="/api/scaffold", tags=["scaffold"])
|
|
26
|
+
|
|
27
|
+
# Hardcoded templates — no arbitrary commands allowed
|
|
28
|
+
TEMPLATES: dict[str, list[str]] = {
|
|
29
|
+
"agentic-starter": ["npx", "create-agentic-app@latest", ".", "-y", "-p", "npm", "--skip-git"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ScaffoldRequest(BaseModel):
|
|
34
|
+
template: str
|
|
35
|
+
target_path: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sse_event(data: dict) -> str:
|
|
39
|
+
"""Format a dict as an SSE data line."""
|
|
40
|
+
return f"data: {json.dumps(data)}\n\n"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _stream_scaffold(template: str, target_path: str, request: Request):
|
|
44
|
+
"""Run the scaffold command and yield SSE events."""
|
|
45
|
+
# Validate template
|
|
46
|
+
if template not in TEMPLATES:
|
|
47
|
+
yield _sse_event({"type": "error", "message": f"Unknown template: {template}"})
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Validate path
|
|
51
|
+
path = Path(target_path)
|
|
52
|
+
try:
|
|
53
|
+
path = path.resolve()
|
|
54
|
+
except (OSError, ValueError) as e:
|
|
55
|
+
yield _sse_event({"type": "error", "message": f"Invalid path: {e}"})
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if is_path_blocked(path):
|
|
59
|
+
yield _sse_event({"type": "error", "message": "Access to this directory is not allowed"})
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
if not path.exists() or not path.is_dir():
|
|
63
|
+
yield _sse_event({"type": "error", "message": "Target directory does not exist"})
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Check npx is available
|
|
67
|
+
npx_name = "npx"
|
|
68
|
+
if sys.platform == "win32":
|
|
69
|
+
npx_name = "npx.cmd"
|
|
70
|
+
|
|
71
|
+
if not shutil.which(npx_name):
|
|
72
|
+
yield _sse_event({"type": "error", "message": "npx is not available. Please install Node.js."})
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# Build command
|
|
76
|
+
argv = list(TEMPLATES[template])
|
|
77
|
+
if sys.platform == "win32" and not argv[0].lower().endswith(".cmd"):
|
|
78
|
+
argv[0] = argv[0] + ".cmd"
|
|
79
|
+
|
|
80
|
+
process = None
|
|
81
|
+
try:
|
|
82
|
+
popen_kwargs: dict = {
|
|
83
|
+
"stdout": subprocess.PIPE,
|
|
84
|
+
"stderr": subprocess.STDOUT,
|
|
85
|
+
"stdin": subprocess.DEVNULL,
|
|
86
|
+
"cwd": str(path),
|
|
87
|
+
}
|
|
88
|
+
if sys.platform == "win32":
|
|
89
|
+
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
90
|
+
|
|
91
|
+
process = subprocess.Popen(argv, **popen_kwargs)
|
|
92
|
+
logger.info("Scaffold process started: pid=%s, template=%s, path=%s", process.pid, template, target_path)
|
|
93
|
+
|
|
94
|
+
# Stream stdout lines
|
|
95
|
+
assert process.stdout is not None
|
|
96
|
+
for raw_line in iter(process.stdout.readline, b""):
|
|
97
|
+
# Check if client disconnected
|
|
98
|
+
if await request.is_disconnected():
|
|
99
|
+
logger.info("Client disconnected during scaffold, terminating process")
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
|
|
103
|
+
yield _sse_event({"type": "output", "line": line})
|
|
104
|
+
# Yield control to event loop so disconnect checks work
|
|
105
|
+
await asyncio.sleep(0)
|
|
106
|
+
|
|
107
|
+
process.wait()
|
|
108
|
+
exit_code = process.returncode
|
|
109
|
+
success = exit_code == 0
|
|
110
|
+
logger.info("Scaffold process completed: exit_code=%s, template=%s", exit_code, template)
|
|
111
|
+
yield _sse_event({"type": "complete", "success": success, "exit_code": exit_code})
|
|
112
|
+
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error("Scaffold error: %s", e)
|
|
115
|
+
yield _sse_event({"type": "error", "message": str(e)})
|
|
116
|
+
|
|
117
|
+
finally:
|
|
118
|
+
if process and process.poll() is None:
|
|
119
|
+
try:
|
|
120
|
+
process.terminate()
|
|
121
|
+
process.wait(timeout=5)
|
|
122
|
+
except Exception:
|
|
123
|
+
process.kill()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.post("/run")
|
|
127
|
+
async def run_scaffold(body: ScaffoldRequest, request: Request):
|
|
128
|
+
"""Run a scaffold template command with SSE streaming output."""
|
|
129
|
+
return StreamingResponse(
|
|
130
|
+
_stream_scaffold(body.template, body.target_path, request),
|
|
131
|
+
media_type="text/event-stream",
|
|
132
|
+
headers={
|
|
133
|
+
"Cache-Control": "no-cache",
|
|
134
|
+
"X-Accel-Buffering": "no",
|
|
135
|
+
},
|
|
136
|
+
)
|
|
@@ -7,7 +7,6 @@ The assistant can answer questions about the codebase and features
|
|
|
7
7
|
but cannot modify any files.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
-
import asyncio
|
|
11
10
|
import json
|
|
12
11
|
import logging
|
|
13
12
|
import os
|
|
@@ -27,10 +26,9 @@ from .assistant_database import (
|
|
|
27
26
|
get_messages,
|
|
28
27
|
)
|
|
29
28
|
from .chat_constants import (
|
|
30
|
-
MAX_CHAT_RATE_LIMIT_RETRIES,
|
|
31
29
|
ROOT_DIR,
|
|
32
|
-
calculate_rate_limit_backoff,
|
|
33
30
|
check_rate_limit_error,
|
|
31
|
+
safe_receive_response,
|
|
34
32
|
)
|
|
35
33
|
|
|
36
34
|
# Load environment variables from .env file if present
|
|
@@ -399,66 +397,47 @@ class AssistantChatSession:
|
|
|
399
397
|
|
|
400
398
|
full_response = ""
|
|
401
399
|
|
|
402
|
-
# Stream the response
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
|
462
441
|
|
|
463
442
|
# Store the complete response in the database
|
|
464
443
|
if full_response and self.conversation_id:
|
|
@@ -12,7 +12,7 @@ imports (``from .chat_constants import API_ENV_VARS``) continue to work.
|
|
|
12
12
|
import logging
|
|
13
13
|
import sys
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import AsyncGenerator
|
|
15
|
+
from typing import Any, AsyncGenerator
|
|
16
16
|
|
|
17
17
|
# -------------------------------------------------------------------
|
|
18
18
|
# Root directory of the autoforge project (repository root).
|
|
@@ -33,15 +33,10 @@ if _root_str not in sys.path:
|
|
|
33
33
|
# imports continue to work unchanged.
|
|
34
34
|
# -------------------------------------------------------------------
|
|
35
35
|
from env_constants import API_ENV_VARS # noqa: E402, F401
|
|
36
|
-
from rate_limit_utils import
|
|
36
|
+
from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
|
|
37
37
|
|
|
38
38
|
logger = logging.getLogger(__name__)
|
|
39
39
|
|
|
40
|
-
# -------------------------------------------------------------------
|
|
41
|
-
# Rate-limit handling for chat sessions
|
|
42
|
-
# -------------------------------------------------------------------
|
|
43
|
-
MAX_CHAT_RATE_LIMIT_RETRIES = 3
|
|
44
|
-
|
|
45
40
|
|
|
46
41
|
def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
|
|
47
42
|
"""Inspect an exception and determine if it represents a rate-limit.
|
|
@@ -49,24 +44,15 @@ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
|
|
|
49
44
|
Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
|
|
50
45
|
parsed Retry-After value when available, otherwise ``None`` (caller
|
|
51
46
|
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``).
|
|
58
47
|
"""
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
68
53
|
|
|
69
|
-
#
|
|
54
|
+
# For all other exceptions: match error text against known rate-limit patterns
|
|
55
|
+
exc_str = str(exc)
|
|
70
56
|
if is_rate_limit_error(exc_str):
|
|
71
57
|
retry = parse_retry_after(exc_str)
|
|
72
58
|
return True, retry
|
|
@@ -74,6 +60,34 @@ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
|
|
|
74
60
|
return False, None
|
|
75
61
|
|
|
76
62
|
|
|
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
|
+
|
|
77
91
|
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
|
78
92
|
"""Yield a single multimodal user message in Claude Agent SDK format.
|
|
79
93
|
|
|
@@ -23,11 +23,10 @@ from dotenv import load_dotenv
|
|
|
23
23
|
|
|
24
24
|
from ..schemas import ImageAttachment
|
|
25
25
|
from .chat_constants import (
|
|
26
|
-
MAX_CHAT_RATE_LIMIT_RETRIES,
|
|
27
26
|
ROOT_DIR,
|
|
28
|
-
calculate_rate_limit_backoff,
|
|
29
27
|
check_rate_limit_error,
|
|
30
28
|
make_multimodal_message,
|
|
29
|
+
safe_receive_response,
|
|
31
30
|
)
|
|
32
31
|
|
|
33
32
|
# Load environment variables from .env file if present
|
|
@@ -304,67 +303,32 @@ class ExpandChatSession:
|
|
|
304
303
|
else:
|
|
305
304
|
await self.client.query(message)
|
|
306
305
|
|
|
307
|
-
# Stream the response
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s")
|
|
334
|
-
yield {
|
|
335
|
-
"type": "rate_limited",
|
|
336
|
-
"retry_in": delay,
|
|
337
|
-
"attempt": _attempt + 1,
|
|
338
|
-
"max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
|
|
339
|
-
}
|
|
340
|
-
await asyncio.sleep(delay)
|
|
341
|
-
# Re-send the query before retrying receive_response
|
|
342
|
-
if attachments and len(attachments) > 0:
|
|
343
|
-
content_blocks_retry: list[dict[str, Any]] = []
|
|
344
|
-
if message:
|
|
345
|
-
content_blocks_retry.append({"type": "text", "text": message})
|
|
346
|
-
for att in attachments:
|
|
347
|
-
content_blocks_retry.append({
|
|
348
|
-
"type": "image",
|
|
349
|
-
"source": {
|
|
350
|
-
"type": "base64",
|
|
351
|
-
"media_type": att.mimeType,
|
|
352
|
-
"data": att.base64Data,
|
|
353
|
-
}
|
|
354
|
-
})
|
|
355
|
-
await self.client.query(make_multimodal_message(content_blocks_retry))
|
|
356
|
-
else:
|
|
357
|
-
await self.client.query(message)
|
|
358
|
-
continue
|
|
359
|
-
if is_rate_limit:
|
|
360
|
-
logger.error("Rate limit retries exhausted for expand chat")
|
|
361
|
-
yield {"type": "error", "content": "Rate limited. Please try again later."}
|
|
362
|
-
return
|
|
363
|
-
# Non-rate-limit MessageParseError: log and break (don't crash)
|
|
364
|
-
if type(exc).__name__ == "MessageParseError":
|
|
365
|
-
logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
|
|
366
|
-
break
|
|
367
|
-
raise
|
|
306
|
+
# Stream the response
|
|
307
|
+
try:
|
|
308
|
+
async for msg in safe_receive_response(self.client, logger):
|
|
309
|
+
msg_type = type(msg).__name__
|
|
310
|
+
|
|
311
|
+
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
|
312
|
+
for block in msg.content:
|
|
313
|
+
block_type = type(block).__name__
|
|
314
|
+
|
|
315
|
+
if block_type == "TextBlock" and hasattr(block, "text"):
|
|
316
|
+
text = block.text
|
|
317
|
+
if text:
|
|
318
|
+
yield {"type": "text", "content": text}
|
|
319
|
+
|
|
320
|
+
self.messages.append({
|
|
321
|
+
"role": "assistant",
|
|
322
|
+
"content": text,
|
|
323
|
+
"timestamp": datetime.now().isoformat()
|
|
324
|
+
})
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
is_rate_limit, _ = check_rate_limit_error(exc)
|
|
327
|
+
if is_rate_limit:
|
|
328
|
+
logger.warning(f"Rate limited: {exc}")
|
|
329
|
+
yield {"type": "error", "content": "Rate limited. Please try again later."}
|
|
330
|
+
return
|
|
331
|
+
raise
|
|
368
332
|
|
|
369
333
|
def get_features_created(self) -> int:
|
|
370
334
|
"""Get the total number of features created in this session."""
|