autoforge-ai 0.1.16 → 0.1.18

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.
@@ -176,14 +176,14 @@ Authentication:
176
176
  "--testing-batch-size",
177
177
  type=int,
178
178
  default=3,
179
- help="Number of features per testing batch (1-5, default: 3)",
179
+ help="Number of features per testing batch (1-15, default: 3)",
180
180
  )
181
181
 
182
182
  parser.add_argument(
183
183
  "--batch-size",
184
184
  type=int,
185
185
  default=3,
186
- help="Max features per coding agent batch (1-3, default: 3)",
186
+ help="Max features per coding agent batch (1-15, default: 3)",
187
187
  )
188
188
 
189
189
  return parser.parse_args()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autoforge-ai",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Autonomous coding agent with web UI - build complete apps with AI",
5
5
  "license": "AGPL-3.0",
6
6
  "bin": {
@@ -131,7 +131,7 @@ def _dump_database_state(feature_dicts: list[dict], label: str = ""):
131
131
  MAX_PARALLEL_AGENTS = 5
132
132
  MAX_TOTAL_AGENTS = 10
133
133
  DEFAULT_CONCURRENCY = 3
134
- DEFAULT_TESTING_BATCH_SIZE = 3 # Number of features per testing batch (1-5)
134
+ DEFAULT_TESTING_BATCH_SIZE = 3 # Number of features per testing batch (1-15)
135
135
  POLL_INTERVAL = 5 # seconds between checking for ready features
136
136
  MAX_FEATURE_RETRIES = 3 # Maximum times to retry a failed feature
137
137
  INITIALIZER_TIMEOUT = 1800 # 30 minutes timeout for initializer
@@ -168,7 +168,7 @@ class ParallelOrchestrator:
168
168
  yolo_mode: Whether to run in YOLO mode (skip testing agents entirely)
169
169
  testing_agent_ratio: Number of regression testing agents to maintain (0-3).
170
170
  0 = disabled, 1-3 = maintain that many testing agents running independently.
171
- testing_batch_size: Number of features to include per testing session (1-5).
171
+ testing_batch_size: Number of features to include per testing session (1-15).
172
172
  Each testing agent receives this many features to regression test.
173
173
  on_output: Callback for agent output (feature_id, line)
174
174
  on_status: Callback for agent status changes (feature_id, status)
@@ -178,8 +178,8 @@ class ParallelOrchestrator:
178
178
  self.model = model
179
179
  self.yolo_mode = yolo_mode
180
180
  self.testing_agent_ratio = min(max(testing_agent_ratio, 0), 3) # Clamp 0-3
181
- self.testing_batch_size = min(max(testing_batch_size, 1), 5) # Clamp 1-5
182
- self.batch_size = min(max(batch_size, 1), 3) # Clamp 1-3
181
+ self.testing_batch_size = min(max(testing_batch_size, 1), 15) # Clamp 1-15
182
+ self.batch_size = min(max(batch_size, 1), 15) # Clamp 1-15
183
183
  self.on_output = on_output
184
184
  self.on_status = on_status
185
185
 
@@ -12,3 +12,7 @@ aiofiles>=24.0.0
12
12
  apscheduler>=3.10.0,<4.0.0
13
13
  pywinpty>=2.0.0; sys_platform == "win32"
14
14
  pyyaml>=6.0.0
15
+ python-docx>=1.1.0
16
+ openpyxl>=3.1.0
17
+ PyPDF2>=3.0.0
18
+ python-pptx>=1.0.0
@@ -17,11 +17,11 @@ from ..utils.project_helpers import get_project_path as _get_project_path
17
17
  from ..utils.validation import validate_project_name
18
18
 
19
19
 
20
- def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
20
+ def _get_settings_defaults() -> tuple[bool, str, int, bool, int, int]:
21
21
  """Get defaults from global settings.
22
22
 
23
23
  Returns:
24
- Tuple of (yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size)
24
+ Tuple of (yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size, testing_batch_size)
25
25
  """
26
26
  import sys
27
27
  root = Path(__file__).parent.parent.parent
@@ -47,7 +47,12 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool, int]:
47
47
  except (ValueError, TypeError):
48
48
  batch_size = 3
49
49
 
50
- return yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size
50
+ try:
51
+ testing_batch_size = int(settings.get("testing_batch_size", "3"))
52
+ except (ValueError, TypeError):
53
+ testing_batch_size = 3
54
+
55
+ return yolo_mode, model, testing_agent_ratio, playwright_headless, batch_size, testing_batch_size
51
56
 
52
57
 
53
58
  router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
@@ -96,7 +101,7 @@ async def start_agent(
96
101
  manager = get_project_manager(project_name)
97
102
 
98
103
  # Get defaults from global settings if not provided in request
99
- default_yolo, default_model, default_testing_ratio, playwright_headless, default_batch_size = _get_settings_defaults()
104
+ default_yolo, default_model, default_testing_ratio, playwright_headless, default_batch_size, default_testing_batch_size = _get_settings_defaults()
100
105
 
101
106
  yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
102
107
  model = request.model if request.model else default_model
@@ -104,6 +109,7 @@ async def start_agent(
104
109
  testing_agent_ratio = request.testing_agent_ratio if request.testing_agent_ratio is not None else default_testing_ratio
105
110
 
106
111
  batch_size = default_batch_size
112
+ testing_batch_size = default_testing_batch_size
107
113
 
108
114
  success, message = await manager.start(
109
115
  yolo_mode=yolo_mode,
@@ -112,6 +118,7 @@ async def start_agent(
112
118
  testing_agent_ratio=testing_agent_ratio,
113
119
  playwright_headless=playwright_headless,
114
120
  batch_size=batch_size,
121
+ testing_batch_size=testing_batch_size,
115
122
  )
116
123
 
117
124
  # Notify scheduler of manual start (to prevent auto-stop during scheduled window)
@@ -13,7 +13,7 @@ from typing import Optional
13
13
  from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
14
14
  from pydantic import BaseModel, ValidationError
15
15
 
16
- from ..schemas import ImageAttachment
16
+ from ..schemas import FileAttachment
17
17
  from ..services.expand_chat_session import (
18
18
  ExpandChatSession,
19
19
  create_expand_session,
@@ -181,12 +181,12 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str):
181
181
  user_content = message.get("content", "").strip()
182
182
 
183
183
  # Parse attachments if present
184
- attachments: list[ImageAttachment] = []
184
+ attachments: list[FileAttachment] = []
185
185
  raw_attachments = message.get("attachments", [])
186
186
  if raw_attachments:
187
187
  try:
188
188
  for raw_att in raw_attachments:
189
- attachments.append(ImageAttachment(**raw_att))
189
+ attachments.append(FileAttachment(**raw_att))
190
190
  except (ValidationError, Exception) as e:
191
191
  logger.warning(f"Invalid attachment data: {e}")
192
192
  await websocket.send_json({
@@ -113,6 +113,7 @@ async def get_settings():
113
113
  testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
114
114
  playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
115
115
  batch_size=_parse_int(all_settings.get("batch_size"), 3),
116
+ testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
116
117
  api_provider=api_provider,
117
118
  api_base_url=all_settings.get("api_base_url"),
118
119
  api_has_auth_token=bool(all_settings.get("api_auth_token")),
@@ -138,6 +139,9 @@ async def update_settings(update: SettingsUpdate):
138
139
  if update.batch_size is not None:
139
140
  set_setting("batch_size", str(update.batch_size))
140
141
 
142
+ if update.testing_batch_size is not None:
143
+ set_setting("testing_batch_size", str(update.testing_batch_size))
144
+
141
145
  # API provider settings
142
146
  if update.api_provider is not None:
143
147
  old_provider = get_setting("api_provider", "claude")
@@ -177,6 +181,7 @@ async def update_settings(update: SettingsUpdate):
177
181
  testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
178
182
  playwright_headless=_parse_bool(all_settings.get("playwright_headless"), default=True),
179
183
  batch_size=_parse_int(all_settings.get("batch_size"), 3),
184
+ testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
180
185
  api_provider=api_provider,
181
186
  api_base_url=all_settings.get("api_base_url"),
182
187
  api_has_auth_token=bool(all_settings.get("api_auth_token")),
@@ -12,7 +12,7 @@ from typing import Optional
12
12
  from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
13
13
  from pydantic import BaseModel, ValidationError
14
14
 
15
- from ..schemas import ImageAttachment
15
+ from ..schemas import FileAttachment
16
16
  from ..services.spec_chat_session import (
17
17
  SpecChatSession,
18
18
  create_session,
@@ -242,12 +242,12 @@ async def spec_chat_websocket(websocket: WebSocket, project_name: str):
242
242
  user_content = message.get("content", "").strip()
243
243
 
244
244
  # Parse attachments if present
245
- attachments: list[ImageAttachment] = []
245
+ attachments: list[FileAttachment] = []
246
246
  raw_attachments = message.get("attachments", [])
247
247
  if raw_attachments:
248
248
  try:
249
249
  for raw_att in raw_attachments:
250
- attachments.append(ImageAttachment(**raw_att))
250
+ attachments.append(FileAttachment(**raw_att))
251
251
  except (ValidationError, Exception) as e:
252
252
  logger.warning(f"Invalid attachment data: {e}")
253
253
  await websocket.send_json({
package/server/schemas.py CHANGED
@@ -11,7 +11,7 @@ from datetime import datetime
11
11
  from pathlib import Path
12
12
  from typing import Literal
13
13
 
14
- from pydantic import BaseModel, Field, field_validator
14
+ from pydantic import BaseModel, Field, field_validator, model_validator
15
15
 
16
16
  # Import model constants from registry (single source of truth)
17
17
  _root = Path(__file__).parent.parent
@@ -331,36 +331,61 @@ class WSAgentUpdateMessage(BaseModel):
331
331
 
332
332
 
333
333
  # ============================================================================
334
- # Spec Chat Schemas
334
+ # Chat Attachment Schemas
335
335
  # ============================================================================
336
336
 
337
- # Maximum image file size: 5 MB
338
- MAX_IMAGE_SIZE = 5 * 1024 * 1024
337
+ # Size limits
338
+ MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5 MB for images
339
+ MAX_DOCUMENT_SIZE = 20 * 1024 * 1024 # 20 MB for documents
339
340
 
341
+ _IMAGE_MIME_TYPES = {'image/jpeg', 'image/png'}
340
342
 
341
- class ImageAttachment(BaseModel):
342
- """Image attachment from client for spec creation chat."""
343
+
344
+ class FileAttachment(BaseModel):
345
+ """File attachment from client for spec creation / expand project chat."""
343
346
  filename: str = Field(..., min_length=1, max_length=255)
344
- mimeType: Literal['image/jpeg', 'image/png']
347
+ mimeType: Literal[
348
+ 'image/jpeg', 'image/png',
349
+ 'text/plain', 'text/markdown', 'text/csv',
350
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
351
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
352
+ 'application/pdf',
353
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
354
+ ]
345
355
  base64Data: str
346
356
 
347
357
  @field_validator('base64Data')
348
358
  @classmethod
349
- def validate_base64_and_size(cls, v: str) -> str:
350
- """Validate that base64 data is valid and within size limit."""
359
+ def validate_base64(cls, v: str) -> str:
360
+ """Validate that base64 data is decodable."""
351
361
  try:
352
- decoded = base64.b64decode(v)
353
- if len(decoded) > MAX_IMAGE_SIZE:
354
- raise ValueError(
355
- f'Image size ({len(decoded) / (1024 * 1024):.1f} MB) exceeds '
356
- f'maximum of {MAX_IMAGE_SIZE // (1024 * 1024)} MB'
357
- )
362
+ base64.b64decode(v)
358
363
  return v
359
364
  except Exception as e:
360
- if 'Image size' in str(e):
361
- raise
362
365
  raise ValueError(f'Invalid base64 data: {e}')
363
366
 
367
+ @model_validator(mode='after')
368
+ def validate_size(self) -> 'FileAttachment':
369
+ """Validate file size based on MIME type."""
370
+ try:
371
+ decoded = base64.b64decode(self.base64Data)
372
+ except Exception:
373
+ return self # Already caught by field validator
374
+
375
+ if self.mimeType in _IMAGE_MIME_TYPES:
376
+ max_size = MAX_IMAGE_SIZE
377
+ label = "Image"
378
+ else:
379
+ max_size = MAX_DOCUMENT_SIZE
380
+ label = "Document"
381
+
382
+ if len(decoded) > max_size:
383
+ raise ValueError(
384
+ f'{label} size ({len(decoded) / (1024 * 1024):.1f} MB) exceeds '
385
+ f'maximum of {max_size // (1024 * 1024)} MB'
386
+ )
387
+ return self
388
+
364
389
 
365
390
  # ============================================================================
366
391
  # Filesystem Schemas
@@ -444,7 +469,8 @@ class SettingsResponse(BaseModel):
444
469
  ollama_mode: bool = False # True when api_provider is "ollama"
445
470
  testing_agent_ratio: int = 1 # Regression testing agents (0-3)
446
471
  playwright_headless: bool = True
447
- batch_size: int = 3 # Features per coding agent batch (1-3)
472
+ batch_size: int = 3 # Features per coding agent batch (1-15)
473
+ testing_batch_size: int = 3 # Features per testing agent batch (1-15)
448
474
  api_provider: str = "claude"
449
475
  api_base_url: str | None = None
450
476
  api_has_auth_token: bool = False # Never expose actual token
@@ -463,7 +489,8 @@ class SettingsUpdate(BaseModel):
463
489
  model: str | None = None
464
490
  testing_agent_ratio: int | None = None # 0-3
465
491
  playwright_headless: bool | None = None
466
- batch_size: int | None = None # Features per agent batch (1-3)
492
+ batch_size: int | None = None # Features per agent batch (1-15)
493
+ testing_batch_size: int | None = None # Features per testing agent batch (1-15)
467
494
  api_provider: str | None = None
468
495
  api_base_url: str | None = Field(None, max_length=500)
469
496
  api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
@@ -500,8 +527,15 @@ class SettingsUpdate(BaseModel):
500
527
  @field_validator('batch_size')
501
528
  @classmethod
502
529
  def validate_batch_size(cls, v: int | None) -> int | None:
503
- if v is not None and (v < 1 or v > 3):
504
- raise ValueError("batch_size must be between 1 and 3")
530
+ if v is not None and (v < 1 or v > 15):
531
+ raise ValueError("batch_size must be between 1 and 15")
532
+ return v
533
+
534
+ @field_validator('testing_batch_size')
535
+ @classmethod
536
+ def validate_testing_batch_size(cls, v: int | None) -> int | None:
537
+ if v is not None and (v < 1 or v > 15):
538
+ raise ValueError("testing_batch_size must be between 1 and 15")
505
539
  return v
506
540
 
507
541
 
@@ -35,6 +35,13 @@ if _root_str not in sys.path:
35
35
  from env_constants import API_ENV_VARS # noqa: E402, F401
36
36
  from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
37
37
 
38
+ from ..schemas import FileAttachment
39
+ from ..utils.document_extraction import (
40
+ extract_text_from_document,
41
+ is_document,
42
+ is_image,
43
+ )
44
+
38
45
  logger = logging.getLogger(__name__)
39
46
 
40
47
 
@@ -88,6 +95,35 @@ async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenera
88
95
  raise
89
96
 
90
97
 
98
+ def build_attachment_content_blocks(attachments: list[FileAttachment]) -> list[dict]:
99
+ """Convert FileAttachment objects to Claude API content blocks.
100
+
101
+ Images become image content blocks (passed directly to Claude's vision).
102
+ Documents are extracted to text and become text content blocks.
103
+
104
+ Raises:
105
+ DocumentExtractionError: If a document cannot be read.
106
+ """
107
+ blocks: list[dict] = []
108
+ for att in attachments:
109
+ if is_image(att.mimeType):
110
+ blocks.append({
111
+ "type": "image",
112
+ "source": {
113
+ "type": "base64",
114
+ "media_type": att.mimeType,
115
+ "data": att.base64Data,
116
+ }
117
+ })
118
+ elif is_document(att.mimeType):
119
+ text = extract_text_from_document(att.base64Data, att.mimeType, att.filename)
120
+ blocks.append({
121
+ "type": "text",
122
+ "text": f"[Content of uploaded file: {att.filename}]\n\n{text}",
123
+ })
124
+ return blocks
125
+
126
+
91
127
  async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
92
128
  """Yield a single multimodal user message in Claude Agent SDK format.
93
129
 
@@ -21,9 +21,11 @@ from typing import Any, AsyncGenerator, Optional
21
21
  from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
22
22
  from dotenv import load_dotenv
23
23
 
24
- from ..schemas import ImageAttachment
24
+ from ..schemas import FileAttachment
25
+ from ..utils.document_extraction import DocumentExtractionError
25
26
  from .chat_constants import (
26
27
  ROOT_DIR,
28
+ build_attachment_content_blocks,
27
29
  check_rate_limit_error,
28
30
  make_multimodal_message,
29
31
  safe_receive_response,
@@ -226,7 +228,7 @@ class ExpandChatSession:
226
228
  async def send_message(
227
229
  self,
228
230
  user_message: str,
229
- attachments: list[ImageAttachment] | None = None
231
+ attachments: list[FileAttachment] | None = None
230
232
  ) -> AsyncGenerator[dict, None]:
231
233
  """
232
234
  Send user message and stream Claude's response.
@@ -273,7 +275,7 @@ class ExpandChatSession:
273
275
  async def _query_claude(
274
276
  self,
275
277
  message: str,
276
- attachments: list[ImageAttachment] | None = None
278
+ attachments: list[FileAttachment] | None = None
277
279
  ) -> AsyncGenerator[dict, None]:
278
280
  """
279
281
  Internal method to query Claude and stream responses.
@@ -289,17 +291,16 @@ class ExpandChatSession:
289
291
  content_blocks: list[dict[str, Any]] = []
290
292
  if message:
291
293
  content_blocks.append({"type": "text", "text": message})
292
- for att in attachments:
293
- content_blocks.append({
294
- "type": "image",
295
- "source": {
296
- "type": "base64",
297
- "media_type": att.mimeType,
298
- "data": att.base64Data,
299
- }
300
- })
294
+
295
+ # Add attachment blocks (images as image blocks, documents as extracted text)
296
+ try:
297
+ content_blocks.extend(build_attachment_content_blocks(attachments))
298
+ except DocumentExtractionError as e:
299
+ yield {"type": "error", "content": str(e)}
300
+ return
301
+
301
302
  await self.client.query(make_multimodal_message(content_blocks))
302
- logger.info(f"Sent multimodal message with {len(attachments)} image(s)")
303
+ logger.info(f"Sent multimodal message with {len(attachments)} attachment(s)")
303
304
  else:
304
305
  await self.client.query(message)
305
306
 
@@ -374,6 +374,7 @@ class AgentProcessManager:
374
374
  testing_agent_ratio: int = 1,
375
375
  playwright_headless: bool = True,
376
376
  batch_size: int = 3,
377
+ testing_batch_size: int = 3,
377
378
  ) -> tuple[bool, str]:
378
379
  """
379
380
  Start the agent as a subprocess.
@@ -440,6 +441,9 @@ class AgentProcessManager:
440
441
  # Add --batch-size flag for multi-feature batching
441
442
  cmd.extend(["--batch-size", str(batch_size)])
442
443
 
444
+ # Add --testing-batch-size flag for testing agent batching
445
+ cmd.extend(["--testing-batch-size", str(testing_batch_size)])
446
+
443
447
  # Apply headless setting to .playwright/cli.config.json so playwright-cli
444
448
  # picks it up (the only mechanism it supports for headless control)
445
449
  self._apply_playwright_headless(playwright_headless)
@@ -18,9 +18,11 @@ from typing import Any, AsyncGenerator, Optional
18
18
  from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
19
19
  from dotenv import load_dotenv
20
20
 
21
- from ..schemas import ImageAttachment
21
+ from ..schemas import FileAttachment
22
+ from ..utils.document_extraction import DocumentExtractionError
22
23
  from .chat_constants import (
23
24
  ROOT_DIR,
25
+ build_attachment_content_blocks,
24
26
  check_rate_limit_error,
25
27
  make_multimodal_message,
26
28
  safe_receive_response,
@@ -201,7 +203,7 @@ class SpecChatSession:
201
203
  async def send_message(
202
204
  self,
203
205
  user_message: str,
204
- attachments: list[ImageAttachment] | None = None
206
+ attachments: list[FileAttachment] | None = None
205
207
  ) -> AsyncGenerator[dict, None]:
206
208
  """
207
209
  Send user message and stream Claude's response.
@@ -247,7 +249,7 @@ class SpecChatSession:
247
249
  async def _query_claude(
248
250
  self,
249
251
  message: str,
250
- attachments: list[ImageAttachment] | None = None
252
+ attachments: list[FileAttachment] | None = None
251
253
  ) -> AsyncGenerator[dict, None]:
252
254
  """
253
255
  Internal method to query Claude and stream responses.
@@ -273,21 +275,17 @@ class SpecChatSession:
273
275
  if message:
274
276
  content_blocks.append({"type": "text", "text": message})
275
277
 
276
- # Add image blocks
277
- for att in attachments:
278
- content_blocks.append({
279
- "type": "image",
280
- "source": {
281
- "type": "base64",
282
- "media_type": att.mimeType,
283
- "data": att.base64Data,
284
- }
285
- })
278
+ # Add attachment blocks (images as image blocks, documents as extracted text)
279
+ try:
280
+ content_blocks.extend(build_attachment_content_blocks(attachments))
281
+ except DocumentExtractionError as e:
282
+ yield {"type": "error", "content": str(e)}
283
+ return
286
284
 
287
285
  # Send multimodal content to Claude using async generator format
288
286
  # The SDK's query() accepts AsyncIterable[dict] for custom message formats
289
287
  await self.client.query(make_multimodal_message(content_blocks))
290
- logger.info(f"Sent multimodal message with {len(attachments)} image(s)")
288
+ logger.info(f"Sent multimodal message with {len(attachments)} attachment(s)")
291
289
  else:
292
290
  # Text-only message: use string format
293
291
  await self.client.query(message)