ralphx 0.3.5__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- ralphx/__init__.py +1 -1
- ralphx/adapters/base.py +10 -2
- ralphx/adapters/claude_cli.py +222 -82
- ralphx/api/routes/auth.py +105 -32
- ralphx/api/routes/planning.py +865 -16
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +58 -56
- ralphx/api/routes/workflows.py +257 -25
- ralphx/core/auth.py +32 -7
- ralphx/core/executor.py +170 -16
- ralphx/core/loop_templates.py +26 -0
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +10 -3
- ralphx/core/project_db.py +770 -79
- ralphx/core/resources.py +28 -2
- ralphx/core/workflow_executor.py +32 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +3 -3
- ralphx/static/assets/index-BuLI7ffn.css +1 -0
- ralphx/static/assets/index-DWvlqOTb.js +264 -0
- ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.5.dist-info → ralphx-0.4.0.dist-info}/METADATA +1 -1
- {ralphx-0.3.5.dist-info → ralphx-0.4.0.dist-info}/RECORD +28 -27
- ralphx/static/assets/index-0ovNnfOq.css +0 -1
- ralphx/static/assets/index-CY9s08ZB.js +0 -251
- ralphx/static/assets/index-CY9s08ZB.js.map +0 -1
- {ralphx-0.3.5.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
- {ralphx-0.3.5.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
ralphx/core/auth.py
CHANGED
|
@@ -128,7 +128,7 @@ class AuthStatus(BaseModel):
|
|
|
128
128
|
"""Authentication status."""
|
|
129
129
|
|
|
130
130
|
connected: bool
|
|
131
|
-
scope: Optional[Literal["project", "global"]] = None
|
|
131
|
+
scope: Optional[Literal["project", "global", "account"]] = None
|
|
132
132
|
email: Optional[str] = None # User's email address
|
|
133
133
|
subscription_type: Optional[str] = None
|
|
134
134
|
rate_limit_tier: Optional[str] = None
|
|
@@ -357,6 +357,7 @@ def swap_credentials_for_account(
|
|
|
357
357
|
}
|
|
358
358
|
CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
359
359
|
CLAUDE_CREDENTIALS_PATH.write_text(json.dumps(creds_data, indent=2))
|
|
360
|
+
os.chmod(CLAUDE_CREDENTIALS_PATH, 0o600) # Restrict to owner-only read/write
|
|
360
361
|
has_creds = True
|
|
361
362
|
|
|
362
363
|
# Update last_used_at for the account
|
|
@@ -711,7 +712,17 @@ async def refresh_token_if_needed(
|
|
|
711
712
|
if now < account["expires_at"] - 300:
|
|
712
713
|
return True # Token assumed valid based on expiry
|
|
713
714
|
|
|
714
|
-
|
|
715
|
+
# Use lock to prevent concurrent refresh operations.
|
|
716
|
+
# Re-read account inside lock in case another process already refreshed.
|
|
717
|
+
async with _token_refresh_lock():
|
|
718
|
+
# Re-fetch account to get latest token state
|
|
719
|
+
fresh_account = db.get_effective_account(project_id)
|
|
720
|
+
if not fresh_account:
|
|
721
|
+
return False
|
|
722
|
+
# Check if another process already refreshed while we waited for lock
|
|
723
|
+
if int(time.time()) < fresh_account["expires_at"] - 300:
|
|
724
|
+
return True
|
|
725
|
+
return await _do_token_refresh(fresh_account, project_id)
|
|
715
726
|
|
|
716
727
|
|
|
717
728
|
async def force_refresh_token(project_id: Optional[str] = None) -> dict:
|
|
@@ -733,7 +744,13 @@ async def force_refresh_token(project_id: Optional[str] = None) -> dict:
|
|
|
733
744
|
return {"success": False, "error": "No refresh token available"}
|
|
734
745
|
|
|
735
746
|
try:
|
|
736
|
-
|
|
747
|
+
# Use lock to prevent concurrent refresh operations.
|
|
748
|
+
# Re-read account inside lock in case another process already refreshed.
|
|
749
|
+
async with _token_refresh_lock():
|
|
750
|
+
fresh_account = db.get_effective_account(project_id)
|
|
751
|
+
if not fresh_account or not fresh_account.get("refresh_token"):
|
|
752
|
+
return {"success": False, "error": "No account or refresh token available"}
|
|
753
|
+
success = await _do_token_refresh(fresh_account, project_id)
|
|
737
754
|
if success:
|
|
738
755
|
return {"success": True, "message": "Token refreshed successfully"}
|
|
739
756
|
else:
|
|
@@ -780,12 +797,16 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
|
780
797
|
# Token was refreshed by another process, skip
|
|
781
798
|
continue
|
|
782
799
|
|
|
800
|
+
# Use fresh_account's refresh_token (not the pre-lock stale one)
|
|
801
|
+
# Another process may have refreshed and changed the token while we waited
|
|
802
|
+
refresh_token = fresh_account["refresh_token"] if fresh_account else account["refresh_token"]
|
|
803
|
+
|
|
783
804
|
async with httpx.AsyncClient() as client:
|
|
784
805
|
resp = await client.post(
|
|
785
806
|
TOKEN_URL,
|
|
786
807
|
json={
|
|
787
808
|
"grant_type": "refresh_token",
|
|
788
|
-
"refresh_token":
|
|
809
|
+
"refresh_token": refresh_token,
|
|
789
810
|
"client_id": CLIENT_ID,
|
|
790
811
|
},
|
|
791
812
|
headers={
|
|
@@ -799,9 +820,11 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
|
799
820
|
new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
800
821
|
|
|
801
822
|
# Build update dict - capture subscription/plan info if present
|
|
823
|
+
# Use refresh_token (the fresh value used for this request) as fallback,
|
|
824
|
+
# not account["refresh_token"] which is the pre-lock stale copy
|
|
802
825
|
update_data = {
|
|
803
826
|
"access_token": tokens["access_token"],
|
|
804
|
-
"refresh_token": tokens.get("refresh_token",
|
|
827
|
+
"refresh_token": tokens.get("refresh_token", refresh_token),
|
|
805
828
|
"expires_at": new_expires_at,
|
|
806
829
|
"consecutive_failures": 0,
|
|
807
830
|
}
|
|
@@ -830,10 +853,11 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
|
|
|
830
853
|
)
|
|
831
854
|
else:
|
|
832
855
|
result["failed"] += 1
|
|
833
|
-
# Track consecutive failures
|
|
856
|
+
# Track consecutive failures (use fresh_account for accurate count)
|
|
857
|
+
failure_count = (fresh_account or account).get("consecutive_failures", 0)
|
|
834
858
|
db.update_account(
|
|
835
859
|
account["id"],
|
|
836
|
-
consecutive_failures=
|
|
860
|
+
consecutive_failures=failure_count + 1,
|
|
837
861
|
)
|
|
838
862
|
auth_log.warning(
|
|
839
863
|
"token_refresh_failed",
|
|
@@ -937,6 +961,7 @@ def swap_credentials_for_loop(
|
|
|
937
961
|
}
|
|
938
962
|
CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
939
963
|
CLAUDE_CREDENTIALS_PATH.write_text(json.dumps(creds_data, indent=2))
|
|
964
|
+
os.chmod(CLAUDE_CREDENTIALS_PATH, 0o600) # Restrict to owner-only read/write
|
|
940
965
|
has_creds = True
|
|
941
966
|
|
|
942
967
|
try:
|
ralphx/core/executor.py
CHANGED
|
@@ -23,7 +23,7 @@ from enum import Enum
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import Any, AsyncIterator, Callable, Optional
|
|
25
25
|
|
|
26
|
-
from ralphx.adapters.base import ExecutionResult, LLMAdapter
|
|
26
|
+
from ralphx.adapters.base import AdapterEvent, ExecutionResult, LLMAdapter, StreamEvent
|
|
27
27
|
from ralphx.adapters.claude_cli import ClaudeCLIAdapter
|
|
28
28
|
from ralphx.core.dependencies import DependencyGraph, order_items_by_dependency
|
|
29
29
|
from ralphx.core.project_db import ProjectDatabase
|
|
@@ -47,6 +47,7 @@ class ExecutorEvent(str, Enum):
|
|
|
47
47
|
GIT_COMMIT = "git_commit" # Git commit after successful iteration
|
|
48
48
|
ERROR = "error"
|
|
49
49
|
WARNING = "warning"
|
|
50
|
+
INFO = "info"
|
|
50
51
|
HEARTBEAT = "heartbeat"
|
|
51
52
|
RUN_PAUSED = "run_paused"
|
|
52
53
|
RUN_RESUMED = "run_resumed"
|
|
@@ -123,6 +124,7 @@ class LoopExecutor:
|
|
|
123
124
|
batch_size: int = 10,
|
|
124
125
|
consume_from_step_id: Optional[int] = None,
|
|
125
126
|
architecture_first: bool = False,
|
|
127
|
+
context_from_steps: Optional[list[int]] = None,
|
|
126
128
|
):
|
|
127
129
|
"""Initialize the executor.
|
|
128
130
|
|
|
@@ -142,6 +144,8 @@ class LoopExecutor:
|
|
|
142
144
|
consume_from_step_id: For consumer loops, the step ID to consume items from.
|
|
143
145
|
architecture_first: If True, prioritize foundational stories (FND, DBM, SEC, ARC)
|
|
144
146
|
for new codebases. Batches them together for initial build.
|
|
147
|
+
context_from_steps: For generator loops, additional step IDs whose items
|
|
148
|
+
should be included as existing context (avoid duplicates).
|
|
145
149
|
"""
|
|
146
150
|
self.project = project
|
|
147
151
|
self.config = loop_config
|
|
@@ -149,6 +153,7 @@ class LoopExecutor:
|
|
|
149
153
|
self.workflow_id = workflow_id
|
|
150
154
|
self.step_id = step_id
|
|
151
155
|
self._consume_from_step_id = consume_from_step_id
|
|
156
|
+
self._context_from_steps = context_from_steps or []
|
|
152
157
|
# Create adapter with per-loop settings path for permission templates
|
|
153
158
|
# Pass project_id for credential lookup (project-scoped auth)
|
|
154
159
|
if adapter is None:
|
|
@@ -403,15 +408,24 @@ class LoopExecutor:
|
|
|
403
408
|
mode_name = list(modes.keys())[0]
|
|
404
409
|
return mode_name, modes[mode_name]
|
|
405
410
|
|
|
406
|
-
def _resolve_loop_resource_content(self, resource: dict) -> Optional[str]:
|
|
411
|
+
def _resolve_loop_resource_content(self, resource: dict, _depth: int = 0) -> Optional[str]:
|
|
407
412
|
"""Resolve content for a loop resource based on its source_type.
|
|
408
413
|
|
|
409
414
|
Args:
|
|
410
415
|
resource: Loop resource dict from database.
|
|
416
|
+
_depth: Internal recursion depth counter (max 5 to prevent cycles).
|
|
411
417
|
|
|
412
418
|
Returns:
|
|
413
419
|
Resolved content string, or None if unable to resolve.
|
|
414
420
|
"""
|
|
421
|
+
if _depth > 5:
|
|
422
|
+
import logging
|
|
423
|
+
logging.getLogger(__name__).warning(
|
|
424
|
+
f"[RESOLVE] Max recursion depth reached resolving loop resource "
|
|
425
|
+
f"(possible circular loop_ref). Resource: {resource.get('name', 'unknown')}"
|
|
426
|
+
)
|
|
427
|
+
return None
|
|
428
|
+
|
|
415
429
|
source_type = resource.get("source_type", "")
|
|
416
430
|
|
|
417
431
|
if source_type == "system":
|
|
@@ -425,22 +439,30 @@ class LoopExecutor:
|
|
|
425
439
|
return None
|
|
426
440
|
|
|
427
441
|
elif source_type == "project_file":
|
|
428
|
-
# Load from project file path
|
|
442
|
+
# Load from project file path (must stay within project directory)
|
|
429
443
|
source_path = resource.get("source_path")
|
|
430
444
|
if source_path:
|
|
431
|
-
file_path = Path(self.project.path) / source_path
|
|
445
|
+
file_path = (Path(self.project.path) / source_path).resolve()
|
|
446
|
+
project_root = Path(self.project.path).resolve()
|
|
447
|
+
# Prevent path traversal outside project directory
|
|
448
|
+
if not str(file_path).startswith(str(project_root) + "/") and file_path != project_root:
|
|
449
|
+
import logging
|
|
450
|
+
logging.getLogger(__name__).warning(
|
|
451
|
+
f"[RESOLVE] Path traversal blocked: {source_path} resolves outside project"
|
|
452
|
+
)
|
|
453
|
+
return None
|
|
432
454
|
if file_path.exists():
|
|
433
455
|
return file_path.read_text()
|
|
434
456
|
return None
|
|
435
457
|
|
|
436
458
|
elif source_type == "loop_ref":
|
|
437
|
-
# Load from another loop's resource (recursively)
|
|
459
|
+
# Load from another loop's resource (recursively, with depth limit)
|
|
438
460
|
source_loop = resource.get("source_loop")
|
|
439
461
|
source_resource_id = resource.get("source_resource_id")
|
|
440
462
|
if source_loop and source_resource_id:
|
|
441
463
|
source_resource = self.db.get_loop_resource(source_resource_id)
|
|
442
464
|
if source_resource:
|
|
443
|
-
return self._resolve_loop_resource_content(source_resource)
|
|
465
|
+
return self._resolve_loop_resource_content(source_resource, _depth=_depth + 1)
|
|
444
466
|
return None
|
|
445
467
|
|
|
446
468
|
elif source_type == "project_resource":
|
|
@@ -528,7 +550,7 @@ class LoopExecutor:
|
|
|
528
550
|
|
|
529
551
|
return "\n".join(sections)
|
|
530
552
|
|
|
531
|
-
def _load_prompt_template(self, mode: Mode) -> str:
|
|
553
|
+
def _load_prompt_template(self, mode: Mode, loop_resources: Optional[list[dict]] = None) -> str:
|
|
532
554
|
"""Load prompt template for a mode.
|
|
533
555
|
|
|
534
556
|
Priority order:
|
|
@@ -539,12 +561,14 @@ class LoopExecutor:
|
|
|
539
561
|
|
|
540
562
|
Args:
|
|
541
563
|
mode: Mode configuration.
|
|
564
|
+
loop_resources: Pre-loaded loop resources (avoids redundant DB query).
|
|
542
565
|
|
|
543
566
|
Returns:
|
|
544
567
|
Prompt template content.
|
|
545
568
|
"""
|
|
546
569
|
# Priority 1: Check for loop-level LOOP_TEMPLATE resource
|
|
547
|
-
loop_resources
|
|
570
|
+
if loop_resources is None:
|
|
571
|
+
loop_resources = self._load_loop_resources()
|
|
548
572
|
for resource in loop_resources:
|
|
549
573
|
if (resource.get("resource_type") == "loop_template" and
|
|
550
574
|
resource.get("injection_position") == "template_body"):
|
|
@@ -627,11 +651,12 @@ class LoopExecutor:
|
|
|
627
651
|
Returns:
|
|
628
652
|
Complete prompt with all resources and tracking marker.
|
|
629
653
|
"""
|
|
630
|
-
template = self._load_prompt_template(mode)
|
|
631
|
-
|
|
632
654
|
# Load loop-specific resources first (from loop_resources table)
|
|
655
|
+
# Pass to _load_prompt_template to avoid redundant DB query
|
|
633
656
|
loop_resources = self._load_loop_resources()
|
|
634
657
|
|
|
658
|
+
template = self._load_prompt_template(mode, loop_resources=loop_resources)
|
|
659
|
+
|
|
635
660
|
# Also load project-level resources as fallback
|
|
636
661
|
resource_manager = ResourceManager(self.project.path, db=self.db)
|
|
637
662
|
resource_set = resource_manager.load_for_loop(self.config, mode_name)
|
|
@@ -692,6 +717,17 @@ class LoopExecutor:
|
|
|
692
717
|
escaped_design_doc = self._escape_template_vars(design_doc_content)
|
|
693
718
|
template = template.replace("{DESIGN_DOC}", escaped_design_doc)
|
|
694
719
|
|
|
720
|
+
# Substitute {{design_doc}} (RalphX style) with actual design doc content
|
|
721
|
+
# Note: {{design_doc}} may have already been used as a position marker for
|
|
722
|
+
# after_design_doc resources above, but the marker text itself remains.
|
|
723
|
+
# Replace it with the actual design doc content (or empty string if none).
|
|
724
|
+
if "{{design_doc}}" in template:
|
|
725
|
+
if design_doc_content:
|
|
726
|
+
escaped_design_doc = self._escape_template_vars(design_doc_content)
|
|
727
|
+
template = template.replace("{{design_doc}}", escaped_design_doc)
|
|
728
|
+
else:
|
|
729
|
+
template = template.replace("{{design_doc}}", "")
|
|
730
|
+
|
|
695
731
|
# BEFORE_TASK: Insert before the main task instruction
|
|
696
732
|
# Look for {{task}} marker or insert near the end
|
|
697
733
|
if before_task:
|
|
@@ -701,10 +737,21 @@ class LoopExecutor:
|
|
|
701
737
|
# Append before the final section
|
|
702
738
|
template = template + "\n\n" + before_task
|
|
703
739
|
|
|
704
|
-
# AFTER_TASK: Append at the end
|
|
705
|
-
|
|
740
|
+
# AFTER_TASK: Append at the end, unless the template has a dedicated
|
|
741
|
+
# {{custom_context}} placeholder (which serves the same purpose)
|
|
742
|
+
if "{{custom_context}}" in template:
|
|
743
|
+
# Template has a designated place for custom context - substitute there
|
|
744
|
+
template = template.replace("{{custom_context}}", after_task or "")
|
|
745
|
+
elif after_task:
|
|
746
|
+
# No placeholder - append at end
|
|
706
747
|
template = template + "\n\n" + after_task
|
|
707
748
|
|
|
749
|
+
# Substitute {{backlog_status}} for hybrid loops
|
|
750
|
+
# Shows current item counts to help mode detection
|
|
751
|
+
if "{{backlog_status}}" in template:
|
|
752
|
+
backlog_status = self._build_backlog_status()
|
|
753
|
+
template = template.replace("{{backlog_status}}", backlog_status)
|
|
754
|
+
|
|
708
755
|
# Inject generator loop context (existing stories, category stats, inputs)
|
|
709
756
|
# This MUST happen before any variable substitution
|
|
710
757
|
if self._is_generator_loop():
|
|
@@ -776,7 +823,7 @@ class LoopExecutor:
|
|
|
776
823
|
# Support both {{implemented_summary}} (RalphX style) and {IMPLEMENTED_SUMMARY} (hank-rcm style)
|
|
777
824
|
if self._is_consumer_loop():
|
|
778
825
|
if "{{implemented_summary}}" in template or "{IMPLEMENTED_SUMMARY}" in template:
|
|
779
|
-
impl_summary = self._build_implemented_summary()
|
|
826
|
+
impl_summary = self._escape_template_vars(self._build_implemented_summary())
|
|
780
827
|
template = template.replace("{{implemented_summary}}", impl_summary)
|
|
781
828
|
template = template.replace("{IMPLEMENTED_SUMMARY}", impl_summary)
|
|
782
829
|
|
|
@@ -807,8 +854,49 @@ class LoopExecutor:
|
|
|
807
854
|
)
|
|
808
855
|
template = template + marker
|
|
809
856
|
|
|
857
|
+
# Warn if prompt exceeds reasonable size (200K chars ~ 50K tokens)
|
|
858
|
+
prompt_size = len(template)
|
|
859
|
+
if prompt_size > 200_000:
|
|
860
|
+
self._emit_event(
|
|
861
|
+
ExecutorEvent.WARNING,
|
|
862
|
+
f"Prompt is very large ({prompt_size:,} chars, ~{prompt_size // 4:,} tokens). "
|
|
863
|
+
f"This may exceed the model's context window. Consider reducing resource sizes.",
|
|
864
|
+
)
|
|
865
|
+
|
|
810
866
|
return template
|
|
811
867
|
|
|
868
|
+
def _build_backlog_status(self) -> str:
|
|
869
|
+
"""Build backlog status summary for hybrid loop templates.
|
|
870
|
+
|
|
871
|
+
Shows the current state of work items to help the hybrid template
|
|
872
|
+
decide whether to generate new items or implement existing ones.
|
|
873
|
+
|
|
874
|
+
Returns:
|
|
875
|
+
Status string describing pending/completed item counts.
|
|
876
|
+
"""
|
|
877
|
+
try:
|
|
878
|
+
# Get all items for this workflow step
|
|
879
|
+
all_items, total = self.db.list_work_items(
|
|
880
|
+
workflow_id=self.workflow_id,
|
|
881
|
+
source_step_id=self.step_id,
|
|
882
|
+
limit=10000,
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
if total == 0:
|
|
886
|
+
return "Backlog is EMPTY. No work items exist yet."
|
|
887
|
+
|
|
888
|
+
# Count by status
|
|
889
|
+
pending = sum(1 for i in all_items if i.get("status") == "pending")
|
|
890
|
+
completed = sum(1 for i in all_items if i.get("status") in ("completed", "processed"))
|
|
891
|
+
in_progress = sum(1 for i in all_items if i.get("status") == "in_progress")
|
|
892
|
+
|
|
893
|
+
return (
|
|
894
|
+
f"Backlog has {total} items: "
|
|
895
|
+
f"{pending} pending, {in_progress} in progress, {completed} completed."
|
|
896
|
+
)
|
|
897
|
+
except Exception:
|
|
898
|
+
return "Backlog status unavailable."
|
|
899
|
+
|
|
812
900
|
def _is_consumer_loop(self) -> bool:
|
|
813
901
|
"""Check if this loop consumes items from another loop.
|
|
814
902
|
|
|
@@ -873,6 +961,15 @@ class LoopExecutor:
|
|
|
873
961
|
limit=10000, # Get all existing items
|
|
874
962
|
)
|
|
875
963
|
|
|
964
|
+
# Also include items from linked context steps (cross-step visibility)
|
|
965
|
+
for ctx_step_id in self._context_from_steps:
|
|
966
|
+
ctx_items, _ = self.db.list_work_items(
|
|
967
|
+
workflow_id=self.workflow_id,
|
|
968
|
+
source_step_id=ctx_step_id,
|
|
969
|
+
limit=10000,
|
|
970
|
+
)
|
|
971
|
+
existing_items.extend(ctx_items)
|
|
972
|
+
|
|
876
973
|
# 2. Build category stats with next available ID
|
|
877
974
|
category_stats: dict[str, dict] = {}
|
|
878
975
|
for item in existing_items:
|
|
@@ -920,10 +1017,13 @@ class LoopExecutor:
|
|
|
920
1017
|
json.dumps(category_stats, indent=2)
|
|
921
1018
|
)
|
|
922
1019
|
|
|
1020
|
+
# Escape inputs_list to prevent template injection from filenames
|
|
1021
|
+
inputs_list_escaped = self._escape_template_vars(inputs_list)
|
|
1022
|
+
|
|
923
1023
|
template = template.replace("{{existing_stories}}", existing_stories_json)
|
|
924
1024
|
template = template.replace("{{category_stats}}", category_stats_json)
|
|
925
1025
|
template = template.replace("{{total_stories}}", str(len(existing_items)))
|
|
926
|
-
template = template.replace("{{inputs_list}}",
|
|
1026
|
+
template = template.replace("{{inputs_list}}", inputs_list_escaped)
|
|
927
1027
|
|
|
928
1028
|
return template
|
|
929
1029
|
|
|
@@ -1637,9 +1737,13 @@ class LoopExecutor:
|
|
|
1637
1737
|
if self._is_consumer_loop() and not self._batch_mode:
|
|
1638
1738
|
json_schema = IMPLEMENTATION_STATUS_SCHEMA
|
|
1639
1739
|
|
|
1740
|
+
# Mutable holder for session_id (set by INIT event before other events)
|
|
1741
|
+
session_id_holder: list[Optional[str]] = [None]
|
|
1742
|
+
|
|
1640
1743
|
# Callback to register session immediately when it starts
|
|
1641
1744
|
# This enables live streaming in the UI before execution completes
|
|
1642
1745
|
def register_session_early(session_id: str) -> None:
|
|
1746
|
+
session_id_holder[0] = session_id
|
|
1643
1747
|
if self._run:
|
|
1644
1748
|
self.db.create_session(
|
|
1645
1749
|
session_id=session_id,
|
|
@@ -1649,6 +1753,55 @@ class LoopExecutor:
|
|
|
1649
1753
|
status="running",
|
|
1650
1754
|
)
|
|
1651
1755
|
|
|
1756
|
+
# Callback to persist events to DB for history/debugging
|
|
1757
|
+
def persist_event(event: StreamEvent) -> None:
|
|
1758
|
+
sid = session_id_holder[0]
|
|
1759
|
+
if not sid:
|
|
1760
|
+
return
|
|
1761
|
+
try:
|
|
1762
|
+
if event.type == AdapterEvent.INIT:
|
|
1763
|
+
self.db.add_session_event(
|
|
1764
|
+
session_id=sid,
|
|
1765
|
+
event_type="init",
|
|
1766
|
+
raw_data=event.data if event.data else None,
|
|
1767
|
+
)
|
|
1768
|
+
elif event.type == AdapterEvent.TEXT and event.text:
|
|
1769
|
+
self.db.add_session_event(
|
|
1770
|
+
session_id=sid,
|
|
1771
|
+
event_type="text",
|
|
1772
|
+
content=event.text,
|
|
1773
|
+
)
|
|
1774
|
+
elif event.type == AdapterEvent.TOOL_USE:
|
|
1775
|
+
self.db.add_session_event(
|
|
1776
|
+
session_id=sid,
|
|
1777
|
+
event_type="tool_call",
|
|
1778
|
+
tool_name=event.tool_name,
|
|
1779
|
+
tool_input=event.tool_input,
|
|
1780
|
+
)
|
|
1781
|
+
elif event.type == AdapterEvent.TOOL_RESULT:
|
|
1782
|
+
self.db.add_session_event(
|
|
1783
|
+
session_id=sid,
|
|
1784
|
+
event_type="tool_result",
|
|
1785
|
+
tool_name=event.tool_name,
|
|
1786
|
+
tool_result=event.tool_result[:1000] if event.tool_result else None,
|
|
1787
|
+
)
|
|
1788
|
+
elif event.type == AdapterEvent.ERROR:
|
|
1789
|
+
self.db.add_session_event(
|
|
1790
|
+
session_id=sid,
|
|
1791
|
+
event_type="error",
|
|
1792
|
+
error_message=event.error_message,
|
|
1793
|
+
)
|
|
1794
|
+
elif event.type == AdapterEvent.COMPLETE:
|
|
1795
|
+
self.db.add_session_event(
|
|
1796
|
+
session_id=sid,
|
|
1797
|
+
event_type="complete",
|
|
1798
|
+
)
|
|
1799
|
+
except Exception as exc:
|
|
1800
|
+
import logging
|
|
1801
|
+
logging.getLogger(__name__).debug(
|
|
1802
|
+
f"[PERSIST] Failed to persist {event.type} event for session {sid}: {exc}"
|
|
1803
|
+
)
|
|
1804
|
+
|
|
1652
1805
|
exec_result = await self.adapter.execute(
|
|
1653
1806
|
prompt=prompt,
|
|
1654
1807
|
model=mode.model,
|
|
@@ -1656,6 +1809,7 @@ class LoopExecutor:
|
|
|
1656
1809
|
timeout=mode.timeout,
|
|
1657
1810
|
json_schema=json_schema,
|
|
1658
1811
|
on_session_start=register_session_early,
|
|
1812
|
+
on_event=persist_event,
|
|
1659
1813
|
)
|
|
1660
1814
|
|
|
1661
1815
|
result.session_id = exec_result.session_id
|
|
@@ -1945,8 +2099,8 @@ class LoopExecutor:
|
|
|
1945
2099
|
generator_done = True
|
|
1946
2100
|
stop_reason = f"Generator signaled completion after {self._iteration} iterations, {self._items_generated} items"
|
|
1947
2101
|
|
|
1948
|
-
elif
|
|
1949
|
-
#
|
|
2102
|
+
elif self._no_items_streak >= 3:
|
|
2103
|
+
# 3 consecutive empty iterations — extraction is exhausted
|
|
1950
2104
|
generator_done = True
|
|
1951
2105
|
stop_reason = f"Generator exhausted (3 empty iterations), {self._items_generated} items generated"
|
|
1952
2106
|
|
ralphx/core/loop_templates.py
CHANGED
|
@@ -178,6 +178,19 @@ Return your findings as JSON:
|
|
|
178
178
|
- Avoid duplicating stories that already exist
|
|
179
179
|
- Flag any ambiguous requirements for clarification
|
|
180
180
|
- Mark infrastructure/architecture stories with category "INFRA" or "ARCH"
|
|
181
|
+
|
|
182
|
+
## Completion Signal
|
|
183
|
+
|
|
184
|
+
When you have exhausted all stories from the documents and have nothing new to add,
|
|
185
|
+
return an empty stories array and output `[GENERATION_COMPLETE]` at the end of your response:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{"stories": [], "notes": "All requirements have been extracted."}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
[GENERATION_COMPLETE]
|
|
192
|
+
|
|
193
|
+
Do NOT emit this signal if you are returning new stories in this response.
|
|
181
194
|
"""
|
|
182
195
|
|
|
183
196
|
PLANNING_RESEARCH_PROMPT = """# Research Mode
|
|
@@ -290,6 +303,19 @@ Total: {{total_stories}}
|
|
|
290
303
|
4. If web search returns nothing useful, return empty stories array with explanation
|
|
291
304
|
5. DO NOT duplicate existing stories - check IDs and titles carefully
|
|
292
305
|
6. Focus on GAPS - things genuinely missing, not rephrasing existing stories
|
|
306
|
+
|
|
307
|
+
## Completion Signal
|
|
308
|
+
|
|
309
|
+
When you have exhausted all gaps and have nothing new to add,
|
|
310
|
+
return an empty stories array and output `[GENERATION_COMPLETE]` at the end of your response:
|
|
311
|
+
|
|
312
|
+
```json
|
|
313
|
+
{"stories": [], "gaps_found": [], "notes": "All gaps have been addressed."}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
[GENERATION_COMPLETE]
|
|
317
|
+
|
|
318
|
+
Do NOT emit this signal if you are returning new stories in this response.
|
|
293
319
|
"""
|
|
294
320
|
|
|
295
321
|
|