""" Copy-edit workflows for existing records. """ from __future__ import annotations import logging import shutil from pathlib import Path from typing import Any, Callable, Optional from agent.cost import max_cost_usd_from_env from agent.defaults import resolve_max_turns from agent.prompts import DESIGNER_PROMPT_NAME, normalize_sdk_package from agent.run_context import ( RunExecutionOutcome, _build_single_run_context, _default_model_id, _first_string, _infer_provider_from_model_id, _optional_max_cost_usd, _optional_string, _resolve_runtime_record_author, ) from agent.single_run import ExecuteSingleRun, execute_single_run from agent.tools import build_initial_user_content from storage.records import RecordStore from storage.repo import StorageRepo from storage.revisions import ( INITIAL_REVISION_ID, active_inputs_dir, active_model_path, active_prompt_path, active_provenance_path, active_revision_id, revision_relative_path, sha256_file, ) from storage.runs import RunStore logger = logging.getLogger(__name__) EDIT_RUNTIME_GUIDANCE = """This is an edit request for an existing Articraft asset. The active parent model.py has already been staged as the editable starting file for this run. Treat that staged code as the current asset source, preserve the parts that still satisfy the original object, and make the smallest coherent set of changes needed for the new user request. Do not assume prior conversation history is available; use the staged code, parent metadata, and this edit prompt as the source of truth.""" EXTERNAL_AGENT_DOC_PROMPT_NAME = "EXTERNAL_AGENT_DATA.md" def _system_prompt_for_internal_edit( prompting: dict[str, Any], parent_record: dict[str, Any] ) -> str: stored_prompt = _first_string(prompting.get("system_prompt_file"), DESIGNER_PROMPT_NAME) creator = parent_record.get("creator") is_external_parent = isinstance(creator, dict) and creator.get("mode") == "external_agent" if is_external_parent or stored_prompt == EXTERNAL_AGENT_DOC_PROMPT_NAME: return DESIGNER_PROMPT_NAME return stored_prompt def _parent_input_refs( repo: StorageRepo, *, parent_record_id: str, parent_revision_id: str, parent_record: dict[str, Any], ) -> list[dict[str, str]]: seen: set[tuple[str, str, str]] = set() refs: list[dict[str, str]] = [] parent_revision = repo.read_json( repo.layout.record_revision_metadata_path(parent_record_id, parent_revision_id), default={}, ) inherited = ( parent_revision.get("inherited_inputs") if isinstance(parent_revision, dict) and isinstance(parent_revision.get("inherited_inputs"), list) else [] ) for item in inherited: if not isinstance(item, dict): continue ref_record_id = str(item.get("record_id") or "").strip() ref_revision_id = str(item.get("revision_id") or "").strip() ref_path = str(item.get("path") or "").strip() if not ref_record_id or not ref_revision_id or not ref_path: continue key = (ref_record_id, ref_revision_id, ref_path) if key in seen: continue seen.add(key) ref = { "record_id": ref_record_id, "revision_id": ref_revision_id, "path": ref_path, } digest = str(item.get("sha256") or "").strip() if digest: ref["sha256"] = digest refs.append(ref) inputs_dir = active_inputs_dir(repo, parent_record_id, record=parent_record) if not inputs_dir.is_dir(): return refs for path in sorted(item for item in inputs_dir.rglob("*") if item.is_file()): try: relative = path.relative_to(inputs_dir) except ValueError: continue ref_path = revision_relative_path( parent_revision_id, f"inputs/{relative.as_posix()}", ) key = (parent_record_id, parent_revision_id, ref_path) if key in seen: continue seen.add(key) ref: dict[str, str] = { "record_id": parent_record_id, "revision_id": parent_revision_id, "path": ref_path, } digest = sha256_file(path) if digest: ref["sha256"] = digest refs.append(ref) return refs async def edit_record( *, repo_root: Path, parent_record_id: str, edit_prompt: str, data_root: Path | None = None, image_path: Path | None = None, provider: str | None = None, model_id: str | None = None, thinking_level: str | None = None, max_turns: int | None = None, sdk_package: str | None = None, max_cost_usd: float | None = None, record_id: str | None = None, label: str | None = None, tags: Optional[list[str]] = None, display_enabled: Optional[bool] = None, execute_single_run_func: ExecuteSingleRun = execute_single_run, resolve_record_author_func: Callable[[Path], str | None] = _resolve_runtime_record_author, ) -> RunExecutionOutcome: normalized_prompt = edit_prompt.strip() if not normalized_prompt: return RunExecutionOutcome( exit_code=1, run_id="", record_id=record_id or parent_record_id, status="failed", message="Edit prompt is required.", ) resolved_repo_root = repo_root.resolve() storage_repo = StorageRepo(resolved_repo_root, data_root=data_root) storage_repo.ensure_layout() record_author = resolve_record_author_func(resolved_repo_root) record_store = RecordStore(storage_repo) run_store = RunStore(storage_repo) parent_record = record_store.load_record(parent_record_id) if not isinstance(parent_record, dict): return RunExecutionOutcome( exit_code=1, run_id="", record_id=parent_record_id, status="failed", message=f"Record not found: {parent_record_id}", ) parent_revision_id = active_revision_id( storage_repo, parent_record_id, record=parent_record, ) parent_model_path = active_model_path(storage_repo, parent_record_id, record=parent_record) if not parent_model_path.is_file(): return RunExecutionOutcome( exit_code=1, run_id="", record_id=parent_record_id, status="failed", message=f"Missing parent model.py: {parent_model_path}", ) parent_prompt_path = active_prompt_path(storage_repo, parent_record_id, record=parent_record) parent_prompt = ( parent_prompt_path.read_text(encoding="utf-8").strip() if parent_prompt_path.exists() else "" ) provenance_path = active_provenance_path(storage_repo, parent_record_id, record=parent_record) provenance = storage_repo.read_json(provenance_path) if not isinstance(provenance, dict): return RunExecutionOutcome( exit_code=1, run_id="", record_id=parent_record_id, status="failed", message=f"Missing provenance.json for record {parent_record_id}", ) generation = provenance.get("generation") prompting = provenance.get("prompting") sdk = provenance.get("sdk") if ( not isinstance(generation, dict) or not isinstance(prompting, dict) or not isinstance(sdk, dict) ): return RunExecutionOutcome( exit_code=1, run_id="", record_id=parent_record_id, status="failed", message=f"Invalid provenance.json for record {parent_record_id}", ) stored_provider = _first_string( generation.get("provider"), _first_string(parent_record.get("provider"), "openai"), ) stored_model_id = _optional_string(generation.get("model_id")) stored_openai_transport = _first_string(generation.get("openai_transport"), "http") stored_thinking_level = _first_string(generation.get("thinking_level"), "high") stored_max_turns = generation.get("max_turns") openai_reasoning_summary = ( _optional_string(generation.get("openai_reasoning_summary")) or "auto" ) system_prompt_path = _system_prompt_for_internal_edit(prompting, parent_record) resolved_sdk_package = normalize_sdk_package( sdk_package if sdk_package is not None else _first_string(sdk.get("sdk_package"), parent_record.get("sdk_package")) ) try: stored_max_cost_usd = _optional_max_cost_usd( generation.get("max_cost_usd"), label=f"generation.max_cost_usd for {parent_record_id}", ) env_max_cost_usd = max_cost_usd_from_env() except ValueError as exc: return RunExecutionOutcome( exit_code=1, run_id="", record_id=parent_record_id, status="failed", message=str(exc), ) selected_model_override = _optional_string(model_id) or stored_model_id selected_provider = ( _optional_string(provider) or _infer_provider_from_model_id(selected_model_override) or stored_provider ) selected_thinking = _optional_string(thinking_level) or stored_thinking_level selected_model_id = _default_model_id( provider=selected_provider, model_id=selected_model_override, thinking_level=selected_thinking, openai_transport=stored_openai_transport, openai_reasoning_summary=openai_reasoning_summary, ) resolved_max_turns = resolve_max_turns( model_id=selected_model_id, max_turns=( max_turns if max_turns is not None else stored_max_turns if isinstance(stored_max_turns, int) else None ), ) if max_cost_usd is not None: resolved_max_cost_usd = max_cost_usd elif stored_max_cost_usd is not None: resolved_max_cost_usd = stored_max_cost_usd else: resolved_max_cost_usd = env_max_cost_usd if record_id and storage_repo.layout.record_dir(record_id).exists(): return RunExecutionOutcome( exit_code=1, run_id="", record_id=record_id, status="failed", message=f"Record already exists: {record_id}", ) target_record_id = record_id or None revision_id = INITIAL_REVISION_ID existing_record = None category_slug = _optional_string(parent_record.get("category_slug")) inherited_label = label if label is not None else _optional_string(parent_record.get("label")) parent_tags = parent_record.get("tags") inherited_tags = ( list(tags) if tags is not None else ([str(tag) for tag in parent_tags] if isinstance(parent_tags, list) else []) ) parent_lineage = ( parent_record.get("lineage") if isinstance(parent_record.get("lineage"), dict) else {} ) lineage = { "origin_record_id": _optional_string(parent_lineage.get("origin_record_id")) or parent_record_id, "parent_record_id": parent_record_id, "parent_revision_id": parent_revision_id, "edit_mode": "copy", } context = _build_single_run_context( repo_root=resolved_repo_root, prompt=normalized_prompt, storage_repo=storage_repo, record_id=target_record_id, revision_id=revision_id, ) if storage_repo.layout.record_dir(context.record_id).exists(): return RunExecutionOutcome( exit_code=1, run_id=context.run_id, record_id=context.record_id, status="failed", message=f"Record already exists: {context.record_id}", ) context.script_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(parent_model_path, context.script_path) inherited_inputs = _parent_input_refs( storage_repo, parent_record_id=parent_record_id, parent_revision_id=parent_revision_id, parent_record=parent_record, ) revision_parent = {"record_id": parent_record_id, "revision_id": parent_revision_id} revision_seed = { "record_id": parent_record_id, "revision_id": parent_revision_id, "artifact": "model.py", } parent_prompt_line = f"\nParent prompt: {parent_prompt}" if parent_prompt else "" user_content = build_initial_user_content( normalized_prompt, image_path=image_path, runtime_guidance_text=( f"{EDIT_RUNTIME_GUIDANCE}\n" f"Parent record: {parent_record_id}\n" f"Parent revision: {parent_revision_id}" f"{parent_prompt_line}" ), ) outcome = await execute_single_run_func( user_content, prompt_text=normalized_prompt, display_prompt=normalized_prompt, resolved_repo_root=resolved_repo_root, storage_repo=storage_repo, record_store=record_store, run_store=run_store, image_path=image_path, provider=selected_provider, model_id=selected_model_override, openai_transport=stored_openai_transport, thinking_level=selected_thinking, max_turns=resolved_max_turns, system_prompt_path=system_prompt_path, display_enabled=display_enabled, sdk_package=resolved_sdk_package, openai_reasoning_summary=openai_reasoning_summary, max_cost_usd=resolved_max_cost_usd, label=inherited_label, tags=inherited_tags, category_slug=category_slug, run_mode="library_single", context=context, existing_record=existing_record, record_author=record_author, lineage=lineage, revision_parent=revision_parent, revision_seed=revision_seed, inherited_inputs=inherited_inputs, ) return outcome