autoforge-ai 0.1.13 → 0.1.15
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/.claude/commands/review-pr.md +4 -4
- package/agent.py +58 -39
- package/mcp_server/feature_mcp.py +15 -2
- package/package.json +1 -1
- package/requirements-prod.txt +1 -1
- package/server/services/assistant_chat_session.py +45 -33
- package/server/services/chat_constants.py +55 -1
- package/server/services/expand_chat_session.py +31 -18
- package/server/services/spec_chat_session.py +122 -109
|
@@ -55,10 +55,10 @@ Pull request(s): $ARGUMENTS
|
|
|
55
55
|
- Reviewing large, unfocused PRs is impractical and error-prone; the review cannot provide adequate assurance for such changes
|
|
56
56
|
|
|
57
57
|
6. **Vision Alignment Check**
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
61
|
-
-
|
|
58
|
+
- **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.
|
|
59
|
+
- Read the project's `VISION.md`, `README.md`, and `CLAUDE.md` to understand the application's core purpose and mandatory architectural constraints
|
|
60
|
+
- Assess whether this PR aligns with the vision defined in `VISION.md`
|
|
61
|
+
- **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.
|
|
62
62
|
|
|
63
63
|
7. **Safety Assessment**
|
|
64
64
|
- Provide a review on whether the PR is safe to merge as-is
|
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
|
|
@@ -1049,8 +1049,21 @@ def feature_request_human_input(
|
|
|
1049
1049
|
ftype = field.get("type", "text")
|
|
1050
1050
|
if ftype not in VALID_FIELD_TYPES:
|
|
1051
1051
|
return json.dumps({"error": f"Field at index {i} has invalid type '{ftype}'. Must be one of: {', '.join(sorted(VALID_FIELD_TYPES))}"})
|
|
1052
|
-
if ftype == "select"
|
|
1053
|
-
|
|
1052
|
+
if ftype == "select":
|
|
1053
|
+
options = field.get("options")
|
|
1054
|
+
if not options or not isinstance(options, list):
|
|
1055
|
+
return json.dumps({"error": f"Field at index {i} is type 'select' but missing or invalid 'options' array"})
|
|
1056
|
+
for j, opt in enumerate(options):
|
|
1057
|
+
if not isinstance(opt, dict):
|
|
1058
|
+
return json.dumps({"error": f"Field at index {i}, option {j} must be an object with 'value' and 'label'"})
|
|
1059
|
+
if "value" not in opt or "label" not in opt:
|
|
1060
|
+
return json.dumps({"error": f"Field at index {i}, option {j} missing required 'value' or 'label'"})
|
|
1061
|
+
if not isinstance(opt["value"], str) or not opt["value"].strip():
|
|
1062
|
+
return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'value'"})
|
|
1063
|
+
if not isinstance(opt["label"], str) or not opt["label"].strip():
|
|
1064
|
+
return json.dumps({"error": f"Field at index {i}, option {j} has empty or invalid 'label'"})
|
|
1065
|
+
elif field.get("options"):
|
|
1066
|
+
return json.dumps({"error": f"Field at index {i} has 'options' but type is '{ftype}' (only 'select' uses options)"})
|
|
1054
1067
|
|
|
1055
1068
|
request_data = {
|
|
1056
1069
|
"prompt": prompt,
|
package/package.json
CHANGED
package/requirements-prod.txt
CHANGED
|
@@ -25,7 +25,11 @@ from .assistant_database import (
|
|
|
25
25
|
create_conversation,
|
|
26
26
|
get_messages,
|
|
27
27
|
)
|
|
28
|
-
from .chat_constants import
|
|
28
|
+
from .chat_constants import (
|
|
29
|
+
ROOT_DIR,
|
|
30
|
+
check_rate_limit_error,
|
|
31
|
+
safe_receive_response,
|
|
32
|
+
)
|
|
29
33
|
|
|
30
34
|
# Load environment variables from .env file if present
|
|
31
35
|
load_dotenv()
|
|
@@ -394,38 +398,46 @@ class AssistantChatSession:
|
|
|
394
398
|
full_response = ""
|
|
395
399
|
|
|
396
400
|
# Stream the response
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
429
441
|
|
|
430
442
|
# Store the complete response in the database
|
|
431
443
|
if full_response and self.conversation_id:
|
|
@@ -9,9 +9,10 @@ project root and is re-exported here for convenience so that existing
|
|
|
9
9
|
imports (``from .chat_constants import API_ENV_VARS``) continue to work.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import logging
|
|
12
13
|
import sys
|
|
13
14
|
from pathlib import Path
|
|
14
|
-
from typing import AsyncGenerator
|
|
15
|
+
from typing import Any, AsyncGenerator
|
|
15
16
|
|
|
16
17
|
# -------------------------------------------------------------------
|
|
17
18
|
# Root directory of the autoforge project (repository root).
|
|
@@ -32,6 +33,59 @@ if _root_str not in sys.path:
|
|
|
32
33
|
# imports continue to work unchanged.
|
|
33
34
|
# -------------------------------------------------------------------
|
|
34
35
|
from env_constants import API_ENV_VARS # noqa: E402, F401
|
|
36
|
+
from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
|
|
42
|
+
"""Inspect an exception and determine if it represents a rate-limit.
|
|
43
|
+
|
|
44
|
+
Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
|
|
45
|
+
parsed Retry-After value when available, otherwise ``None`` (caller
|
|
46
|
+
should use exponential backoff).
|
|
47
|
+
"""
|
|
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
|
|
53
|
+
|
|
54
|
+
# For all other exceptions: match error text against known rate-limit patterns
|
|
55
|
+
exc_str = str(exc)
|
|
56
|
+
if is_rate_limit_error(exc_str):
|
|
57
|
+
retry = parse_retry_after(exc_str)
|
|
58
|
+
return True, retry
|
|
59
|
+
|
|
60
|
+
return False, None
|
|
61
|
+
|
|
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
|
|
35
89
|
|
|
36
90
|
|
|
37
91
|
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
|
|
@@ -22,7 +22,12 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|
|
22
22
|
from dotenv import load_dotenv
|
|
23
23
|
|
|
24
24
|
from ..schemas import ImageAttachment
|
|
25
|
-
from .chat_constants import
|
|
25
|
+
from .chat_constants import (
|
|
26
|
+
ROOT_DIR,
|
|
27
|
+
check_rate_limit_error,
|
|
28
|
+
make_multimodal_message,
|
|
29
|
+
safe_receive_response,
|
|
30
|
+
)
|
|
26
31
|
|
|
27
32
|
# Load environment variables from .env file if present
|
|
28
33
|
load_dotenv()
|
|
@@ -299,23 +304,31 @@ class ExpandChatSession:
|
|
|
299
304
|
await self.client.query(message)
|
|
300
305
|
|
|
301
306
|
# Stream the response
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
319
332
|
|
|
320
333
|
def get_features_created(self) -> int:
|
|
321
334
|
"""Get the total number of features created in this session."""
|
|
@@ -19,7 +19,12 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|
|
19
19
|
from dotenv import load_dotenv
|
|
20
20
|
|
|
21
21
|
from ..schemas import ImageAttachment
|
|
22
|
-
from .chat_constants import
|
|
22
|
+
from .chat_constants import (
|
|
23
|
+
ROOT_DIR,
|
|
24
|
+
check_rate_limit_error,
|
|
25
|
+
make_multimodal_message,
|
|
26
|
+
safe_receive_response,
|
|
27
|
+
)
|
|
23
28
|
|
|
24
29
|
# Load environment variables from .env file if present
|
|
25
30
|
load_dotenv()
|
|
@@ -304,117 +309,125 @@ class SpecChatSession:
|
|
|
304
309
|
# Store paths for the completion message
|
|
305
310
|
spec_path = None
|
|
306
311
|
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
"
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
"
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
|
|
354
|
-
|
|
355
|
-
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
|
356
|
-
# Tool results - check for write confirmations and errors
|
|
357
|
-
for block in msg.content:
|
|
358
|
-
block_type = type(block).__name__
|
|
359
|
-
if block_type == "ToolResultBlock":
|
|
360
|
-
is_error = getattr(block, "is_error", False)
|
|
361
|
-
tool_use_id = getattr(block, "tool_use_id", "")
|
|
362
|
-
|
|
363
|
-
if is_error:
|
|
364
|
-
content = getattr(block, "content", "Unknown error")
|
|
365
|
-
logger.warning(f"Tool error: {content}")
|
|
366
|
-
# Clear any pending writes that failed
|
|
367
|
-
for key in pending_writes:
|
|
368
|
-
pending_write = pending_writes[key]
|
|
369
|
-
if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
|
|
370
|
-
logger.error(f"{key} write failed: {content}")
|
|
371
|
-
pending_writes[key] = None
|
|
372
|
-
else:
|
|
373
|
-
# Tool succeeded - check which file was written
|
|
374
|
-
|
|
375
|
-
# Check app_spec.txt
|
|
376
|
-
if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
|
|
377
|
-
file_path = pending_writes["app_spec"]["path"]
|
|
378
|
-
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
|
379
|
-
if full_path.exists():
|
|
380
|
-
logger.info(f"app_spec.txt verified at: {full_path}")
|
|
381
|
-
files_written["app_spec"] = True
|
|
382
|
-
spec_path = file_path
|
|
383
|
-
|
|
384
|
-
# Notify about file write (but NOT completion yet)
|
|
385
|
-
yield {
|
|
386
|
-
"type": "file_written",
|
|
387
|
-
"path": str(file_path)
|
|
312
|
+
# Stream the response
|
|
313
|
+
try:
|
|
314
|
+
async for msg in safe_receive_response(self.client, logger):
|
|
315
|
+
msg_type = type(msg).__name__
|
|
316
|
+
|
|
317
|
+
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
|
318
|
+
# Process content blocks in the assistant message
|
|
319
|
+
for block in msg.content:
|
|
320
|
+
block_type = type(block).__name__
|
|
321
|
+
|
|
322
|
+
if block_type == "TextBlock" and hasattr(block, "text"):
|
|
323
|
+
# Accumulate text and yield it
|
|
324
|
+
text = block.text
|
|
325
|
+
if text:
|
|
326
|
+
current_text += text
|
|
327
|
+
yield {"type": "text", "content": text}
|
|
328
|
+
|
|
329
|
+
# Store in message history
|
|
330
|
+
self.messages.append({
|
|
331
|
+
"role": "assistant",
|
|
332
|
+
"content": text,
|
|
333
|
+
"timestamp": datetime.now().isoformat()
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
|
337
|
+
tool_name = block.name
|
|
338
|
+
tool_input = getattr(block, "input", {})
|
|
339
|
+
tool_id = getattr(block, "id", "")
|
|
340
|
+
|
|
341
|
+
if tool_name in ("Write", "Edit"):
|
|
342
|
+
# File being written or edited - track for verification
|
|
343
|
+
file_path = tool_input.get("file_path", "")
|
|
344
|
+
|
|
345
|
+
# Track app_spec.txt
|
|
346
|
+
if "app_spec.txt" in str(file_path):
|
|
347
|
+
pending_writes["app_spec"] = {
|
|
348
|
+
"tool_id": tool_id,
|
|
349
|
+
"path": file_path
|
|
350
|
+
}
|
|
351
|
+
logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
|
|
352
|
+
|
|
353
|
+
# Track initializer_prompt.md
|
|
354
|
+
elif "initializer_prompt.md" in str(file_path):
|
|
355
|
+
pending_writes["initializer"] = {
|
|
356
|
+
"tool_id": tool_id,
|
|
357
|
+
"path": file_path
|
|
388
358
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
359
|
+
logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
|
|
360
|
+
|
|
361
|
+
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
|
362
|
+
# Tool results - check for write confirmations and errors
|
|
363
|
+
for block in msg.content:
|
|
364
|
+
block_type = type(block).__name__
|
|
365
|
+
if block_type == "ToolResultBlock":
|
|
366
|
+
is_error = getattr(block, "is_error", False)
|
|
367
|
+
tool_use_id = getattr(block, "tool_use_id", "")
|
|
368
|
+
|
|
369
|
+
if is_error:
|
|
370
|
+
content = getattr(block, "content", "Unknown error")
|
|
371
|
+
logger.warning(f"Tool error: {content}")
|
|
372
|
+
# Clear any pending writes that failed
|
|
373
|
+
for key in pending_writes:
|
|
374
|
+
pending_write = pending_writes[key]
|
|
375
|
+
if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
|
|
376
|
+
logger.error(f"{key} write failed: {content}")
|
|
377
|
+
pending_writes[key] = None
|
|
378
|
+
else:
|
|
379
|
+
# Tool succeeded - check which file was written
|
|
380
|
+
|
|
381
|
+
# Check app_spec.txt
|
|
382
|
+
if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
|
|
383
|
+
file_path = pending_writes["app_spec"]["path"]
|
|
384
|
+
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
|
385
|
+
if full_path.exists():
|
|
386
|
+
logger.info(f"app_spec.txt verified at: {full_path}")
|
|
387
|
+
files_written["app_spec"] = True
|
|
388
|
+
spec_path = file_path
|
|
389
|
+
|
|
390
|
+
# Notify about file write (but NOT completion yet)
|
|
391
|
+
yield {
|
|
392
|
+
"type": "file_written",
|
|
393
|
+
"path": str(file_path)
|
|
394
|
+
}
|
|
395
|
+
else:
|
|
396
|
+
logger.error(f"app_spec.txt not found after write: {full_path}")
|
|
397
|
+
pending_writes["app_spec"] = None
|
|
398
|
+
|
|
399
|
+
# Check initializer_prompt.md
|
|
400
|
+
if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
|
|
401
|
+
file_path = pending_writes["initializer"]["path"]
|
|
402
|
+
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
|
403
|
+
if full_path.exists():
|
|
404
|
+
logger.info(f"initializer_prompt.md verified at: {full_path}")
|
|
405
|
+
files_written["initializer"] = True
|
|
406
|
+
|
|
407
|
+
# Notify about file write
|
|
408
|
+
yield {
|
|
409
|
+
"type": "file_written",
|
|
410
|
+
"path": str(file_path)
|
|
411
|
+
}
|
|
412
|
+
else:
|
|
413
|
+
logger.error(f"initializer_prompt.md not found after write: {full_path}")
|
|
414
|
+
pending_writes["initializer"] = None
|
|
415
|
+
|
|
416
|
+
# Check if BOTH files are now written - only then signal completion
|
|
417
|
+
if files_written["app_spec"] and files_written["initializer"]:
|
|
418
|
+
logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
|
|
419
|
+
self.complete = True
|
|
402
420
|
yield {
|
|
403
|
-
"type": "
|
|
404
|
-
"path": str(
|
|
421
|
+
"type": "spec_complete",
|
|
422
|
+
"path": str(spec_path)
|
|
405
423
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
self.complete = True
|
|
414
|
-
yield {
|
|
415
|
-
"type": "spec_complete",
|
|
416
|
-
"path": str(spec_path)
|
|
417
|
-
}
|
|
424
|
+
except Exception as exc:
|
|
425
|
+
is_rate_limit, _ = check_rate_limit_error(exc)
|
|
426
|
+
if is_rate_limit:
|
|
427
|
+
logger.warning(f"Rate limited: {exc}")
|
|
428
|
+
yield {"type": "error", "content": "Rate limited. Please try again later."}
|
|
429
|
+
return
|
|
430
|
+
raise
|
|
418
431
|
|
|
419
432
|
def is_complete(self) -> bool:
|
|
420
433
|
"""Check if spec creation is complete."""
|