autoforge-ai 0.1.21 → 0.1.22
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/templates/auto_improve_prompt.template.md +160 -0
- package/agent.py +14 -2
- package/autonomous_agent_demo.py +27 -1
- package/client.py +7 -2
- package/package.json +1 -1
- package/prompts.py +24 -0
- package/registry.py +143 -15
- package/server/routers/projects.py +58 -10
- package/server/routers/settings.py +8 -0
- package/server/schemas.py +21 -2
- package/server/services/assistant_chat_session.py +3 -1
- package/server/services/expand_chat_session.py +3 -1
- package/server/services/process_manager.py +17 -0
- package/server/services/scheduler_service.py +154 -4
- package/server/services/spec_chat_session.py +3 -1
- package/ui/dist/assets/index-BvNxzjlP.js +96 -0
- package/ui/dist/assets/index-hSFqqmJF.css +1 -0
- package/ui/dist/assets/{vendor-utils-_RSkPk2f.js → vendor-utils-D_WdX4_S.js} +1 -1
- package/ui/dist/index.html +3 -3
- package/ui/dist/assets/index-BB9FkE5a.js +0 -96
- package/ui/dist/assets/index-CaH_F11g.css +0 -1
|
@@ -66,10 +66,12 @@ def _get_registry_functions():
|
|
|
66
66
|
sys.path.insert(0, str(root))
|
|
67
67
|
|
|
68
68
|
from registry import (
|
|
69
|
+
get_project_auto_improve,
|
|
69
70
|
get_project_concurrency,
|
|
70
71
|
get_project_path,
|
|
71
72
|
list_registered_projects,
|
|
72
73
|
register_project,
|
|
74
|
+
set_project_auto_improve,
|
|
73
75
|
set_project_concurrency,
|
|
74
76
|
unregister_project,
|
|
75
77
|
validate_project_path,
|
|
@@ -82,6 +84,8 @@ def _get_registry_functions():
|
|
|
82
84
|
validate_project_path,
|
|
83
85
|
get_project_concurrency,
|
|
84
86
|
set_project_concurrency,
|
|
87
|
+
get_project_auto_improve,
|
|
88
|
+
set_project_auto_improve,
|
|
85
89
|
)
|
|
86
90
|
|
|
87
91
|
|
|
@@ -118,7 +122,7 @@ async def list_projects():
|
|
|
118
122
|
_init_imports()
|
|
119
123
|
assert _check_spec_exists is not None # guaranteed by _init_imports()
|
|
120
124
|
(_, _, _, list_registered_projects, validate_project_path,
|
|
121
|
-
get_project_concurrency, _) = _get_registry_functions()
|
|
125
|
+
get_project_concurrency, _, _, _) = _get_registry_functions()
|
|
122
126
|
|
|
123
127
|
projects = list_registered_projects()
|
|
124
128
|
result = []
|
|
@@ -140,6 +144,10 @@ async def list_projects():
|
|
|
140
144
|
has_spec=has_spec,
|
|
141
145
|
stats=stats,
|
|
142
146
|
default_concurrency=info.get("default_concurrency", 3),
|
|
147
|
+
auto_improve_enabled=bool(info.get("auto_improve_enabled", False)),
|
|
148
|
+
auto_improve_interval_minutes=int(
|
|
149
|
+
info.get("auto_improve_interval_minutes", 10) or 10
|
|
150
|
+
),
|
|
143
151
|
))
|
|
144
152
|
|
|
145
153
|
return result
|
|
@@ -151,7 +159,7 @@ async def create_project(project: ProjectCreate):
|
|
|
151
159
|
_init_imports()
|
|
152
160
|
assert _scaffold_project_prompts is not None # guaranteed by _init_imports()
|
|
153
161
|
(register_project, _, get_project_path, list_registered_projects,
|
|
154
|
-
_, _, _) = _get_registry_functions()
|
|
162
|
+
_, _, _, _, _) = _get_registry_functions()
|
|
155
163
|
|
|
156
164
|
name = validate_project_name(project.name)
|
|
157
165
|
project_path = Path(project.path).resolve()
|
|
@@ -232,7 +240,8 @@ async def get_project(name: str):
|
|
|
232
240
|
_init_imports()
|
|
233
241
|
assert _check_spec_exists is not None # guaranteed by _init_imports()
|
|
234
242
|
assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
|
|
235
|
-
(_, _, get_project_path, _, _, get_project_concurrency, _
|
|
243
|
+
(_, _, get_project_path, _, _, get_project_concurrency, _,
|
|
244
|
+
get_project_auto_improve, _) = _get_registry_functions()
|
|
236
245
|
|
|
237
246
|
name = validate_project_name(name)
|
|
238
247
|
project_dir = get_project_path(name)
|
|
@@ -246,6 +255,7 @@ async def get_project(name: str):
|
|
|
246
255
|
has_spec = _check_spec_exists(project_dir)
|
|
247
256
|
stats = get_project_stats(project_dir)
|
|
248
257
|
prompts_dir = _get_project_prompts_dir(project_dir)
|
|
258
|
+
ai_enabled, ai_interval = get_project_auto_improve(name)
|
|
249
259
|
|
|
250
260
|
return ProjectDetail(
|
|
251
261
|
name=name,
|
|
@@ -254,6 +264,8 @@ async def get_project(name: str):
|
|
|
254
264
|
stats=stats,
|
|
255
265
|
prompts_dir=str(prompts_dir),
|
|
256
266
|
default_concurrency=get_project_concurrency(name),
|
|
267
|
+
auto_improve_enabled=ai_enabled,
|
|
268
|
+
auto_improve_interval_minutes=ai_interval,
|
|
257
269
|
)
|
|
258
270
|
|
|
259
271
|
|
|
@@ -267,7 +279,7 @@ async def delete_project(name: str, delete_files: bool = False):
|
|
|
267
279
|
delete_files: If True, also delete the project directory and files
|
|
268
280
|
"""
|
|
269
281
|
_init_imports()
|
|
270
|
-
(_, unregister_project, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
282
|
+
(_, unregister_project, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
|
|
271
283
|
|
|
272
284
|
name = validate_project_name(name)
|
|
273
285
|
project_dir = get_project_path(name)
|
|
@@ -304,7 +316,7 @@ async def get_project_prompts(name: str):
|
|
|
304
316
|
"""Get the content of project prompt files."""
|
|
305
317
|
_init_imports()
|
|
306
318
|
assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
|
|
307
|
-
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
319
|
+
(_, _, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
|
|
308
320
|
|
|
309
321
|
name = validate_project_name(name)
|
|
310
322
|
project_dir = get_project_path(name)
|
|
@@ -338,7 +350,7 @@ async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
|
|
|
338
350
|
"""Update project prompt files."""
|
|
339
351
|
_init_imports()
|
|
340
352
|
assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
|
|
341
|
-
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
353
|
+
(_, _, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
|
|
342
354
|
|
|
343
355
|
name = validate_project_name(name)
|
|
344
356
|
project_dir = get_project_path(name)
|
|
@@ -368,7 +380,7 @@ async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
|
|
|
368
380
|
async def get_project_stats_endpoint(name: str):
|
|
369
381
|
"""Get current progress statistics for a project."""
|
|
370
382
|
_init_imports()
|
|
371
|
-
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
383
|
+
(_, _, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
|
|
372
384
|
|
|
373
385
|
name = validate_project_name(name)
|
|
374
386
|
project_dir = get_project_path(name)
|
|
@@ -395,7 +407,7 @@ async def reset_project(name: str, full_reset: bool = False):
|
|
|
395
407
|
Dictionary with list of deleted files and reset type
|
|
396
408
|
"""
|
|
397
409
|
_init_imports()
|
|
398
|
-
(_, _, get_project_path, _, _, _, _) = _get_registry_functions()
|
|
410
|
+
(_, _, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
|
|
399
411
|
|
|
400
412
|
name = validate_project_name(name)
|
|
401
413
|
project_dir = get_project_path(name)
|
|
@@ -487,12 +499,13 @@ async def reset_project(name: str, full_reset: bool = False):
|
|
|
487
499
|
|
|
488
500
|
@router.patch("/{name}/settings", response_model=ProjectDetail)
|
|
489
501
|
async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
|
|
490
|
-
"""Update project-level settings (concurrency, etc.)."""
|
|
502
|
+
"""Update project-level settings (concurrency, auto-improve, etc.)."""
|
|
491
503
|
_init_imports()
|
|
492
504
|
assert _check_spec_exists is not None # guaranteed by _init_imports()
|
|
493
505
|
assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
|
|
494
506
|
(_, _, get_project_path, _, _, get_project_concurrency,
|
|
495
|
-
set_project_concurrency
|
|
507
|
+
set_project_concurrency, get_project_auto_improve,
|
|
508
|
+
set_project_auto_improve) = _get_registry_functions()
|
|
496
509
|
|
|
497
510
|
name = validate_project_name(name)
|
|
498
511
|
project_dir = get_project_path(name)
|
|
@@ -509,10 +522,43 @@ async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
|
|
|
509
522
|
if not success:
|
|
510
523
|
raise HTTPException(status_code=500, detail="Failed to update concurrency")
|
|
511
524
|
|
|
525
|
+
# Update auto-improve config if either field was provided
|
|
526
|
+
auto_improve_touched = (
|
|
527
|
+
settings.auto_improve_enabled is not None
|
|
528
|
+
or settings.auto_improve_interval_minutes is not None
|
|
529
|
+
)
|
|
530
|
+
if auto_improve_touched:
|
|
531
|
+
try:
|
|
532
|
+
success = set_project_auto_improve(
|
|
533
|
+
name,
|
|
534
|
+
enabled=settings.auto_improve_enabled,
|
|
535
|
+
interval_minutes=settings.auto_improve_interval_minutes,
|
|
536
|
+
)
|
|
537
|
+
except ValueError as e:
|
|
538
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
539
|
+
if not success:
|
|
540
|
+
raise HTTPException(status_code=500, detail="Failed to update auto-improve settings")
|
|
541
|
+
|
|
542
|
+
# Sync the scheduler with the new state.
|
|
543
|
+
from ..services.scheduler_service import get_scheduler
|
|
544
|
+
scheduler = get_scheduler()
|
|
545
|
+
ai_enabled, ai_interval = get_project_auto_improve(name)
|
|
546
|
+
if ai_enabled:
|
|
547
|
+
try:
|
|
548
|
+
await scheduler.register_auto_improve(name, project_dir, ai_interval)
|
|
549
|
+
except Exception as e:
|
|
550
|
+
raise HTTPException(
|
|
551
|
+
status_code=500,
|
|
552
|
+
detail=f"Failed to register auto-improve schedule: {e}",
|
|
553
|
+
)
|
|
554
|
+
else:
|
|
555
|
+
scheduler.remove_auto_improve(name)
|
|
556
|
+
|
|
512
557
|
# Return updated project details
|
|
513
558
|
has_spec = _check_spec_exists(project_dir)
|
|
514
559
|
stats = get_project_stats(project_dir)
|
|
515
560
|
prompts_dir = _get_project_prompts_dir(project_dir)
|
|
561
|
+
ai_enabled, ai_interval = get_project_auto_improve(name)
|
|
516
562
|
|
|
517
563
|
return ProjectDetail(
|
|
518
564
|
name=name,
|
|
@@ -521,4 +567,6 @@ async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
|
|
|
521
567
|
stats=stats,
|
|
522
568
|
prompts_dir=str(prompts_dir),
|
|
523
569
|
default_concurrency=get_project_concurrency(name),
|
|
570
|
+
auto_improve_enabled=ai_enabled,
|
|
571
|
+
auto_improve_interval_minutes=ai_interval,
|
|
524
572
|
)
|
|
@@ -26,6 +26,7 @@ from registry import (
|
|
|
26
26
|
AVAILABLE_MODELS,
|
|
27
27
|
DEFAULT_MODEL,
|
|
28
28
|
get_all_settings,
|
|
29
|
+
get_effort_setting,
|
|
29
30
|
get_setting,
|
|
30
31
|
set_setting,
|
|
31
32
|
)
|
|
@@ -95,6 +96,8 @@ def _parse_bool(value: str | None, default: bool = False) -> bool:
|
|
|
95
96
|
return value.lower() == "true"
|
|
96
97
|
|
|
97
98
|
|
|
99
|
+
|
|
100
|
+
|
|
98
101
|
@router.get("", response_model=SettingsResponse)
|
|
99
102
|
async def get_settings():
|
|
100
103
|
"""Get current global settings."""
|
|
@@ -114,6 +117,7 @@ async def get_settings():
|
|
|
114
117
|
playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
|
|
115
118
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
|
116
119
|
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
|
|
120
|
+
effort=get_effort_setting(),
|
|
117
121
|
api_provider=api_provider,
|
|
118
122
|
api_base_url=all_settings.get("api_base_url"),
|
|
119
123
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
|
@@ -142,6 +146,9 @@ async def update_settings(update: SettingsUpdate):
|
|
|
142
146
|
if update.testing_batch_size is not None:
|
|
143
147
|
set_setting("testing_batch_size", str(update.testing_batch_size))
|
|
144
148
|
|
|
149
|
+
if update.effort is not None:
|
|
150
|
+
set_setting("effort", update.effort)
|
|
151
|
+
|
|
145
152
|
# API provider settings
|
|
146
153
|
if update.api_provider is not None:
|
|
147
154
|
old_provider = get_setting("api_provider", "claude")
|
|
@@ -182,6 +189,7 @@ async def update_settings(update: SettingsUpdate):
|
|
|
182
189
|
playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
|
|
183
190
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
|
184
191
|
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
|
|
192
|
+
effort=get_effort_setting(),
|
|
185
193
|
api_provider=api_provider,
|
|
186
194
|
api_base_url=all_settings.get("api_base_url"),
|
|
187
195
|
api_has_auth_token=bool(all_settings.get("api_auth_token")),
|
package/server/schemas.py
CHANGED
|
@@ -18,7 +18,7 @@ _root = Path(__file__).parent.parent
|
|
|
18
18
|
if str(_root) not in sys.path:
|
|
19
19
|
sys.path.insert(0, str(_root))
|
|
20
20
|
|
|
21
|
-
from registry import DEFAULT_MODEL, VALID_MODELS
|
|
21
|
+
from registry import DEFAULT_MODEL, LEGACY_MODEL_MAP, VALID_MODELS
|
|
22
22
|
|
|
23
23
|
# ============================================================================
|
|
24
24
|
# Project Schemas
|
|
@@ -46,6 +46,8 @@ class ProjectSummary(BaseModel):
|
|
|
46
46
|
has_spec: bool
|
|
47
47
|
stats: ProjectStats
|
|
48
48
|
default_concurrency: int = 3
|
|
49
|
+
auto_improve_enabled: bool = False
|
|
50
|
+
auto_improve_interval_minutes: int = 10
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
class ProjectDetail(BaseModel):
|
|
@@ -56,6 +58,8 @@ class ProjectDetail(BaseModel):
|
|
|
56
58
|
stats: ProjectStats
|
|
57
59
|
prompts_dir: str
|
|
58
60
|
default_concurrency: int = 3
|
|
61
|
+
auto_improve_enabled: bool = False
|
|
62
|
+
auto_improve_interval_minutes: int = 10
|
|
59
63
|
|
|
60
64
|
|
|
61
65
|
class ProjectPrompts(BaseModel):
|
|
@@ -75,6 +79,8 @@ class ProjectPromptsUpdate(BaseModel):
|
|
|
75
79
|
class ProjectSettingsUpdate(BaseModel):
|
|
76
80
|
"""Request schema for updating project-level settings."""
|
|
77
81
|
default_concurrency: int | None = None
|
|
82
|
+
auto_improve_enabled: bool | None = None
|
|
83
|
+
auto_improve_interval_minutes: int | None = None
|
|
78
84
|
|
|
79
85
|
@field_validator('default_concurrency')
|
|
80
86
|
@classmethod
|
|
@@ -83,6 +89,13 @@ class ProjectSettingsUpdate(BaseModel):
|
|
|
83
89
|
raise ValueError("default_concurrency must be between 1 and 5")
|
|
84
90
|
return v
|
|
85
91
|
|
|
92
|
+
@field_validator('auto_improve_interval_minutes')
|
|
93
|
+
@classmethod
|
|
94
|
+
def validate_auto_improve_interval(cls, v: int | None) -> int | None:
|
|
95
|
+
if v is not None and (v < 1 or v > 1440):
|
|
96
|
+
raise ValueError("auto_improve_interval_minutes must be between 1 and 1440")
|
|
97
|
+
return v
|
|
98
|
+
|
|
86
99
|
|
|
87
100
|
# ============================================================================
|
|
88
101
|
# Feature Schemas
|
|
@@ -471,6 +484,7 @@ class SettingsResponse(BaseModel):
|
|
|
471
484
|
playwright_headless: bool = True
|
|
472
485
|
batch_size: int = 3 # Features per coding agent batch (1-15)
|
|
473
486
|
testing_batch_size: int = 3 # Features per testing agent batch (1-15)
|
|
487
|
+
effort: Literal["low", "medium", "high", "xhigh", "max"] = "xhigh"
|
|
474
488
|
api_provider: str = "claude"
|
|
475
489
|
api_base_url: str | None = None
|
|
476
490
|
api_has_auth_token: bool = False # Never expose actual token
|
|
@@ -491,6 +505,7 @@ class SettingsUpdate(BaseModel):
|
|
|
491
505
|
playwright_headless: bool | None = None
|
|
492
506
|
batch_size: int | None = None # Features per agent batch (1-15)
|
|
493
507
|
testing_batch_size: int | None = None # Features per testing agent batch (1-15)
|
|
508
|
+
effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None
|
|
494
509
|
api_provider: str | None = None
|
|
495
510
|
api_base_url: str | None = Field(None, max_length=500)
|
|
496
511
|
api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
|
|
@@ -507,12 +522,16 @@ class SettingsUpdate(BaseModel):
|
|
|
507
522
|
|
|
508
523
|
@field_validator('model')
|
|
509
524
|
@classmethod
|
|
510
|
-
def validate_model(cls, v: str | None, info) -> str | None:
|
|
525
|
+
def validate_model(cls, v: str | None, info) -> str | None:
|
|
511
526
|
if v is not None:
|
|
512
527
|
# Skip VALID_MODELS check when using an alternative API provider
|
|
513
528
|
api_provider = info.data.get("api_provider")
|
|
514
529
|
if api_provider and api_provider != "claude":
|
|
515
530
|
return v
|
|
531
|
+
# Transparently accept legacy IDs so in-flight clients don't 422
|
|
532
|
+
# during an upgrade window; LEGACY_MODEL_MAP already covers migration.
|
|
533
|
+
if v in LEGACY_MODEL_MAP:
|
|
534
|
+
v = LEGACY_MODEL_MAP[v]
|
|
516
535
|
if v not in VALID_MODELS:
|
|
517
536
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
|
518
537
|
return v
|
|
@@ -270,8 +270,9 @@ class AssistantChatSession:
|
|
|
270
270
|
system_cli = shutil.which("claude")
|
|
271
271
|
|
|
272
272
|
# Build environment overrides for API configuration
|
|
273
|
-
from registry import DEFAULT_MODEL, get_effective_sdk_env
|
|
273
|
+
from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
|
|
274
274
|
sdk_env = get_effective_sdk_env()
|
|
275
|
+
effort = get_effort_setting()
|
|
275
276
|
|
|
276
277
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
|
277
278
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
|
|
@@ -281,6 +282,7 @@ class AssistantChatSession:
|
|
|
281
282
|
self.client = ClaudeSDKClient(
|
|
282
283
|
options=ClaudeAgentOptions(
|
|
283
284
|
model=model,
|
|
285
|
+
effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
|
|
284
286
|
cli_path=system_cli,
|
|
285
287
|
# System prompt loaded from CLAUDE.md via setting_sources
|
|
286
288
|
# This avoids Windows command line length limit (~8191 chars)
|
|
@@ -161,8 +161,9 @@ class ExpandChatSession:
|
|
|
161
161
|
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
|
162
162
|
|
|
163
163
|
# Build environment overrides for API configuration
|
|
164
|
-
from registry import DEFAULT_MODEL, get_effective_sdk_env
|
|
164
|
+
from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
|
|
165
165
|
sdk_env = get_effective_sdk_env()
|
|
166
|
+
effort = get_effort_setting()
|
|
166
167
|
|
|
167
168
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
|
168
169
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
|
|
@@ -184,6 +185,7 @@ class ExpandChatSession:
|
|
|
184
185
|
self.client = ClaudeSDKClient(
|
|
185
186
|
options=ClaudeAgentOptions(
|
|
186
187
|
model=model,
|
|
188
|
+
effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
|
|
187
189
|
cli_path=system_cli,
|
|
188
190
|
system_prompt=system_prompt,
|
|
189
191
|
allowed_tools=[
|
|
@@ -85,6 +85,7 @@ class AgentProcessManager:
|
|
|
85
85
|
self.parallel_mode: bool = False # Parallel execution mode
|
|
86
86
|
self.max_concurrency: int | None = None # Max concurrent agents
|
|
87
87
|
self.testing_agent_ratio: int = 1 # Regression testing agents (0-3)
|
|
88
|
+
self.auto_improve: bool = False # Auto-improve mode (single session)
|
|
88
89
|
|
|
89
90
|
# Support multiple callbacks (for multiple WebSocket clients)
|
|
90
91
|
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
|
|
@@ -375,6 +376,7 @@ class AgentProcessManager:
|
|
|
375
376
|
playwright_headless: bool = True,
|
|
376
377
|
batch_size: int = 3,
|
|
377
378
|
testing_batch_size: int = 3,
|
|
379
|
+
auto_improve: bool = False,
|
|
378
380
|
) -> tuple[bool, str]:
|
|
379
381
|
"""
|
|
380
382
|
Start the agent as a subprocess.
|
|
@@ -386,6 +388,10 @@ class AgentProcessManager:
|
|
|
386
388
|
max_concurrency: Max concurrent coding agents (1-5, default 1)
|
|
387
389
|
testing_agent_ratio: Number of regression testing agents (0-3, default 1)
|
|
388
390
|
playwright_headless: If True, run browser in headless mode
|
|
391
|
+
auto_improve: If True, run in auto-improve mode. Forces single-agent
|
|
392
|
+
execution (concurrency=1, testing_ratio=0) and passes
|
|
393
|
+
--auto-improve to the subprocess so it runs exactly one
|
|
394
|
+
improvement session and exits.
|
|
389
395
|
|
|
390
396
|
Returns:
|
|
391
397
|
Tuple of (success, message)
|
|
@@ -408,12 +414,19 @@ class AgentProcessManager:
|
|
|
408
414
|
# Clean up features stuck from a previous crash/stop
|
|
409
415
|
self._cleanup_stale_features()
|
|
410
416
|
|
|
417
|
+
# Auto-improve mode forces single-agent execution and skips testing
|
|
418
|
+
# agents — the subprocess bypasses the orchestrator entirely.
|
|
419
|
+
if auto_improve:
|
|
420
|
+
max_concurrency = 1
|
|
421
|
+
testing_agent_ratio = 0
|
|
422
|
+
|
|
411
423
|
# Store for status queries
|
|
412
424
|
self.yolo_mode = yolo_mode
|
|
413
425
|
self.model = model
|
|
414
426
|
self.parallel_mode = True # Always True now (unified orchestrator)
|
|
415
427
|
self.max_concurrency = max_concurrency or 1
|
|
416
428
|
self.testing_agent_ratio = testing_agent_ratio
|
|
429
|
+
self.auto_improve = auto_improve
|
|
417
430
|
|
|
418
431
|
# Build command - unified orchestrator with --concurrency
|
|
419
432
|
cmd = [
|
|
@@ -432,6 +445,10 @@ class AgentProcessManager:
|
|
|
432
445
|
if yolo_mode:
|
|
433
446
|
cmd.append("--yolo")
|
|
434
447
|
|
|
448
|
+
# Add --auto-improve flag: bypasses the orchestrator for a one-shot run
|
|
449
|
+
if auto_improve:
|
|
450
|
+
cmd.append("--auto-improve")
|
|
451
|
+
|
|
435
452
|
# Add --concurrency flag (unified orchestrator always uses this)
|
|
436
453
|
cmd.extend(["--concurrency", str(max_concurrency or 1)])
|
|
437
454
|
|
|
@@ -15,6 +15,7 @@ from typing import Optional
|
|
|
15
15
|
|
|
16
16
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
17
17
|
from apscheduler.triggers.cron import CronTrigger
|
|
18
|
+
from apscheduler.triggers.interval import IntervalTrigger
|
|
18
19
|
|
|
19
20
|
# Add parent directory for imports
|
|
20
21
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
@@ -73,19 +74,39 @@ class SchedulerService:
|
|
|
73
74
|
logger.info("Scheduler service stopped")
|
|
74
75
|
|
|
75
76
|
async def _load_all_schedules(self):
|
|
76
|
-
"""Load schedules for all registered projects."""
|
|
77
|
+
"""Load schedules and auto-improve jobs for all registered projects."""
|
|
77
78
|
from registry import list_registered_projects
|
|
78
79
|
|
|
79
80
|
try:
|
|
80
81
|
projects = list_registered_projects()
|
|
81
82
|
total_loaded = 0
|
|
83
|
+
total_auto_improve = 0
|
|
82
84
|
for project_name, info in projects.items():
|
|
83
85
|
project_path = Path(info.get("path", ""))
|
|
84
|
-
if project_path.exists():
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
if not project_path.exists():
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Windowed schedules (cron-based)
|
|
90
|
+
count = await self._load_project_schedules(project_name, project_path)
|
|
91
|
+
total_loaded += count
|
|
92
|
+
|
|
93
|
+
# Auto-improve interval jobs (stored in the registry directly)
|
|
94
|
+
if info.get("auto_improve_enabled"):
|
|
95
|
+
interval = int(info.get("auto_improve_interval_minutes", 10) or 10)
|
|
96
|
+
try:
|
|
97
|
+
await self.register_auto_improve(project_name, project_path, interval)
|
|
98
|
+
total_auto_improve += 1
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.error(
|
|
101
|
+
f"Failed to register auto-improve for {project_name}: {e}"
|
|
102
|
+
)
|
|
103
|
+
|
|
87
104
|
if total_loaded > 0:
|
|
88
105
|
logger.info(f"Loaded {total_loaded} schedule(s) across all projects")
|
|
106
|
+
if total_auto_improve > 0:
|
|
107
|
+
logger.info(
|
|
108
|
+
f"Registered {total_auto_improve} auto-improve job(s) across all projects"
|
|
109
|
+
)
|
|
89
110
|
except Exception as e:
|
|
90
111
|
logger.error(f"Error loading schedules: {e}")
|
|
91
112
|
|
|
@@ -205,6 +226,135 @@ class SchedulerService:
|
|
|
205
226
|
else:
|
|
206
227
|
logger.warning(f"No jobs found to remove for schedule {schedule_id}")
|
|
207
228
|
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Auto-improve interval jobs
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def _auto_improve_job_id(project_name: str) -> str:
|
|
235
|
+
return f"auto_improve_{project_name}"
|
|
236
|
+
|
|
237
|
+
async def register_auto_improve(
|
|
238
|
+
self,
|
|
239
|
+
project_name: str,
|
|
240
|
+
project_dir: Path,
|
|
241
|
+
interval_minutes: int,
|
|
242
|
+
):
|
|
243
|
+
"""Register or replace the auto-improve interval job for a project.
|
|
244
|
+
|
|
245
|
+
Uses APScheduler IntervalTrigger to fire every ``interval_minutes``.
|
|
246
|
+
On each tick, a single one-shot auto-improve agent session is started
|
|
247
|
+
(unless the project's agent is already running, in which case the tick
|
|
248
|
+
is silently skipped).
|
|
249
|
+
"""
|
|
250
|
+
if interval_minutes < 1 or interval_minutes > 1440:
|
|
251
|
+
raise ValueError("interval_minutes must be between 1 and 1440")
|
|
252
|
+
|
|
253
|
+
job_id = self._auto_improve_job_id(project_name)
|
|
254
|
+
trigger = IntervalTrigger(minutes=interval_minutes)
|
|
255
|
+
|
|
256
|
+
self.scheduler.add_job(
|
|
257
|
+
self._handle_auto_improve_tick,
|
|
258
|
+
trigger,
|
|
259
|
+
id=job_id,
|
|
260
|
+
args=[project_name, str(project_dir)],
|
|
261
|
+
replace_existing=True,
|
|
262
|
+
misfire_grace_time=300,
|
|
263
|
+
max_instances=1, # Never overlap ticks
|
|
264
|
+
coalesce=True, # Collapse missed ticks during long runs
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
job = self.scheduler.get_job(job_id)
|
|
268
|
+
next_run = job.next_run_time if job else None
|
|
269
|
+
logger.info(
|
|
270
|
+
f"Registered auto-improve for {project_name}: "
|
|
271
|
+
f"every {interval_minutes} min (next: {next_run})"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def remove_auto_improve(self, project_name: str):
|
|
275
|
+
"""Remove the auto-improve interval job for a project (no-op if missing)."""
|
|
276
|
+
job_id = self._auto_improve_job_id(project_name)
|
|
277
|
+
try:
|
|
278
|
+
self.scheduler.remove_job(job_id)
|
|
279
|
+
logger.info(f"Removed auto-improve job for {project_name}")
|
|
280
|
+
except Exception:
|
|
281
|
+
logger.debug(f"No auto-improve job to remove for {project_name}")
|
|
282
|
+
|
|
283
|
+
async def _handle_auto_improve_tick(
|
|
284
|
+
self,
|
|
285
|
+
project_name: str,
|
|
286
|
+
project_dir_str: str,
|
|
287
|
+
):
|
|
288
|
+
"""Fire one auto-improve agent session for a project.
|
|
289
|
+
|
|
290
|
+
Silently skips if the agent is already running (manual run, another
|
|
291
|
+
tick still executing, etc.). The scheduler's ``max_instances=1`` and
|
|
292
|
+
``coalesce=True`` settings make sure ticks never stack up.
|
|
293
|
+
"""
|
|
294
|
+
logger.info(f"Auto-improve tick for {project_name}")
|
|
295
|
+
project_dir = Path(project_dir_str)
|
|
296
|
+
|
|
297
|
+
if not project_dir.exists():
|
|
298
|
+
logger.warning(
|
|
299
|
+
f"Auto-improve tick: project dir missing for {project_name}, skipping"
|
|
300
|
+
)
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
from .process_manager import get_manager
|
|
305
|
+
|
|
306
|
+
root_dir = Path(__file__).parent.parent.parent
|
|
307
|
+
manager = get_manager(project_name, project_dir, root_dir)
|
|
308
|
+
|
|
309
|
+
if manager.status in ("running", "paused", "pausing", "paused_graceful"):
|
|
310
|
+
logger.info(
|
|
311
|
+
f"Auto-improve tick for {project_name}: agent already "
|
|
312
|
+
f"{manager.status}, skipping this tick"
|
|
313
|
+
)
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
# Resolve effective yolo/model from global settings, mirroring the
|
|
317
|
+
# agent router's _get_settings_defaults() pattern so auto-improve
|
|
318
|
+
# respects whatever the user has configured globally.
|
|
319
|
+
yolo_mode, model = self._resolve_agent_defaults()
|
|
320
|
+
|
|
321
|
+
logger.info(
|
|
322
|
+
f"Starting auto-improve agent for {project_name} "
|
|
323
|
+
f"(yolo={yolo_mode}, model={model})"
|
|
324
|
+
)
|
|
325
|
+
success, msg = await manager.start(
|
|
326
|
+
yolo_mode=yolo_mode,
|
|
327
|
+
model=model,
|
|
328
|
+
max_concurrency=1,
|
|
329
|
+
testing_agent_ratio=0,
|
|
330
|
+
playwright_headless=True,
|
|
331
|
+
auto_improve=True,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if success:
|
|
335
|
+
logger.info(f"Auto-improve agent started for {project_name}")
|
|
336
|
+
else:
|
|
337
|
+
logger.warning(
|
|
338
|
+
f"Auto-improve agent failed to start for {project_name}: {msg}"
|
|
339
|
+
)
|
|
340
|
+
except Exception as e:
|
|
341
|
+
logger.error(f"Error in auto-improve tick for {project_name}: {e}")
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _resolve_agent_defaults() -> tuple[bool, str]:
|
|
345
|
+
"""Resolve (yolo_mode, model) from global settings.
|
|
346
|
+
|
|
347
|
+
Kept separate from the agent router's helper so the scheduler never
|
|
348
|
+
has to import FastAPI routers. Mirrors the parsing behavior of
|
|
349
|
+
``server/routers/agent.py::_get_settings_defaults``.
|
|
350
|
+
"""
|
|
351
|
+
from registry import DEFAULT_MODEL, get_all_settings
|
|
352
|
+
|
|
353
|
+
settings = get_all_settings()
|
|
354
|
+
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
|
|
355
|
+
model = settings.get("api_model") or settings.get("model", DEFAULT_MODEL)
|
|
356
|
+
return yolo_mode, model
|
|
357
|
+
|
|
208
358
|
async def _handle_scheduled_start(
|
|
209
359
|
self, project_name: str, schedule_id: int, project_dir_str: str
|
|
210
360
|
):
|
|
@@ -147,8 +147,9 @@ class SpecChatSession:
|
|
|
147
147
|
system_cli = shutil.which("claude")
|
|
148
148
|
|
|
149
149
|
# Build environment overrides for API configuration
|
|
150
|
-
from registry import DEFAULT_MODEL, get_effective_sdk_env
|
|
150
|
+
from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
|
|
151
151
|
sdk_env = get_effective_sdk_env()
|
|
152
|
+
effort = get_effort_setting()
|
|
152
153
|
|
|
153
154
|
# Determine model from SDK env (provider-aware) or fallback to env/default
|
|
154
155
|
model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
|
|
@@ -157,6 +158,7 @@ class SpecChatSession:
|
|
|
157
158
|
self.client = ClaudeSDKClient(
|
|
158
159
|
options=ClaudeAgentOptions(
|
|
159
160
|
model=model,
|
|
161
|
+
effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
|
|
160
162
|
cli_path=system_cli,
|
|
161
163
|
# System prompt loaded from CLAUDE.md via setting_sources
|
|
162
164
|
# Include "user" for global skills and subagents from ~/.claude/
|