feat(simulation): add POST /agent create and /agent/{id}/regenerate endpoints

Implements Tasks 8 and 9 of F2-A+B: async agent creation from entity UUID
and async per-agent profile regeneration, both with atomic profile file writes.
Refactors generate_profile_from_entity to accept extra_instructions instead
of requiring user_id (renamed internal logic to _generate_single_profile).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-05-03 21:59:25 +00:00
parent 306414a555
commit 5d679024c3
3 changed files with 325 additions and 2 deletions

View File

@ -2912,3 +2912,232 @@ def clone_simulation(simulation_id: str):
except Exception as e:
logger.error(f"clone_simulation failed: {e}")
return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500
@simulation_bp.route('/<simulation_id>/agent', methods=['POST'])
def create_agent(simulation_id: str):
"""
Task 8 Create a new agent from an entity UUID.
Requires status in {profiles_ready, created}.
Runs async; returns task_id for polling.
"""
import json
import threading
from ..models.task import TaskManager, TaskStatus
try:
data = request.get_json() or {}
source_entity_uuid = data.get("source_entity_uuid")
if not source_entity_uuid:
return jsonify({"success": False, "error": t('api.requireFields')}), 400
extra_instructions = data.get("extra_instructions")
manager = SimulationManager()
state = manager.get_simulation(simulation_id)
if not state:
return jsonify({"success": False, "error": t('api.simulationNotFound', id=simulation_id)}), 404
allowed_statuses = {SimulationStatus.PROFILES_READY, SimulationStatus.CREATED}
if state.status not in allowed_statuses:
return jsonify({
"success": False,
"error": t('api.requireProfilesReady', status=state.status.value)
}), 400
task_manager = TaskManager()
task_id = task_manager.create_task(
task_type="create_agent",
metadata={"simulation_id": simulation_id, "source_entity_uuid": source_entity_uuid}
)
current_locale = get_locale()
def run_create_agent():
set_locale(current_locale)
try:
task_manager.update_task(task_id, status=TaskStatus.PROCESSING, progress=0,
message=t('progress.generatingProfile'))
sim_dir = manager._get_simulation_dir(simulation_id)
profiles_file = os.path.join(sim_dir, "reddit_profiles.json")
# Fetch entity
reader = ZepEntityReader()
entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid)
if not entity:
raise ValueError(f"Entity not found: {source_entity_uuid}")
# Read current profiles
with open(profiles_file, 'r', encoding='utf-8') as f:
profiles = json.load(f)
# Check for duplicate source_entity_uuid
for p in profiles:
if p.get("source_entity_uuid") == source_entity_uuid:
raise ValueError(f"Agent with source_entity_uuid '{source_entity_uuid}' already exists")
# Compute next user_id
next_user_id = max((p.get("user_id", -1) for p in profiles), default=-1) + 1
# Generate profile
gen = OasisProfileGenerator(graph_id=state.graph_id)
profile = gen.generate_profile_from_entity(entity, extra_instructions=extra_instructions)
profile.user_id = next_user_id
# Convert to dict and append
profile_dict = profile.to_reddit_format()
profile_dict["user_id"] = next_user_id
profile_dict["source_entity_uuid"] = profile.source_entity_uuid
profile_dict["source_entity_type"] = profile.source_entity_type
profile_dict["manually_edited"] = False
profiles.append(profile_dict)
# Atomic write: backup → write → delete backup
backup_file = profiles_file + ".bak"
if os.path.exists(profiles_file):
import shutil
shutil.copy2(profiles_file, backup_file)
try:
with open(profiles_file, 'w', encoding='utf-8') as f:
json.dump(profiles, f, ensure_ascii=False, indent=2)
if os.path.exists(backup_file):
os.remove(backup_file)
except Exception as write_err:
# Restore from backup on failure
if os.path.exists(backup_file):
import shutil
shutil.copy2(backup_file, profiles_file)
raise write_err
# Update profiles_count in state
state2 = manager.get_simulation(simulation_id)
if state2:
state2.profiles_count = len(profiles)
manager._save_simulation_state(state2)
task_manager.complete_task(task_id, result={"user_id": next_user_id})
except Exception as e:
logger.error(f"create_agent background failed: {e}")
task_manager.fail_task(task_id, str(e))
threading.Thread(target=run_create_agent, daemon=True).start()
return jsonify({"success": True, "data": {"simulation_id": simulation_id, "task_id": task_id}})
except Exception as e:
logger.error(f"create_agent endpoint error: {e}")
return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500
@simulation_bp.route('/<simulation_id>/agent/<int:user_id>/regenerate', methods=['POST'])
def regenerate_agent(simulation_id: str, user_id: int):
"""
Task 9 Regenerate an agent's profile from its source entity.
Requires status == profiles_ready and the agent must have source_entity_uuid.
Runs async; returns task_id for polling.
"""
import json
import threading
from ..models.task import TaskManager, TaskStatus
try:
data = request.get_json() or {}
extra_instructions = data.get("extra_instructions")
manager = SimulationManager()
state = manager.get_simulation(simulation_id)
if not state:
return jsonify({"success": False, "error": t('api.simulationNotFound', id=simulation_id)}), 404
if state.status != SimulationStatus.PROFILES_READY:
return jsonify({
"success": False,
"error": t('api.requireProfilesReady', status=state.status.value)
}), 400
task_manager = TaskManager()
task_id = task_manager.create_task(
task_type="regenerate_agent",
metadata={"simulation_id": simulation_id, "user_id": user_id}
)
current_locale = get_locale()
def run_regenerate_agent():
set_locale(current_locale)
try:
task_manager.update_task(task_id, status=TaskStatus.PROCESSING, progress=0,
message=t('progress.generatingProfile'))
sim_dir = manager._get_simulation_dir(simulation_id)
profiles_file = os.path.join(sim_dir, "reddit_profiles.json")
with open(profiles_file, 'r', encoding='utf-8') as f:
profiles = json.load(f)
# Find the agent
agent_idx = None
agent = None
for i, p in enumerate(profiles):
if p.get("user_id") == user_id:
agent_idx = i
agent = p
break
if agent_idx is None:
raise LookupError(f"Agent with user_id {user_id} not found")
source_entity_uuid = agent.get("source_entity_uuid")
if not source_entity_uuid:
raise ValueError(f"Agent {user_id} has no source_entity_uuid — cannot regenerate")
# Fetch entity
reader = ZepEntityReader()
entity = reader.get_entity_with_context(state.graph_id, source_entity_uuid)
if not entity:
raise ValueError(f"Entity not found: {source_entity_uuid}")
# Generate new profile
gen = OasisProfileGenerator(graph_id=state.graph_id)
new_profile = gen.generate_profile_from_entity(entity, extra_instructions=extra_instructions)
new_profile.user_id = user_id
# Build dict, preserving user_id and resetting manually_edited
new_profile_dict = new_profile.to_reddit_format()
new_profile_dict["user_id"] = user_id
new_profile_dict["source_entity_uuid"] = new_profile.source_entity_uuid
new_profile_dict["source_entity_type"] = new_profile.source_entity_type
new_profile_dict["manually_edited"] = False
profiles[agent_idx] = new_profile_dict
# Atomic write
backup_file = profiles_file + ".bak"
if os.path.exists(profiles_file):
import shutil
shutil.copy2(profiles_file, backup_file)
try:
with open(profiles_file, 'w', encoding='utf-8') as f:
json.dump(profiles, f, ensure_ascii=False, indent=2)
if os.path.exists(backup_file):
os.remove(backup_file)
except Exception as write_err:
if os.path.exists(backup_file):
import shutil
shutil.copy2(backup_file, profiles_file)
raise write_err
task_manager.complete_task(task_id, result={"user_id": user_id})
except Exception as e:
logger.error(f"regenerate_agent background failed: {e}")
task_manager.fail_task(task_id, str(e))
threading.Thread(target=run_regenerate_agent, daemon=True).start()
return jsonify({"success": True, "data": {"simulation_id": simulation_id, "task_id": task_id}})
except Exception as e:
logger.error(f"regenerate_agent endpoint error: {e}")
return jsonify({"success": False, "error": str(e), "traceback": traceback.format_exc()}), 500

View File

@ -231,17 +231,40 @@ class OasisProfileGenerator:
logger.warning(f"Zep client initialisation failed: {e}")
def generate_profile_from_entity(
self,
entity: EntityNode,
extra_instructions: Optional[str] = None,
) -> OasisAgentProfile:
"""
Generate an OASIS Agent Profile from a Zep entity (public API).
The returned profile has user_id=0 as a placeholder; callers must
reassign user_id after insertion into the profiles list.
Args:
entity: Zep entity node
extra_instructions: Optional extra instructions for the LLM persona
Returns:
OasisAgentProfile
"""
return self._generate_single_profile(entity, user_id=0,
extra_instructions=extra_instructions)
def _generate_single_profile(
self,
entity: EntityNode,
user_id: int,
extra_instructions: Optional[str] = None,
use_llm: bool = True
) -> OasisAgentProfile:
"""
Generate an OASIS Agent Profile from a Zep entity.
Internal: generate an OASIS Agent Profile for a single entity.
Args:
entity: Zep entity node
user_id: User ID (for OASIS)
extra_instructions: Optional extra instructions injected into the LLM prompt
use_llm: Whether to use an LLM to generate a detailed persona
Returns:
@ -975,7 +998,7 @@ Important:
entity_type = entity.get_entity_type() or "Entity"
try:
profile = self.generate_profile_from_entity(
profile = self._generate_single_profile(
entity=entity,
user_id=idx,
use_llm=use_llm

View File

@ -168,3 +168,74 @@ def test_patch_config_updates_total_hours(client, sim_prepared):
data = resp.get_json()
assert data["success"] is True
assert data["data"]["time_config"]["total_simulation_hours"] == 48
def test_create_agent_adds_to_profiles(client, sim_with_profiles, monkeypatch):
sim_id = sim_with_profiles
import backend.app.services.simulation_manager as sm_module
from backend.app.services.zep_entity_reader import EntityNode
fake_entity = EntityNode(
uuid="uuid_carol", name="Carol", labels=["Person", "Entity"],
summary="Carol is a scientist", attributes={}
)
def fake_get_entity(self, graph_id, uuid_):
return fake_entity
monkeypatch.setattr(
"backend.app.services.zep_entity_reader.ZepEntityReader.get_entity_with_context",
fake_get_entity
)
from backend.app.services.oasis_profile_generator import OasisAgentProfile
fake_profile = OasisAgentProfile(user_id=99, user_name="carol", name="Carol",
bio="Carol bio", persona="Scientist",
source_entity_uuid="uuid_carol")
monkeypatch.setattr(
"backend.app.services.oasis_profile_generator.OasisProfileGenerator.generate_profile_from_entity",
lambda self, entity, extra_instructions=None: fake_profile
)
resp = client.post(f"/api/simulation/{sim_id}/agent",
json={"source_entity_uuid": "uuid_carol", "extra_instructions": "Make her skeptical"})
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "task_id" in data["data"]
def test_regenerate_agent_returns_task_id(client, sim_with_profiles, monkeypatch):
sim_id = sim_with_profiles
from backend.app.services.zep_entity_reader import EntityNode
fake_entity = EntityNode(uuid="uuid_alice", name="Alice", labels=["Entity"], summary="", attributes={})
monkeypatch.setattr(
"backend.app.services.zep_entity_reader.ZepEntityReader.get_entity_with_context",
lambda self, g, u: fake_entity
)
from backend.app.services.oasis_profile_generator import OasisAgentProfile
fake_profile = OasisAgentProfile(user_id=0, user_name="alice2", name="Alice2",
bio="New bio", persona="Skeptic",
source_entity_uuid="uuid_alice")
monkeypatch.setattr(
"backend.app.services.oasis_profile_generator.OasisProfileGenerator.generate_profile_from_entity",
lambda self, entity, extra_instructions=None: fake_profile
)
# Add source_entity_uuid to the first profile in the fixture's sim directory
import json as _j
from pathlib import Path
from backend.app.services.simulation_manager import SimulationManager
sim_dir = Path(SimulationManager.SIMULATION_DATA_DIR) / sim_id
profiles = _j.loads((sim_dir / "reddit_profiles.json").read_text())
profiles[0]["source_entity_uuid"] = "uuid_alice"
(sim_dir / "reddit_profiles.json").write_text(_j.dumps(profiles))
resp = client.post(f"/api/simulation/{sim_id}/agent/0/regenerate",
json={"extra_instructions": "Make her skeptical"})
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "task_id" in data["data"]