Feat: Added finish command to scientific paper writer

This commit is contained in:
2025-12-16 10:16:13 -05:00
parent c0852820f8
commit d829afc01e
6 changed files with 1790 additions and 3413 deletions
+5 -5
View File
@@ -36,11 +36,11 @@ run_step_with_capture() {
run_step python -m cleveragents context delete --all --yes
run_step python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer_langgraph.yaml --context "bookmark" -p "Hello"
run_step python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer.yaml --context "bookmark" -p "Hello"
while IFS= read -r prompt; do
[ -z "${prompt}" ] && continue
run_step python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer_langgraph.yaml --context "bookmark" -p "${prompt}"
run_step python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer.yaml --context "bookmark" -p "${prompt}"
done <<'EOF'
!next
COVID19
@@ -62,14 +62,14 @@ EOF
while :; do
for prompt in "Suggest five additional sources specific to this section to the list" "!write" "!proofread" "!accept"; do
if [ "${prompt}" = "!accept" ]; then
run_step_with_capture python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer_langgraph.yaml --context "bookmark" -p "${prompt}"
run_step_with_capture python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer.yaml --context "bookmark" -p "${prompt}"
case "${RUN_STEP_CAPTURE_OUTPUT}" in
*!next*)
break 2
;;
esac
else
run_step python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer_langgraph.yaml --context "bookmark" -p "${prompt}"
run_step python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer.yaml --context "bookmark" -p "${prompt}"
fi
done
done
@@ -77,4 +77,4 @@ done
run_step rm -rf at_bookmark.json
run_step python -m cleveragents context export bookmark at_bookmark.json
run_step python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer_langgraph.yaml --load-context at_bookmark.json -p '!next'
run_step python -m cleveragents run -t 0 --unsafe --config examples/scientific_paper_writer.yaml --load-context at_bookmark.json -p '!next'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,419 +0,0 @@
# Scientific Paper Writer - Message Router Version
# Uses the generic message_router node type for user-defined routing patterns
name: scientific_paper_writer_with_router
# Context configuration
context:
global:
stage_order:
- intro
- discovery
- brainstorming
- vetting
- structure
- section_writing
- paper_review
- latex_generation
writing_stage: null
paper_details:
topic: null
length: null
audience: null
publication: null
format: null
other: null
# Agent definitions
agents:
workflow_controller:
type: llm
config:
provider: openai
model: gpt-4
temperature: 0.7
system_prompt: |
You are the workflow controller for a scientific paper writing assistant.
Guide users through the paper writing process step by step.
When responding to commands:
- For "!next": Emit "GOTO_DISCOVERY:" followed by a message
- For "!stage": Show the current stage
- For "!help": Show available commands
Available routing commands you can emit:
- GOTO_INTRO: Start introduction
- GOTO_DISCOVERY: Start discovery phase
- GOTO_BRAINSTORMING: Start brainstorming
- GOTO_VETTING: Start vetting
- GOTO_STRUCTURE: Start structuring
- GOTO_SECTION_WRITING: Start writing sections
- GOTO_PAPER_REVIEW: Start review
- GOTO_LATEX_GENERATION: Generate LaTeX
intro_agent:
type: llm
config:
provider: openai
model: gpt-4
temperature: 0.7
system_prompt: |
Welcome users to the scientific paper writer.
Explain the process and guide them to start with discovery.
When ready, emit "GOTO_DISCOVERY:" to proceed.
discovery_agent:
type: llm
config:
provider: openai
model: gpt-4
temperature: 0.7
system_prompt: |
You are the discovery agent. Gather paper requirements.
Ask about topic, length, audience, publication, and format.
Store details in context.
For specific questions, emit:
- ROUTE_ASK_TOPIC: Ask about topic
- ROUTE_ASK_LENGTH: Ask about length
- ROUTE_ASK_AUDIENCE: Ask about audience
ask_topic_agent:
type: llm
config:
provider: openai
model: gpt-4
system_prompt: "Ask the user about their paper topic. Be specific and helpful."
ask_length_agent:
type: llm
config:
provider: openai
model: gpt-4
system_prompt: "Ask about the desired paper length (words/pages)."
ask_audience_agent:
type: llm
config:
provider: openai
model: gpt-4
system_prompt: "Ask about the target audience for the paper."
brainstorming_agent:
type: llm
config:
provider: openai
model: gpt-4
temperature: 0.8
system_prompt: "Help brainstorm ideas for the scientific paper based on the topic."
vetting_agent:
type: llm
config:
provider: openai
model: gpt-4
system_prompt: "Evaluate and refine the paper ideas, checking for feasibility and originality."
structure_agent:
type: llm
config:
provider: openai
model: gpt-4
system_prompt: "Create a detailed outline and structure for the paper."
section_writing_agent:
type: llm
config:
provider: openai
model: gpt-4
system_prompt: "Write individual sections of the paper based on the outline."
paper_review_agent:
type: llm
config:
provider: openai
model: gpt-4
system_prompt: "Review the complete paper for coherence, accuracy, and quality."
latex_generation_agent:
type: llm
config:
provider: openai
model: gpt-4
system_prompt: "Generate LaTeX code for the final paper with proper formatting."
# Routes configuration using LangGraph with message router
routes:
main:
type: graph
entry_point: start
nodes:
start:
type: START
end:
type: END
# Main message router with user-defined routing patterns
router:
type: message_router
rules:
# Main workflow routing patterns
- match_type: prefix
pattern: "GOTO_INTRO"
target: intro
extract_message: true
separator: ":"
- match_type: prefix
pattern: "GOTO_DISCOVERY"
target: discovery
extract_message: true
separator: ":"
- match_type: prefix
pattern: "GOTO_BRAINSTORMING"
target: brainstorming
extract_message: true
separator: ":"
- match_type: prefix
pattern: "GOTO_VETTING"
target: vetting
extract_message: true
separator: ":"
- match_type: prefix
pattern: "GOTO_STRUCTURE"
target: structure
extract_message: true
separator: ":"
- match_type: prefix
pattern: "GOTO_SECTION_WRITING"
target: section_writing
extract_message: true
separator: ":"
- match_type: prefix
pattern: "GOTO_PAPER_REVIEW"
target: paper_review
extract_message: true
separator: ":"
- match_type: prefix
pattern: "GOTO_LATEX_GENERATION"
target: latex_generation
extract_message: true
separator: ":"
# Discovery sub-routing patterns
- match_type: prefix
pattern: "ROUTE_ASK_TOPIC"
target: ask_topic
extract_message: true
separator: ":"
- match_type: prefix
pattern: "ROUTE_ASK_LENGTH"
target: ask_length
extract_message: true
separator: ":"
- match_type: prefix
pattern: "ROUTE_ASK_AUDIENCE"
target: ask_audience
extract_message: true
separator: ":"
# Default to workflow controller
- match_type: suffix
pattern: ""
target: workflow_controller
extract_message: false
# Agent nodes
workflow_controller:
type: AGENT
agent: workflow_controller
intro:
type: AGENT
agent: intro_agent
discovery:
type: AGENT
agent: discovery_agent
ask_topic:
type: AGENT
agent: ask_topic_agent
ask_length:
type: AGENT
agent: ask_length_agent
ask_audience:
type: AGENT
agent: ask_audience_agent
brainstorming:
type: AGENT
agent: brainstorming_agent
vetting:
type: AGENT
agent: vetting_agent
structure:
type: AGENT
agent: structure_agent
section_writing:
type: AGENT
agent: section_writing_agent
paper_review:
type: AGENT
agent: paper_review_agent
latex_generation:
type: AGENT
agent: latex_generation_agent
# Edge definitions
edges:
# Start to router
- source: start
target: router
# Router to all possible targets based on next_node
- source: router
target: workflow_controller
condition:
type: context_value
key: next_node
value: workflow_controller
- source: router
target: intro
condition:
type: context_value
key: next_node
value: intro
- source: router
target: discovery
condition:
type: context_value
key: next_node
value: discovery
- source: router
target: ask_topic
condition:
type: context_value
key: next_node
value: ask_topic
- source: router
target: ask_length
condition:
type: context_value
key: next_node
value: ask_length
- source: router
target: ask_audience
condition:
type: context_value
key: next_node
value: ask_audience
- source: router
target: brainstorming
condition:
type: context_value
key: next_node
value: brainstorming
- source: router
target: vetting
condition:
type: context_value
key: next_node
value: vetting
- source: router
target: structure
condition:
type: context_value
key: next_node
value: structure
- source: router
target: section_writing
condition:
type: context_value
key: next_node
value: section_writing
- source: router
target: paper_review
condition:
type: context_value
key: next_node
value: paper_review
- source: router
target: latex_generation
condition:
type: context_value
key: next_node
value: latex_generation
# All agents return to router
- source: workflow_controller
target: router
- source: intro
target: router
- source: discovery
target: router
- source: ask_topic
target: router
- source: ask_length
target: router
- source: ask_audience
target: router
- source: brainstorming
target: router
- source: vetting
target: router
- source: structure
target: router
- source: section_writing
target: router
- source: paper_review
target: router
- source: latex_generation
target: router
# Output configuration
publications:
- __output__
merges:
- sources: [__input__]
target: main
+102 -38
View File
@@ -33,7 +33,9 @@ class ToolAgent(Agent):
stream architecture, supporting both built-in and custom tools.
"""
def __init__(self, name: str, config: dict[str, Any], template_renderer: TemplateRenderer):
def __init__(
self, name: str, config: dict[str, Any], template_renderer: TemplateRenderer
):
"""
Initialize a reactive tool agent.
@@ -74,20 +76,26 @@ class ToolAgent(Agent):
# Validate tools
self._validate_tools()
logger.info("Initialized reactive tool agent %s with tools: %s", name, self.tools)
logger.info(
"Initialized reactive tool agent %s with tools: %s", name, self.tools
)
def _validate_tools(self) -> None:
"""Validate the configured tools."""
for tool in self.tools:
if isinstance(tool, str):
if tool not in self.builtin_tools and not self.allow_shell:
raise AgentCreationError(f"Unknown tool '{tool}' and shell execution disabled")
raise AgentCreationError(
f"Unknown tool '{tool}' and shell execution disabled"
)
elif isinstance(tool, dict):
if "name" not in tool:
raise AgentCreationError("Tool configuration must include 'name'")
# Allow inline code tools
if "code" in tool and not isinstance(tool["code"], str):
raise AgentCreationError(f"Tool code must be a string: {tool['name']}")
raise AgentCreationError(
f"Tool code must be a string: {tool['name']}"
)
else:
raise AgentCreationError(f"Invalid tool configuration: {tool}")
@@ -140,7 +148,9 @@ class ToolAgent(Agent):
return None
async def process_message(self, message: str, context: Optional[dict[str, Any]] = None) -> str:
async def process_message(
self, message: str, context: Optional[dict[str, Any]] = None
) -> str:
"""
Process a message by executing the appropriate tool.
@@ -167,18 +177,26 @@ class ToolAgent(Agent):
effective_context = {**self.context}
# Special case: if we have exactly one tool with inline code, execute it directly
if len(self.tools) == 1 and isinstance(self.tools[0], dict) and "code" in self.tools[0]:
if (
len(self.tools) == 1
and isinstance(self.tools[0], dict)
and "code" in self.tools[0]
):
tool_config = self.tools[0]
tool_args = {
"input_data": message,
"message": message,
}
result = await self._execute_tool(tool_config["name"], tool_args, effective_context)
result = await self._execute_tool(
tool_config["name"], tool_args, effective_context
)
return str(result)
# Check if message looks like JSON but might be invalid
message_stripped = message.strip()
looks_like_json = message_stripped.startswith("{") and message_stripped.endswith("}")
looks_like_json = message_stripped.startswith(
"{"
) and message_stripped.endswith("}")
# Try to extract JSON tool request from message
tool_request = self._extract_json_from_message(message)
@@ -189,7 +207,9 @@ class ToolAgent(Agent):
if tool_name:
# Execute the tool
result = await self._execute_tool(tool_name, tool_args, effective_context)
result = await self._execute_tool(
tool_name, tool_args, effective_context
)
return str(result)
elif looks_like_json:
# Message looks like JSON but couldn't be parsed - report as invalid JSON
@@ -230,7 +250,9 @@ class ToolAgent(Agent):
# Check if it's an inline Python code tool
if tool_name in self.python_tools:
result = await self._execute_python_code(self.python_tools[tool_name], tool_args, context)
result = await self._execute_python_code(
self.python_tools[tool_name], tool_args, context
)
return result
# Check if it's a built-in tool
@@ -255,7 +277,9 @@ class ToolAgent(Agent):
# Basic safety checks
dangerous_commands = ["rm", "del", "format", "shutdown", "reboot", "kill"]
if any(cmd in command.lower() for cmd in dangerous_commands):
raise ExecutionError(f"Dangerous command '{command}' blocked in safe mode")
raise ExecutionError(
f"Dangerous command '{command}' blocked in safe mode"
)
# Build command with arguments
cmd_parts = [command]
@@ -264,20 +288,30 @@ class ToolAgent(Agent):
try:
# Execute with timeout
process = await asyncio.create_subprocess_exec(*cmd_parts, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
process = await asyncio.create_subprocess_exec(
*cmd_parts, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=self.timeout)
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=self.timeout
)
if process.returncode != 0:
error_msg = stderr.decode().strip()
raise ExecutionError(f"Command failed with code {process.returncode}: {error_msg}")
raise ExecutionError(
f"Command failed with code {process.returncode}: {error_msg}"
)
return stdout.decode().strip()
except asyncio.TimeoutError as timeout_err:
raise ExecutionError(f"Command '{command}' timed out after {self.timeout} seconds") from timeout_err
raise ExecutionError(
f"Command '{command}' timed out after {self.timeout} seconds"
) from timeout_err
async def _execute_python_code(self, code: str, args: dict[str, Any], context: Optional[dict[str, Any]]) -> str:
async def _execute_python_code(
self, code: str, args: dict[str, Any], context: Optional[dict[str, Any]]
) -> str:
"""Execute inline Python code safely."""
# Create a restricted environment for code execution
safe_globals = {
@@ -324,7 +358,9 @@ class ToolAgent(Agent):
}
# Debug: Check context before exec
context_id_before = id(local_vars["context"]) if "context" in local_vars else None
context_id_before = (
id(local_vars["context"]) if "context" in local_vars else None
)
writing_stage_before = (
local_vars["context"].get("writing_stage")
if "context" in local_vars and isinstance(local_vars["context"], dict)
@@ -335,8 +371,10 @@ class ToolAgent(Agent):
safe_globals["json"] = __import__("json") # type: ignore[assignment]
try:
# Execute the code in the restricted environment
logger.debug(f"Before exec - context: {local_vars.get('context')}")
# Execute the code in a shared environment so helper functions see locals
exec_env = dict(safe_globals)
exec_env.update(local_vars)
logger.debug(f"Before exec - context: {exec_env.get('context')}")
# Redirect stderr to suppress debug print statements unless logging is DEBUG level
import io
@@ -344,23 +382,25 @@ class ToolAgent(Agent):
import sys
# Check if we should suppress output (when root logger level > DEBUG)
should_suppress = log_module.getLogger().getEffectiveLevel() > log_module.DEBUG
should_suppress = (
log_module.getLogger().getEffectiveLevel() > log_module.DEBUG
)
if should_suppress:
# Redirect stderr to suppress print(..., file=sys.stderr) statements
old_stderr = sys.stderr
sys.stderr = io.StringIO()
try:
exec(code, safe_globals, local_vars)
exec(code, exec_env, exec_env)
finally:
sys.stderr = old_stderr
else:
exec(code, safe_globals, local_vars)
exec(code, exec_env, exec_env)
logger.debug(f"After exec - context: {local_vars.get('context')}")
logger.debug(f"After exec - context: {exec_env.get('context')}")
# Check for context updates and save globally
context_after = local_vars.get("context")
context_after = exec_env.get("context")
if context and context_after and isinstance(context_after, dict):
global _CONTEXT_UPDATES
_CONTEXT_UPDATES.append(context_after)
@@ -369,13 +409,13 @@ class ToolAgent(Agent):
# The context may have been modified in place by the exec'd code
# No need to update it since it's the same object passed by reference
# Get the result from the execution
result = local_vars.get("result")
result = exec_env.get("result")
if result is None:
# Check if there's a return value stored differently
for key in ["output", "response", "answer"]:
if key in local_vars:
result = local_vars[key]
if key in exec_env:
result = exec_env[key]
break
return str(result) if result is not None else ""
@@ -459,13 +499,17 @@ class ToolAgent(Agent):
try:
async with aiohttp.ClientSession() as session:
async with session.request(method, url, headers=headers, json=data, timeout=self.timeout) as response:
async with session.request(
method, url, headers=headers, json=data, timeout=self.timeout
) as response:
content = await response.text()
return f"Status: {response.status}\n\n{content}"
except Exception as e:
raise ExecutionError(f"HTTP request failed: {e}") from e
async def _file_read_tool(self, args: dict[str, Any], context: Optional[dict[str, Any]]) -> str:
async def _file_read_tool(
self, args: dict[str, Any], context: Optional[dict[str, Any]]
) -> str:
"""File reading tool."""
filepath = args.get("file", "")
if "args" in args and args["args"]:
@@ -479,7 +523,9 @@ class ToolAgent(Agent):
if ".." in filepath:
raise ExecutionError("Unsafe file path blocked in safe mode")
# Block absolute paths unless in unsafe mode
if filepath.startswith("/") and not (context and context.get("_unsafe_mode", False)):
if filepath.startswith("/") and not (
context and context.get("_unsafe_mode", False)
):
raise ExecutionError("Unsafe file path blocked in safe mode")
try:
@@ -544,7 +590,10 @@ class ToolAgent(Agent):
raise ExecutionError("Unsafe file path blocked in safe mode")
# Check if path escapes working directory
path_escapes_working_dir = not absolute_path.startswith(working_dir + os.sep) and absolute_path != working_dir
path_escapes_working_dir = (
not absolute_path.startswith(working_dir + os.sep)
and absolute_path != working_dir
)
if not unsafe_mode:
# Caller didn't opt-in to unsafe_mode: enforce working directory restriction
@@ -564,7 +613,9 @@ class ToolAgent(Agent):
else:
# Caller opted-in to unsafe_mode
if path_escapes_working_dir and self.safe_mode:
logger.debug("Unsafe mode: allowing path outside working dir: %s", filepath)
logger.debug(
"Unsafe mode: allowing path outside working dir: %s", filepath
)
if is_absolute and self.safe_mode:
# Allow absolute paths when caller provides unsafe_mode=True
@@ -630,11 +681,15 @@ class ToolAgent(Agent):
existing_lines.insert(line_idx, formatted_content)
insert_location = line_idx + 1
else:
raise ExecutionError(f"Invalid position '{position}'. Use 'start', 'end', or line number.")
raise ExecutionError(
f"Invalid position '{position}'. Use 'start', 'end', or line number."
)
return existing_lines, insert_location
async def _file_write_tool(self, args: dict[str, Any], context: Optional[dict[str, Any]]) -> str:
async def _file_write_tool(
self, args: dict[str, Any], context: Optional[dict[str, Any]]
) -> str:
"""File writing tool with support for write, append, and insert modes."""
filepath = args.get("file", "")
content = args.get("content", "")
@@ -668,12 +723,19 @@ class ToolAgent(Agent):
if mode == "insert":
# Insert mode at specified position
existing_lines, insert_location = self._handle_insert_position(filepath, content, position)
existing_lines, insert_location = self._handle_insert_position(
filepath, content, position
)
with open(filepath, "w", encoding="utf-8") as f:
f.writelines(existing_lines)
return f"Successfully inserted {len(content)} characters " f"at line {insert_location} in {filepath}"
return (
f"Successfully inserted {len(content)} characters "
f"at line {insert_location} in {filepath}"
)
raise ExecutionError(f"Invalid mode '{mode}'. Use 'w' (write), 'a' (append), or 'insert'.")
raise ExecutionError(
f"Invalid mode '{mode}'. Use 'w' (write), 'a' (append), or 'insert'."
)
except Exception as e:
logger.error("File write failed for %s: %s", filepath, e)
raise ExecutionError(f"File write failed: {e}") from e
@@ -682,7 +744,9 @@ class ToolAgent(Agent):
"""Get the capabilities of the tool agent."""
capabilities = ["tool-execution", "command-execution"]
if "http_request" in self.tools or "http_request" in [t.get("name") for t in self.tools if isinstance(t, dict)]:
if "http_request" in self.tools or "http_request" in [
t.get("name") for t in self.tools if isinstance(t, dict)
]:
capabilities.append("http-requests")
dict_tool_names = [t.get("name") for t in self.tools if isinstance(t, dict)]
+77 -14
View File
@@ -345,12 +345,22 @@ class PureLangGraph:
f"_execute_from_node called with node_name='{node_name}', message type={type(message)}, depth={depth}"
)
# Prevent infinite recursion - use a much lower limit for interactive graphs
max_depth = 20 # Maximum graph traversal depth
# Prevent infinite recursion - but allow deeper traversal for complex workflows
# For workflows with many sections (like scientific paper writer), we need high limits
# Each section requires ~20 node visits, so 50 sections = 1000+ visits
# Use a generous limit that allows complex workflows to complete
max_depth = max(2000, len(self.nodes) * 50)
if depth > max_depth:
self.logger.warning(
f"Maximum recursion depth {max_depth} exceeded. Returning current output."
)
import sys
print(
f"MAX_DEPTH_EXCEEDED node={node_name} depth={depth} max={max_depth}",
file=sys.stderr,
)
return message
# Track node visits with message fingerprints to detect actual loops
@@ -369,13 +379,39 @@ class PureLangGraph:
# Detect when a specific non-router node is visited with the same message twice
# This indicates an actual loop, not just a legitimate revisit with a different message
# EXCEPTION: When auto_finish_active is True, we allow repeated visits to support
# the multi-section writing workflow where sections are processed sequentially
if "router" not in node_name.lower():
if self._node_message_visits[visit_key] > 1:
self.logger.debug(
f"Node '{node_name}' visited {self._node_message_visits[visit_key]} times "
f"with the same message. Stopping execution to return output to user."
)
return message
# Check if auto_finish_active is set in context - if so, bypass loop detection
state = self.state_manager.get_state()
auto_finish_active = False
if hasattr(state, "metadata"):
# Check direct metadata first
auto_finish_active = state.metadata.get("auto_finish_active", False)
# Also check nested context
if not auto_finish_active and "context" in state.metadata:
auto_finish_active = state.metadata.get("context", {}).get(
"auto_finish_active", False
)
if auto_finish_active:
self.logger.debug(
f"Node '{node_name}' visited {self._node_message_visits[visit_key]} times "
f"with same message, but auto_finish_active=True - continuing execution."
)
else:
self.logger.debug(
f"Node '{node_name}' visited {self._node_message_visits[visit_key]} times "
f"with the same message. Stopping execution to return output to user."
)
import sys
print(
f"LOOP_STOP node={node_name} visits={self._node_message_visits[visit_key]}",
file=sys.stderr,
)
return message
# Additional safeguard: track consecutive router-agent cycles
if not hasattr(self, "_execution_path"):
@@ -389,6 +425,7 @@ class PureLangGraph:
# Check for router-agent ping-pong pattern
# A true ping-pong is when the SAME agent is visited twice with router in between
# Pattern we're looking for: router -> agentX -> router -> agentX (same agent)
# EXCEPTION: When auto_finish_active is True, we allow this pattern for workflow progression
if len(self._execution_path) >= 4:
recent = self._execution_path[-4:]
# Check if pattern is: router -> agent -> router -> SAME agent
@@ -396,13 +433,37 @@ class PureLangGraph:
if is_router == [True, False, True, False]:
# Check if the two agents are the same
if recent[1] == recent[3]:
self.logger.debug(
f"Detected router-agent ping-pong loop with same agent: {recent}. "
f"Stopping execution to return output."
)
if self._execution_path:
self._execution_path.pop()
return message
# Check if auto_finish_active is set - if so, bypass ping-pong detection
state = self.state_manager.get_state()
auto_finish_active = False
if hasattr(state, "metadata"):
auto_finish_active = state.metadata.get(
"auto_finish_active", False
)
if not auto_finish_active and "context" in state.metadata:
auto_finish_active = state.metadata.get("context", {}).get(
"auto_finish_active", False
)
if auto_finish_active:
self.logger.debug(
f"Detected router-agent pattern with same agent: {recent}, "
f"but auto_finish_active=True - continuing execution."
)
else:
self.logger.debug(
f"Detected router-agent ping-pong loop with same agent: {recent}. "
f"Stopping execution to return output."
)
import sys
print(
f"PING_PONG_DETECTED recent={recent} auto_finish={auto_finish_active}",
file=sys.stderr,
)
if self._execution_path:
self._execution_path.pop()
return message
# Handle special nodes
if node_name == "start":
@@ -498,6 +559,8 @@ class PureLangGraph:
"SET_",
"CMD_",
"DISCOVERY_RESPONSE",
"AUTO_SECTIONS_COMPLETE",
"COMMAND_OUTPUT",
]
)
if not has_routing_command: