forked from cleveragents/cleveragents-core
Feat: Added progress bar as a built-in tool and added it to finish command
This commit is contained in:
@@ -370,8 +370,24 @@ agents:
|
||||
def has_structure():
|
||||
return bool(context.get('table_of_contents'))
|
||||
|
||||
def report_progress(stage_name: str, note: str = ""):
|
||||
try:
|
||||
from cleveragents.core.progress import ProgressBarManager
|
||||
|
||||
ProgressBarManager.update(
|
||||
stage=stage_name,
|
||||
context=context,
|
||||
message=note,
|
||||
)
|
||||
except Exception as progress_err: # pylint: disable=broad-except
|
||||
print(
|
||||
f"PROGRESS_BAR_ERROR {progress_err}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
def start_stage(stage_name):
|
||||
context['writing_stage'] = stage_name
|
||||
report_progress(stage_name, "Auto-finish in progress")
|
||||
if stage_name == 'brainstorming':
|
||||
set_expect('brainstorming_output')
|
||||
return ("GOTO_BRAINSTORMING:Auto-finish mode is active. Using the captured requirements, "
|
||||
@@ -391,6 +407,23 @@ agents:
|
||||
return ("COMMAND_OUTPUT:Auto-finish cannot start section writing because the structure stage "
|
||||
"is incomplete. Please finish the structure before running !finish.")
|
||||
label = current_section_label()
|
||||
section_paths = context.get('section_paths') or []
|
||||
report_progress(
|
||||
stage_name,
|
||||
f"Sections {context.get('current_section_index', 0)}/{len(section_paths)}",
|
||||
)
|
||||
try:
|
||||
from cleveragents.core.progress import ProgressBarManager
|
||||
|
||||
ProgressBarManager.update(
|
||||
stage=stage_name,
|
||||
current=context.get('current_section_index', 0),
|
||||
total=len(section_paths) if section_paths else 1,
|
||||
message=f"Sections {context.get('current_section_index', 0)}/{len(section_paths)}",
|
||||
context=context,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
set_expect('source_selector_output')
|
||||
return f"GOTO_SECTION_WRITING:{label or ''}"
|
||||
if stage_name == 'paper_review':
|
||||
@@ -401,6 +434,7 @@ agents:
|
||||
# Keep auto_finish_active=True to maintain high depth limit
|
||||
# Mark that we're in final stage
|
||||
context['auto_finish_final_stage'] = True
|
||||
report_progress(stage_name, "Generating LaTeX and compiling")
|
||||
return "GOTO_LATEX_GENERATION:"
|
||||
# Only set auto_finish_active=False at the very end
|
||||
context['auto_finish_active'] = False
|
||||
@@ -426,6 +460,22 @@ agents:
|
||||
label = current_section_label()
|
||||
context['current_section_path'] = label
|
||||
set_expect('source_selector_output')
|
||||
report_progress(
|
||||
'section_writing',
|
||||
f"Completed section {current_idx} of {len(section_paths)}",
|
||||
)
|
||||
try:
|
||||
from cleveragents.core.progress import ProgressBarManager
|
||||
|
||||
ProgressBarManager.update(
|
||||
stage='section_writing',
|
||||
current=current_idx,
|
||||
total=len(section_paths) if section_paths else 1,
|
||||
message=f"Completed section {current_idx} of {len(section_paths)}",
|
||||
context=context,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
next_action = f"GOTO_SECTION_WRITING:{label}"
|
||||
elif expect == 'brainstorming_output':
|
||||
next_action = start_stage('vetting')
|
||||
@@ -1083,85 +1133,83 @@ agents:
|
||||
tools:
|
||||
- name: save_parsed_toc
|
||||
code: |
|
||||
import sys
|
||||
import json
|
||||
print(f"DEBUG toc_parser_saver", file=sys.stderr)
|
||||
import sys
|
||||
import json
|
||||
print("DEBUG toc_parser_saver", file=sys.stderr)
|
||||
|
||||
# Flatten sections iteratively (no recursion)
|
||||
def flatten_sections(sections):
|
||||
"""Flatten nested sections into a list with full paths using iteration."""
|
||||
flat_list = []
|
||||
# Stack holds tuples of (section_list, parent_path, index)
|
||||
stack = [(sections, "", 0)]
|
||||
|
||||
while stack:
|
||||
current_sections, parent_path, idx = stack.pop()
|
||||
|
||||
# Process remaining items in current_sections starting from idx
|
||||
while idx < len(current_sections):
|
||||
section = current_sections[idx]
|
||||
title = section.get('title', '')
|
||||
|
||||
if title:
|
||||
# Build the full path for this section
|
||||
current_path = parent_path + " > " + title if parent_path else title
|
||||
flat_list.append(current_path)
|
||||
|
||||
# Check for subsections
|
||||
subsections = section.get('subsections', [])
|
||||
if subsections:
|
||||
# Save current position to return to later
|
||||
stack.append((current_sections, parent_path, idx + 1))
|
||||
# Start processing subsections
|
||||
stack.append((subsections, current_path, 0))
|
||||
break
|
||||
|
||||
idx = idx + 1
|
||||
|
||||
return flat_list
|
||||
def flatten_sections(sections):
|
||||
"""Flatten nested sections into a list with full paths using iteration."""
|
||||
flat_list = []
|
||||
stack = [(sections, "", 0)]
|
||||
|
||||
# Parse the JSON response from Gemini
|
||||
try:
|
||||
# Strip markdown code block markers if present
|
||||
json_text = input_data.strip()
|
||||
if json_text.startswith('```'):
|
||||
lines = json_text.split('\n')
|
||||
if lines[0].startswith('```'):
|
||||
lines = lines[1:]
|
||||
if lines[-1].strip() == '```':
|
||||
lines = lines[:-1]
|
||||
json_text = '\n'.join(lines)
|
||||
|
||||
toc_data = json.loads(json_text)
|
||||
|
||||
# Handle both formats: {"sections": [...]} or just [...]
|
||||
if type(toc_data) == list:
|
||||
sections = toc_data
|
||||
elif type(toc_data) == dict:
|
||||
sections = toc_data.get('sections', [])
|
||||
else:
|
||||
sections = []
|
||||
while stack:
|
||||
current_sections, parent_path, idx = stack.pop()
|
||||
while idx < len(current_sections):
|
||||
section = current_sections[idx]
|
||||
title = section.get("title", "")
|
||||
if title:
|
||||
current_path = f"{parent_path} > {title}" if parent_path else title
|
||||
flat_list.append(current_path)
|
||||
subsections = section.get("subsections", [])
|
||||
if subsections:
|
||||
stack.append((current_sections, parent_path, idx + 1))
|
||||
stack.append((subsections, current_path, 0))
|
||||
break
|
||||
idx += 1
|
||||
|
||||
# Flatten all sections and subsections
|
||||
section_paths = flatten_sections(sections)
|
||||
return flat_list
|
||||
|
||||
try:
|
||||
json_text = input_data.strip()
|
||||
if json_text.startswith("```"):
|
||||
lines = json_text.split("\n")
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
json_text = "\n".join(lines)
|
||||
|
||||
toc_data = json.loads(json_text)
|
||||
|
||||
if isinstance(toc_data, list):
|
||||
sections = toc_data
|
||||
elif isinstance(toc_data, dict):
|
||||
sections = toc_data.get("sections", [])
|
||||
else:
|
||||
sections = []
|
||||
|
||||
section_paths = flatten_sections(sections)
|
||||
context["section_paths"] = section_paths
|
||||
context["current_section_index"] = 0
|
||||
|
||||
try:
|
||||
from cleveragents.core.progress import ProgressBarManager
|
||||
|
||||
ProgressBarManager.update(
|
||||
stage=context.get("writing_stage", "section_writing") or "section_writing",
|
||||
current=0,
|
||||
total=len(section_paths) if section_paths else 1,
|
||||
message="Preparing sections",
|
||||
context=context,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if section_paths:
|
||||
first_section = section_paths[0]
|
||||
context["current_section_path"] = first_section
|
||||
result = f"ROUTE_SELECT_SOURCES:{first_section}"
|
||||
else:
|
||||
result = "ERROR: No sections found in table of contents"
|
||||
|
||||
except Exception:
|
||||
context["section_paths"] = ["Introduction", "Methods", "Results", "Discussion"]
|
||||
context["current_section_index"] = 0
|
||||
first_section = context["section_paths"][0]
|
||||
context["current_section_path"] = first_section
|
||||
result = f"ROUTE_SELECT_SOURCES:{first_section}"
|
||||
|
||||
context['section_paths'] = section_paths
|
||||
context['current_section_index'] = 0
|
||||
|
||||
# Route to first section
|
||||
if section_paths:
|
||||
first_section = section_paths[0]
|
||||
context['current_section_path'] = first_section
|
||||
result = f"ROUTE_SELECT_SOURCES:{first_section}"
|
||||
else:
|
||||
result = "ERROR: No sections found in table of contents"
|
||||
except:
|
||||
# Fallback to generic sections
|
||||
context['section_paths'] = ['Introduction', 'Methods', 'Results', 'Discussion']
|
||||
context['current_section_index'] = 0
|
||||
first_section = context['section_paths'][0]
|
||||
context['current_section_path'] = first_section
|
||||
result = f"ROUTE_SELECT_SOURCES:{first_section}"
|
||||
|
||||
source_selector:
|
||||
type: llm
|
||||
@@ -1393,12 +1441,28 @@ agents:
|
||||
code: |
|
||||
current_index = context.get('current_section_index', 0)
|
||||
section_paths = context.get('section_paths') or []
|
||||
completed_section = section_paths[current_index] if current_index < len(section_paths) else 'Unknown'
|
||||
completed_section = (
|
||||
section_paths[current_index] if current_index < len(section_paths) else 'Unknown'
|
||||
)
|
||||
|
||||
# Advance to the next section and update the current path so auto-finish progresses
|
||||
context['current_section_index'] = current_index + 1
|
||||
new_index = current_index + 1
|
||||
|
||||
# Progress update after completing a section
|
||||
try:
|
||||
from cleveragents.core.progress import ProgressBarManager
|
||||
|
||||
ProgressBarManager.update(
|
||||
stage=context.get('writing_stage', 'section_writing') or 'section_writing',
|
||||
current=new_index,
|
||||
total=len(section_paths) if section_paths else 1,
|
||||
message=f"Completed {new_index}/{len(section_paths)} sections",
|
||||
context=context,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if new_index >= len(section_paths):
|
||||
# All sections complete; clear current section path
|
||||
context['current_section_path'] = None
|
||||
@@ -1409,6 +1473,8 @@ agents:
|
||||
# Include unique info in message to avoid loop detection
|
||||
result = f"ROUTE_NEXT_SECTION:{next_section}|completed={completed_section}|idx={new_index}"
|
||||
|
||||
|
||||
|
||||
# ============================================
|
||||
# SECTION PROOFREADING
|
||||
# ============================================
|
||||
@@ -1518,19 +1584,29 @@ agents:
|
||||
assembled = full_text.strip()
|
||||
context['assembled_paper'] = assembled
|
||||
|
||||
# Don't print full paper during auto-finish - it will be shown at the end
|
||||
auto_active = context.get('auto_finish_active', False)
|
||||
if not auto_active:
|
||||
preview_output = (
|
||||
f"# Full Paper\n\n{assembled}" if assembled else "# Full Paper\n\n(No content available.)"
|
||||
)
|
||||
print(preview_output, flush=True)
|
||||
|
||||
instruction = (
|
||||
"Please review the assembled paper stored in context['assembled_paper'] "
|
||||
"for coherence, grammar, citations, and overall flow."
|
||||
already_printed_auto = context.get('paper_review_printed_auto', False)
|
||||
preview_output = (
|
||||
f"# Full Paper\n\n{assembled}" if assembled else "# Full Paper\n\n(No content available.)"
|
||||
)
|
||||
result = f"ROUTE_REVIEW_PAPER:{instruction}"
|
||||
|
||||
# Print the assembled paper once; avoid duplicates in auto-finish
|
||||
if not (auto_active and already_printed_auto):
|
||||
print(preview_output, flush=True)
|
||||
if auto_active:
|
||||
context['paper_review_printed_auto'] = True
|
||||
|
||||
if auto_active:
|
||||
result = (
|
||||
"AUTO_PAPER_REVIEW_COMPLETE:Auto-finish mode printed the full paper; "
|
||||
"skipping LLM review and proceeding to LaTeX generation."
|
||||
)
|
||||
else:
|
||||
instruction = (
|
||||
"Please review the assembled paper stored in context['assembled_paper'] "
|
||||
"for coherence, grammar, citations, and overall flow."
|
||||
)
|
||||
result = f"ROUTE_REVIEW_PAPER:{instruction}"
|
||||
|
||||
paper_review_agent:
|
||||
type: llm
|
||||
@@ -2071,15 +2147,22 @@ routes:
|
||||
target: auto_finish_driver
|
||||
extract_message: true
|
||||
separator: ":"
|
||||
|
||||
- match_type: prefix
|
||||
pattern: "AUTO_PAPER_REVIEW_COMPLETE"
|
||||
target: auto_finish_driver
|
||||
extract_message: true
|
||||
separator: ":"
|
||||
|
||||
# Paper review routing
|
||||
|
||||
|
||||
- match_type: prefix
|
||||
pattern: "ROUTE_REVIEW_PAPER"
|
||||
target: paper_review_agent
|
||||
extract_message: true
|
||||
separator: ":"
|
||||
|
||||
|
||||
# LaTeX generation routing patterns
|
||||
- match_type: prefix
|
||||
pattern: "ROUTE_GEN_STRUCTURE"
|
||||
|
||||
@@ -71,6 +71,7 @@ class ToolAgent(Agent):
|
||||
"http_request": self._http_request_tool,
|
||||
"file_read": self._file_read_tool,
|
||||
"file_write": self._file_write_tool,
|
||||
"progress_bar": self._progress_bar_tool,
|
||||
}
|
||||
|
||||
# Validate tools
|
||||
@@ -740,6 +741,46 @@ class ToolAgent(Agent):
|
||||
logger.error("File write failed for %s: %s", filepath, e)
|
||||
raise ExecutionError(f"File write failed: {e}") from e
|
||||
|
||||
async def _progress_bar_tool(
|
||||
self, args: dict[str, Any], context: Optional[dict[str, Any]]
|
||||
) -> str:
|
||||
"""Render or update a simple progress bar directly to stdout."""
|
||||
|
||||
try:
|
||||
from cleveragents.core.progress import ProgressBarManager
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.error("Progress bar helper unavailable: %s", e)
|
||||
raise ExecutionError("Progress bar helper unavailable") from e
|
||||
|
||||
def _to_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
stage = args.get("stage") or args.get("phase")
|
||||
message = args.get("message") or args.get("label")
|
||||
total = _to_int(args.get("total") or args.get("steps") or args.get("count"))
|
||||
current = _to_int(args.get("current") or args.get("done"))
|
||||
remaining = _to_int(args.get("remaining"))
|
||||
sections_total = _to_int(args.get("sections_total"))
|
||||
sections_completed = _to_int(
|
||||
args.get("sections_completed") or args.get("completed_sections")
|
||||
)
|
||||
|
||||
rendered = ProgressBarManager.update(
|
||||
stage=stage,
|
||||
current=current,
|
||||
total=total,
|
||||
remaining=remaining,
|
||||
sections_total=sections_total,
|
||||
sections_completed=sections_completed,
|
||||
message=message,
|
||||
context=context,
|
||||
)
|
||||
|
||||
return f"Progress updated: {rendered}"
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""Get the capabilities of the tool agent."""
|
||||
capabilities = ["tool-execution", "command-execution"]
|
||||
|
||||
@@ -1255,7 +1255,14 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes
|
||||
|
||||
# Prepare tool request
|
||||
tool_request = json.dumps({"tool": tool_name, "args": tool_params})
|
||||
context = {"_unsafe_mode": self.unsafe}
|
||||
context: dict[str, Any] = {"_unsafe_mode": self.unsafe}
|
||||
if self.config and self.config.global_context:
|
||||
try:
|
||||
context.update(self.config.global_context)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self.logger.debug(
|
||||
"Unable to merge global context into tool call: %s", e
|
||||
)
|
||||
|
||||
# Execute based on whether we're in an async context
|
||||
result = None
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Lightweight progress bar rendering utilities for CleverAgents.
|
||||
|
||||
This module provides a small, dependency-free progress bar helper that can be
|
||||
invoked from built-in tools or inline Python tools to display long-running
|
||||
workflow progress (e.g., auto-finish in scientific paper writer). The
|
||||
ProgressBarManager keeps minimal state and writes updates directly to stdout so
|
||||
it can surface progress even when LangGraph routing is bypassed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProgressSnapshot:
|
||||
"""Represents a single progress bar state."""
|
||||
|
||||
stage: str = ""
|
||||
current: int = 0
|
||||
total: int = 1
|
||||
message: str = ""
|
||||
|
||||
@property
|
||||
def percent(self) -> float:
|
||||
if self.total <= 0:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, self.current / float(self.total)))
|
||||
|
||||
|
||||
class ProgressBarManager:
|
||||
"""Renders and tracks a simple textual progress bar.
|
||||
|
||||
The manager is intentionally lightweight and thread-safe; it stores the last
|
||||
snapshot so subsequent updates can fill in missing values (e.g., keep the
|
||||
previous total when only the current count changes).
|
||||
"""
|
||||
|
||||
_lock = threading.Lock()
|
||||
_snapshot = ProgressSnapshot()
|
||||
_bar_width = 24
|
||||
|
||||
@classmethod
|
||||
def _derive_from_context(
|
||||
cls, context: Optional[dict[str, Any]]
|
||||
) -> ProgressSnapshot:
|
||||
"""Best-effort extraction of progress info from workflow context."""
|
||||
|
||||
if not isinstance(context, dict):
|
||||
return ProgressSnapshot()
|
||||
|
||||
stage = str(context.get("writing_stage", "") or "")
|
||||
section_paths = context.get("section_paths") or []
|
||||
total_sections = len(section_paths) if isinstance(section_paths, list) else 0
|
||||
current_section_index = int(context.get("current_section_index", 0) or 0)
|
||||
latex_index = int(context.get("current_latex_index", 0) or 0)
|
||||
|
||||
if stage == "section_writing" and total_sections:
|
||||
message = f"Writing sections ({current_section_index}/{total_sections})"
|
||||
return ProgressSnapshot(
|
||||
stage=stage,
|
||||
current=max(0, min(current_section_index, total_sections)),
|
||||
total=total_sections,
|
||||
message=message,
|
||||
)
|
||||
|
||||
if stage == "latex_generation" and total_sections:
|
||||
message = f"LaTeX conversion ({latex_index}/{total_sections})"
|
||||
return ProgressSnapshot(
|
||||
stage=stage,
|
||||
current=max(0, min(latex_index, total_sections)),
|
||||
total=total_sections,
|
||||
message=message,
|
||||
)
|
||||
|
||||
return ProgressSnapshot(stage=stage)
|
||||
|
||||
@classmethod
|
||||
def update(
|
||||
cls,
|
||||
*,
|
||||
stage: Optional[str] = None,
|
||||
current: Optional[int] = None,
|
||||
total: Optional[int] = None,
|
||||
remaining: Optional[int] = None,
|
||||
sections_total: Optional[int] = None,
|
||||
sections_completed: Optional[int] = None,
|
||||
message: Optional[str] = None,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Update the progress bar and return the rendered line.
|
||||
|
||||
The method is forgiving: callers can provide any combination of current,
|
||||
total, remaining, or contextual information. Missing pieces will be
|
||||
backfilled from the previous snapshot or inferred from the provided
|
||||
context.
|
||||
"""
|
||||
|
||||
with cls._lock:
|
||||
base = ProgressSnapshot(**vars(cls._snapshot))
|
||||
context_snapshot = cls._derive_from_context(context)
|
||||
|
||||
resolved_stage = (stage or context_snapshot.stage or base.stage).strip()
|
||||
|
||||
resolved_total = next(
|
||||
(
|
||||
v
|
||||
for v in (
|
||||
total,
|
||||
sections_total,
|
||||
context_snapshot.total if context_snapshot.total > 0 else None,
|
||||
base.total if base.total > 0 else None,
|
||||
)
|
||||
if v is not None
|
||||
),
|
||||
1,
|
||||
)
|
||||
|
||||
resolved_current = next(
|
||||
(
|
||||
v
|
||||
for v in (
|
||||
current,
|
||||
sections_completed,
|
||||
context_snapshot.current,
|
||||
None
|
||||
if remaining is None or resolved_total is None
|
||||
else max(0, resolved_total - remaining),
|
||||
base.current,
|
||||
)
|
||||
if v is not None
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
resolved_current = max(0, min(resolved_current, resolved_total))
|
||||
|
||||
resolved_message = (
|
||||
message
|
||||
or context_snapshot.message
|
||||
or base.message
|
||||
or ("Stage in progress" if resolved_stage else "Working")
|
||||
)
|
||||
|
||||
snapshot = ProgressSnapshot(
|
||||
stage=resolved_stage,
|
||||
current=resolved_current,
|
||||
total=resolved_total,
|
||||
message=resolved_message,
|
||||
)
|
||||
|
||||
cls._snapshot = snapshot
|
||||
|
||||
rendered = cls._render(snapshot)
|
||||
return rendered
|
||||
|
||||
@classmethod
|
||||
def _render(cls, snapshot: ProgressSnapshot) -> str:
|
||||
pct = snapshot.percent
|
||||
filled = int(pct * cls._bar_width)
|
||||
empty = cls._bar_width - filled
|
||||
bar = "█" * filled + "░" * empty
|
||||
|
||||
label_parts = []
|
||||
if snapshot.stage:
|
||||
label_parts.append(snapshot.stage)
|
||||
if snapshot.message:
|
||||
label_parts.append(snapshot.message)
|
||||
label = " • ".join(label_parts).strip()
|
||||
|
||||
line = f"[{bar}] {pct * 100:5.1f}% ({snapshot.current}/{snapshot.total})"
|
||||
if label:
|
||||
line = f"{line} {label}"
|
||||
|
||||
# Print to stdout immediately so progress is visible even without router output
|
||||
sys.stdout.write("\r" + line)
|
||||
sys.stdout.flush()
|
||||
if pct >= 1.0:
|
||||
sys.stdout.write("\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
return line
|
||||
Reference in New Issue
Block a user