Skip to content

Commit 35483b5

Browse files
committed
Add a redesigned ensemble agent
1 parent 6242800 commit 35483b5

20 files changed

Lines changed: 1165 additions & 0 deletions

ensemble_agent.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env python3
2+
"""Entry point for ensemble_agent — works as a standalone script."""
3+
import asyncio
4+
from ensemble_agent.config import parse_args
5+
from ensemble_agent.agent import run_agent
6+
7+
asyncio.run(run_agent(parse_args()))

ensemble_agent/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Modular ensemble agent for generating, running, and fixing simulation scripts."""

ensemble_agent/__main__.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Entry point for python -m ensemble_agent."""
2+
3+
import asyncio
4+
import sys
5+
6+
from .config import parse_args
7+
from .agent import run_agent
8+
9+
10+
def main():
11+
config = parse_args()
12+
try:
13+
asyncio.run(run_agent(config))
14+
except KeyboardInterrupt:
15+
print("\n\nInterrupted by user")
16+
sys.exit(0)
17+
except Exception as e:
18+
print(f"\nError: {e}")
19+
import traceback
20+
traceback.print_exc()
21+
sys.exit(1)
22+
23+
24+
if __name__ == "__main__":
25+
main()

ensemble_agent/agent.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""Agent orchestrator — builds the agent, runs autonomous or interactive mode."""
2+
3+
import shutil
4+
import sys
5+
from contextlib import AsyncExitStack
6+
from pathlib import Path
7+
8+
from langchain.agents import create_agent
9+
from langchain_core.messages import HumanMessage, SystemMessage
10+
11+
from .archive import ArchiveManager
12+
from .config import AgentConfig, INPUT_MARKER
13+
from .llm import create_llm
14+
from .mcp_client import connect_mcp, find_mcp_server
15+
from .prompts import (
16+
AUTONOMOUS_GOAL,
17+
INTERACTIVE_GOAL,
18+
INTERACTIVE_REVIEW_GOAL,
19+
build_system_prompt,
20+
)
21+
from .scripts import detect_run_script
22+
from .skills import load_skills
23+
from .skills.generator import GeneratorSkill
24+
25+
26+
async def run_agent(config: AgentConfig):
27+
"""Main entry point: build skills, connect MCP, run the agent loop."""
28+
29+
# Set up archive manager
30+
archive = ArchiveManager(config.output_dir)
31+
32+
# Load skills
33+
skills = load_skills(config.skills, config, archive)
34+
has_generator = any(isinstance(s, GeneratorSkill) for s in skills)
35+
36+
# MCP setup for generator skill
37+
async with AsyncExitStack() as stack:
38+
if has_generator:
39+
mcp_server = find_mcp_server(config.mcp_server)
40+
print(f"Generator MCP: {mcp_server}")
41+
session = await stack.enter_async_context(connect_mcp(mcp_server))
42+
print("Connected to MCP server")
43+
44+
# Get MCP tool schema and inject into generator skill
45+
mcp_tools = await session.list_tools()
46+
mcp_tool = mcp_tools.tools[0]
47+
for skill in skills:
48+
if isinstance(skill, GeneratorSkill):
49+
skill.set_mcp_session(session)
50+
skill.set_mcp_tool_schema(mcp_tool)
51+
52+
# Collect tools from all skills
53+
tools = []
54+
for skill in skills:
55+
tools.extend(skill.get_tools())
56+
57+
# Create LLM and agent
58+
llm = create_llm(config.model, config.temperature, config.base_url)
59+
agent = create_agent(llm, tools)
60+
print("Agent initialized\n")
61+
62+
# Build system prompt
63+
system_prompt = build_system_prompt(skills, has_generator)
64+
messages = [("system", system_prompt)]
65+
66+
if config.show_prompts:
67+
print(f"System prompt:\n{system_prompt}\n")
68+
69+
# Determine initial message
70+
initial_msg = _build_initial_message(config, archive)
71+
72+
if not config.interactive:
73+
await _run_autonomous(agent, messages, initial_msg, config)
74+
else:
75+
await _run_interactive(agent, messages, initial_msg, config, has_generator)
76+
77+
78+
def _build_initial_message(config, archive):
79+
"""Build the first user message based on config."""
80+
if config.scripts_dir:
81+
scripts_dir = Path(config.scripts_dir)
82+
for f in sorted(scripts_dir.glob("*.py")):
83+
shutil.copy(f, archive.work_dir)
84+
print(f"Copied: {f.name}")
85+
archive.start("copied_scripts")
86+
archive.archive_scripts()
87+
88+
run_scripts = list(archive.work_dir.glob("run_*.py"))
89+
run_name = run_scripts[0].name if run_scripts else "run_libe.py"
90+
return INTERACTIVE_REVIEW_GOAL.format(run_script_name=run_name)
91+
92+
user_prompt = config.get_user_prompt()
93+
if user_prompt:
94+
return user_prompt
95+
96+
if config.interactive:
97+
print("Describe the scripts you want to generate (or press Enter for default demo):", flush=True)
98+
print(INPUT_MARKER, flush=True)
99+
user_input = input().strip()
100+
if user_input:
101+
return user_input
102+
print("Using default demo prompt")
103+
104+
return (
105+
"Create six_hump_camel APOSMM scripts:\n"
106+
"- Executable: six_hump_camel/six_hump_camel.x\n"
107+
"- Input: six_hump_camel/input.txt\n"
108+
"- Template vars: X0, X1\n"
109+
"- 4 workers, 100 sims.\n"
110+
"- The output file for each simulation is output.txt\n"
111+
"- The bounds should be 0,1 and -1,2 for X0 and X1 respectively"
112+
)
113+
114+
115+
async def _run_autonomous(agent, messages, initial_msg, config):
116+
"""Single invocation — agent generates/loads, runs, fixes, reports."""
117+
goal = AUTONOMOUS_GOAL.format(initial_msg=initial_msg)
118+
messages.append(("user", goal))
119+
120+
if config.show_prompts:
121+
print(f"Goal: {goal}\n")
122+
print("Starting agent...\n")
123+
124+
result = await agent.ainvoke({"messages": messages})
125+
print(f"\n{'=' * 60}")
126+
print("Agent completed")
127+
print(f"{'=' * 60}")
128+
print(result["messages"][-1].content)
129+
130+
131+
async def _run_interactive(agent, messages, initial_msg, config, has_generator):
132+
"""Chat loop — agent responds, waits for user input, repeats."""
133+
if has_generator:
134+
goal = INTERACTIVE_GOAL.format(initial_msg=initial_msg)
135+
else:
136+
goal = initial_msg
137+
messages.append(("user", goal))
138+
print("Starting agent...\n")
139+
140+
while True:
141+
try:
142+
result = await agent.ainvoke({"messages": messages})
143+
messages = result["messages"]
144+
response = messages[-1].content
145+
if response:
146+
print(f"\n{response}", flush=True)
147+
except Exception as e:
148+
print(f"\nAgent error: {e}", flush=True)
149+
150+
# Wait for user input
151+
print(INPUT_MARKER, flush=True)
152+
user_input = input().strip()
153+
154+
if not user_input or user_input.lower() in ("quit", "exit", "done"):
155+
print("\nSession ended")
156+
break
157+
158+
messages.append(
159+
SystemMessage(
160+
content="STOP. Read the user's next message carefully and respond to exactly what they ask. "
161+
"Do not continue previous tasks."
162+
)
163+
)
164+
messages.append(HumanMessage(content=user_input))

ensemble_agent/archive.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Versioned script and output archiving — no global state."""
2+
3+
import shutil
4+
import time
5+
from pathlib import Path
6+
7+
from .config import ARCHIVE_ITEMS, ARCHIVE_RUNS_DIR
8+
9+
10+
class ArchiveManager:
11+
"""Tracks versioned archives of scripts and run outputs."""
12+
13+
def __init__(self, work_dir):
14+
self.work_dir = Path(work_dir)
15+
self.work_dir.mkdir(parents=True, exist_ok=True)
16+
self._counter = 1
17+
self._current = None
18+
19+
@property
20+
def current_archive(self):
21+
return self._current
22+
23+
def start(self, action):
24+
"""Begin a new versioned archive (e.g. '1_generated', '2_fix')."""
25+
self._current = f"{self._counter}_{action}"
26+
(self.work_dir / "versions" / self._current).mkdir(parents=True, exist_ok=True)
27+
self._counter += 1
28+
29+
def archive_scripts(self):
30+
"""Copy current *.py files into the active version directory."""
31+
if not self._current:
32+
return
33+
dest = self.work_dir / "versions" / self._current
34+
for f in self.work_dir.glob("*.py"):
35+
shutil.copy(f, dest / f.name)
36+
37+
def archive_run_output(self, error_msg=""):
38+
"""Move run artifacts into the active version's output/ subdirectory."""
39+
if not self._current:
40+
return
41+
output_dir = self.work_dir / "versions" / self._current / "output"
42+
output_dir.mkdir(parents=True, exist_ok=True)
43+
44+
if error_msg:
45+
(output_dir / "error.txt").write_text(error_msg)
46+
47+
for item in ARCHIVE_ITEMS:
48+
item_path = self.work_dir / item
49+
if item_path.exists() and item_path.is_dir():
50+
shutil.copytree(str(item_path), str(output_dir / item), dirs_exist_ok=True)
51+
shutil.rmtree(str(item_path))
52+
else:
53+
for fp in self.work_dir.glob(item):
54+
if fp.is_file():
55+
shutil.copy(str(fp), str(output_dir / fp.name))
56+
fp.unlink()
57+
58+
@staticmethod
59+
def archive_existing_output_dir(output_dir, archive_parent=None):
60+
"""If output_dir exists, move it to archive_parent/output_dir_<unique>, then create fresh."""
61+
output_dir = Path(output_dir)
62+
archive_dir = Path(archive_parent or ARCHIVE_RUNS_DIR)
63+
if not output_dir.exists():
64+
output_dir.mkdir(parents=True, exist_ok=True)
65+
return
66+
archive_dir.mkdir(parents=True, exist_ok=True)
67+
dest = archive_dir / f"{output_dir.name}_{hex(time.time_ns())[2:10]}"
68+
shutil.move(str(output_dir), str(dest))
69+
print(f"Moved existing {output_dir} to {dest}")
70+
output_dir.mkdir(parents=True, exist_ok=True)

0 commit comments

Comments
 (0)