mirror of
https://github.com/khodges42/nightShift.git
synced 2026-06-14 10:08:37 +00:00
2122 lines
82 KiB
Python
2122 lines
82 KiB
Python
"""Deterministic pipeline runner."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, replace
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
|
|
from .agents import AgentExecutor
|
|
from .artifacts import ArtifactStore
|
|
from .commands import CommandExecutor, extract_test_file_paths, render_command_template
|
|
from .config import COMMAND_STAGE_TYPES, NightShiftConfig, StageConfig
|
|
from .context import ContextManager
|
|
from .dependencies import diagnose_python_dependencies, format_dependency_diagnostic
|
|
from .escalation import evaluate_retry_churn, format_escalation_decision
|
|
from .errors import PipelineError
|
|
from .errors import NightShiftError
|
|
from .failures import build_failure_signature, classify_failure, format_failure_classification
|
|
from .git import ensure_clean_worktree, write_diff_artifact, write_git_artifacts
|
|
from .patches import (
|
|
DEFAULT_FORBIDDEN_PATHS,
|
|
DEFAULT_MAX_CHANGED_LINES,
|
|
DEFAULT_MAX_FILES,
|
|
FileUpdate,
|
|
apply_patch_with_git,
|
|
extract_unified_diff,
|
|
format_patch_apply_result,
|
|
format_validation_result,
|
|
generate_patch_from_file_updates,
|
|
normalize_patch_text,
|
|
parse_file_updates,
|
|
validate_patch,
|
|
)
|
|
from .project_chart import build_project_context_chart
|
|
from .reports import ReportGenerator
|
|
from .repo_tools import RepoTools, extract_agent_stdout, parse_lookup_requests
|
|
from .resources import format_resource_report, parse_resource_requests, satisfy_resource_requests
|
|
from .retry_memory import RetryMemoryEntry, entry_from_stage, summarize_retry_memory
|
|
from .semantic_index import (
|
|
build_semantic_index,
|
|
format_search_results,
|
|
format_semantic_index,
|
|
search_index,
|
|
)
|
|
from .runlog import RunLogger
|
|
from .stages import StageResult
|
|
from .tasks import Task, mark_task_completed
|
|
from .telemetry import TelemetryEntry, format_telemetry_summary, telemetry_from_stage_output
|
|
from .writing_validators import collect_writing_warnings, validate_writing_file_updates
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PipelineResult:
|
|
task_id: str
|
|
status: str
|
|
retry_count: int
|
|
stage_results: tuple[StageResult, ...]
|
|
artifact_dir: str
|
|
reason: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class MultiTaskResult:
|
|
status: str
|
|
task_results: tuple[PipelineResult, ...]
|
|
completed_count: int
|
|
failed_count: int
|
|
reason: str
|
|
|
|
|
|
class PipelineRunner:
|
|
"""Execute configured stages for one task."""
|
|
|
|
def __init__(
|
|
self,
|
|
config: NightShiftConfig,
|
|
artifacts: ArtifactStore | None = None,
|
|
agent_timeout_seconds: int = 600,
|
|
command_timeout_seconds: int = 300,
|
|
logger: RunLogger | None = None,
|
|
) -> None:
|
|
self.config = config
|
|
self.artifacts = artifacts or ArtifactStore.from_config(config)
|
|
self.logger = logger or RunLogger()
|
|
self.context = ContextManager(self.artifacts)
|
|
self.reports = ReportGenerator(
|
|
config.project.root,
|
|
self.artifacts,
|
|
experiment_label=config.experiment.label,
|
|
prompt_variant=config.experiment.prompt_variant,
|
|
)
|
|
self.agent_executor = AgentExecutor(
|
|
config.project.root,
|
|
config.agents,
|
|
self.artifacts,
|
|
timeout_seconds=agent_timeout_seconds,
|
|
logger=self.logger,
|
|
)
|
|
self.command_executor = CommandExecutor(
|
|
config.project.root,
|
|
config.safety,
|
|
self.artifacts,
|
|
timeout_seconds=command_timeout_seconds,
|
|
logger=self.logger,
|
|
)
|
|
self.repo_tools = RepoTools(
|
|
config.project.root,
|
|
config.safety,
|
|
self.artifacts,
|
|
logger=self.logger,
|
|
)
|
|
|
|
def run_task(self, task: Task) -> PipelineResult:
|
|
ensure_clean_worktree(self.config.project.root, self.config.safety.require_clean_worktree)
|
|
self.artifacts.initialize_run()
|
|
self.logger.bind(self.artifacts)
|
|
self.logger.event(
|
|
"task.start",
|
|
"Starting task",
|
|
run_id=self.artifacts.run_id,
|
|
task_id=task.id,
|
|
task_title=task.title,
|
|
)
|
|
self.artifacts.write_config_snapshot(self.config.path)
|
|
self.artifacts.write_prompt_snapshots(
|
|
{
|
|
agent_id: self.config.project.root / agent.system_prompt
|
|
for agent_id, agent in self.config.agents.items()
|
|
}
|
|
)
|
|
self.artifacts.write_run_metadata(format_run_metadata(self.config))
|
|
self.artifacts.write_task_snapshot(task)
|
|
self._write_project_context_chart()
|
|
write_git_artifacts(self.artifacts, task.id, "before")
|
|
self.context.ensure_project_context()
|
|
self.context.create_task_context(task)
|
|
|
|
stages = list(self.config.pipeline.stages)
|
|
stage_indexes = {stage.id: index for index, stage in enumerate(stages)}
|
|
stage_results: list[StageResult] = []
|
|
previous_outputs: dict[str, str] = {}
|
|
retry_notes: list[str] = []
|
|
retry_memory: list[RetryMemoryEntry] = []
|
|
telemetry_entries: list[TelemetryEntry] = []
|
|
retry_count = 0
|
|
index = 0
|
|
final_status = "complete"
|
|
final_reason = "Pipeline completed."
|
|
preflight_result = self._preflight_task(task, stages)
|
|
if preflight_result:
|
|
stage_results.append(preflight_result)
|
|
final_status = "failed"
|
|
final_reason = preflight_result.reason
|
|
index = len(stages)
|
|
|
|
while index < len(stages):
|
|
stage = stages[index]
|
|
self.logger.event(
|
|
"stage.start",
|
|
"Starting stage",
|
|
run_id=self.artifacts.run_id,
|
|
task_id=task.id,
|
|
stage_id=stage.id,
|
|
stage_type=stage.type,
|
|
retry_count=retry_count,
|
|
)
|
|
try:
|
|
result = self._run_stage(stage, task, previous_outputs, retry_notes, retry_count)
|
|
except NightShiftError as exc:
|
|
result = StageResult(
|
|
stage_id=stage.id,
|
|
status="fail",
|
|
reason=str(exc),
|
|
)
|
|
except OSError as exc:
|
|
result = StageResult(
|
|
stage_id=stage.id,
|
|
status="fail",
|
|
reason=f"Unexpected OS error while running stage: {exc}",
|
|
)
|
|
stage_results.append(result)
|
|
if stage.id in previous_outputs:
|
|
del previous_outputs[stage.id]
|
|
previous_outputs[stage.id] = self._read_context_output(result.output_path)
|
|
telemetry_entries.append(self._telemetry_entry(stage, result, retry_count))
|
|
self._write_telemetry(task.id, telemetry_entries)
|
|
self.logger.event(
|
|
"stage.finish",
|
|
"Finished stage",
|
|
run_id=self.artifacts.run_id,
|
|
task_id=task.id,
|
|
stage_id=stage.id,
|
|
status=result.status,
|
|
reason=result.reason,
|
|
artifact_path=result.output_path,
|
|
)
|
|
if result.context_update:
|
|
retry_notes.append(f"Context update from '{stage.id}': {result.context_update}")
|
|
|
|
if result.status == "pass":
|
|
pass_target_stage = result.next_stage or stage.on_pass
|
|
if stage.type in {"agent_review", "review"} and result.next_stage:
|
|
self.logger.event(
|
|
"stage.next_ignored",
|
|
"Ignoring next_stage from passing review",
|
|
run_id=self.artifacts.run_id,
|
|
task_id=task.id,
|
|
stage_id=stage.id,
|
|
requested_next_stage=result.next_stage,
|
|
)
|
|
pass_target_stage = stage.on_pass
|
|
if pass_target_stage:
|
|
if pass_target_stage not in stage_indexes:
|
|
final_status = "failed"
|
|
final_reason = (
|
|
f"Stage '{stage.id}' requested unknown next stage '{pass_target_stage}'."
|
|
)
|
|
break
|
|
self.logger.event(
|
|
"stage.next",
|
|
"Jumping to requested next stage",
|
|
run_id=self.artifacts.run_id,
|
|
task_id=task.id,
|
|
stage_id=stage.id,
|
|
next_stage=pass_target_stage,
|
|
)
|
|
index = stage_indexes[pass_target_stage]
|
|
continue
|
|
index += 1
|
|
continue
|
|
|
|
target_stage = _failure_target_stage(stage, result)
|
|
analysis_note = self._write_failure_diagnostics(stage, task, result, retry_count)
|
|
if analysis_note:
|
|
retry_notes.append(analysis_note)
|
|
debugger_note = self._run_debugger_if_configured(task, result, retry_notes)
|
|
if debugger_note:
|
|
retry_notes.append(debugger_note)
|
|
if target_stage:
|
|
if retry_count >= self.config.pipeline.max_task_retries:
|
|
final_status = "failed"
|
|
final_reason = (
|
|
f"Retry limit reached after stage '{stage.id}': {result.reason}"
|
|
)
|
|
break
|
|
if target_stage not in stage_indexes:
|
|
final_status = "failed"
|
|
final_reason = (
|
|
f"Stage '{stage.id}' requested unknown next stage '{target_stage}'."
|
|
)
|
|
break
|
|
retry_count += 1
|
|
output = self._read_output(result.output_path)
|
|
failure_signature = ""
|
|
if stage.type in COMMAND_STAGE_TYPES:
|
|
failure_signature = build_failure_signature(output, result.reason)
|
|
memory_entry = entry_from_stage(
|
|
retry_count,
|
|
result,
|
|
target_stage,
|
|
failure_signature=failure_signature,
|
|
)
|
|
retry_memory.append(memory_entry)
|
|
self.artifacts.write_stage_output(
|
|
task.id,
|
|
"retry-memory.md",
|
|
summarize_retry_memory(tuple(retry_memory)),
|
|
)
|
|
if _repeated_protected_path_violation(tuple(retry_memory)):
|
|
final_status = "failed"
|
|
final_reason = (
|
|
"Escalation policy stopped retries: implementation repeatedly "
|
|
"attempted to modify paths outside the stage allowlist."
|
|
)
|
|
break
|
|
decision = evaluate_retry_churn(
|
|
tuple(retry_memory),
|
|
retry_budget=self.config.pipeline.max_task_retries + 1,
|
|
repeated_signature_after=self.config.pipeline.stop_on_repeated_failure_signature_after,
|
|
)
|
|
self.artifacts.write_stage_output(
|
|
task.id,
|
|
"escalation-policy.md",
|
|
format_escalation_decision(decision),
|
|
)
|
|
if decision.should_stop:
|
|
final_status = "failed"
|
|
final_reason = f"Escalation policy stopped retries: {decision.reason}"
|
|
break
|
|
self.logger.event(
|
|
"stage.retry",
|
|
"Redirecting after stage result",
|
|
run_id=self.artifacts.run_id,
|
|
task_id=task.id,
|
|
stage_id=stage.id,
|
|
status=result.status,
|
|
retry_count=retry_count,
|
|
next_stage=target_stage,
|
|
)
|
|
retry_notes.append(self._format_retry_note(retry_count, stage, result, target_stage))
|
|
index = stage_indexes[target_stage]
|
|
continue
|
|
|
|
final_status = "failed"
|
|
final_reason = f"Stage '{stage.id}' returned {result.status}: {result.reason}"
|
|
break
|
|
|
|
context_out_path = self.context.write_context_out(
|
|
task,
|
|
final_status,
|
|
final_reason,
|
|
retry_notes,
|
|
durable_notes=[
|
|
result.context_update
|
|
for result in stage_results
|
|
if result.context_update
|
|
],
|
|
)
|
|
completion_changed = False
|
|
if final_status == "complete":
|
|
completion_changed = mark_task_completed(
|
|
self.config.project.root,
|
|
self.config.project.task_file,
|
|
task.id,
|
|
)
|
|
self.artifacts.write_stage_output(
|
|
task.id,
|
|
"task-completion.md",
|
|
format_task_completion(task, final_status, completion_changed),
|
|
)
|
|
write_git_artifacts(self.artifacts, task.id, "after")
|
|
write_diff_artifact(self.artifacts, task.id)
|
|
self.reports.write_reports(
|
|
task,
|
|
final_status,
|
|
final_reason,
|
|
retry_count,
|
|
stage_results,
|
|
context_out_path=context_out_path,
|
|
)
|
|
self._write_telemetry(task.id, telemetry_entries)
|
|
self.logger.event(
|
|
"task.finish",
|
|
"Finished task",
|
|
run_id=self.artifacts.run_id,
|
|
task_id=task.id,
|
|
status=final_status,
|
|
retry_count=retry_count,
|
|
reason=final_reason,
|
|
artifact_path=self.artifacts.create_task_dir(task.id).directory.relative_to(self.config.project.root),
|
|
)
|
|
|
|
return PipelineResult(
|
|
task_id=task.id,
|
|
status=final_status,
|
|
retry_count=retry_count,
|
|
stage_results=tuple(stage_results),
|
|
artifact_dir=str(self.artifacts.create_task_dir(task.id).directory.relative_to(self.config.project.root)),
|
|
reason=final_reason,
|
|
)
|
|
|
|
def _preflight_task(self, task: Task, stages: list[StageConfig]) -> StageResult | None:
|
|
missing_paths: list[str] = []
|
|
for stage in stages:
|
|
if stage.type not in COMMAND_STAGE_TYPES:
|
|
continue
|
|
for command in stage.commands:
|
|
rendered = render_command_template(command, task.id)
|
|
for path_text in extract_test_file_paths(rendered):
|
|
if not (self.config.project.root / path_text).exists():
|
|
missing_paths.append(path_text)
|
|
if not missing_paths:
|
|
return None
|
|
unique_paths = tuple(dict.fromkeys(missing_paths))
|
|
details = "\n".join(f"- `{path}`" for path in unique_paths)
|
|
output_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
"preflight.md",
|
|
"\n".join(
|
|
[
|
|
"# Task Preflight",
|
|
"",
|
|
"Status: fail",
|
|
"Reason: configured task test file is missing.",
|
|
"",
|
|
"## Missing Files",
|
|
"",
|
|
details,
|
|
"",
|
|
]
|
|
),
|
|
)
|
|
return StageResult(
|
|
"preflight",
|
|
"fail",
|
|
"Task preflight failed: configured task test file is missing: "
|
|
+ ", ".join(unique_paths),
|
|
output_path=str(output_path.relative_to(self.config.project.root)),
|
|
)
|
|
|
|
def run_tasks(self, tasks: list[Task] | tuple[Task, ...]) -> MultiTaskResult:
|
|
self.artifacts.initialize_run()
|
|
self.logger.bind(self.artifacts)
|
|
self.logger.event("run.start", "Starting multi-task run", run_id=self.artifacts.run_id)
|
|
results: list[PipelineResult] = []
|
|
known_ids = {task.id for task in tasks}
|
|
completed_ids = {task.id for task in tasks if task.completed}
|
|
for task in tasks:
|
|
if task.completed:
|
|
continue
|
|
missing_refs = [dependency for dependency in task.dependencies if dependency not in known_ids]
|
|
incomplete_deps = [
|
|
dependency for dependency in task.dependencies if dependency in known_ids and dependency not in completed_ids
|
|
]
|
|
if missing_refs or incomplete_deps:
|
|
reason_parts = []
|
|
if missing_refs:
|
|
reason_parts.append(f"missing dependencies: {', '.join(missing_refs)}")
|
|
if incomplete_deps:
|
|
reason_parts.append(f"incomplete dependencies: {', '.join(incomplete_deps)}")
|
|
blocked = PipelineResult(
|
|
task_id=task.id,
|
|
status="blocked",
|
|
retry_count=0,
|
|
stage_results=(),
|
|
artifact_dir="",
|
|
reason="Task blocked by " + "; ".join(reason_parts),
|
|
)
|
|
results.append(blocked)
|
|
self.logger.event(
|
|
"task.blocked",
|
|
"Task blocked by dependencies",
|
|
run_id=self.artifacts.run_id,
|
|
task_id=task.id,
|
|
reason=blocked.reason,
|
|
)
|
|
if not self.config.pipeline.continue_on_task_failure:
|
|
break
|
|
continue
|
|
result = self.run_task(task)
|
|
results.append(result)
|
|
if result.status == "complete":
|
|
completed_ids.add(task.id)
|
|
if result.status != "complete" and not self.config.pipeline.continue_on_task_failure:
|
|
break
|
|
|
|
completed_count = sum(1 for result in results if result.status == "complete")
|
|
failed_count = sum(1 for result in results if result.status != "complete")
|
|
status = "complete" if failed_count == 0 else "failed"
|
|
reason = "All selected tasks completed." if status == "complete" else "One or more tasks failed."
|
|
self.artifacts.run_summary_path.write_text(
|
|
format_aggregate_run_summary(results, status, reason),
|
|
encoding="utf-8",
|
|
)
|
|
self.logger.event(
|
|
"run.finish",
|
|
"Finished multi-task run",
|
|
run_id=self.artifacts.run_id,
|
|
status=status,
|
|
completed_count=completed_count,
|
|
failed_count=failed_count,
|
|
)
|
|
return MultiTaskResult(
|
|
status=status,
|
|
task_results=tuple(results),
|
|
completed_count=completed_count,
|
|
failed_count=failed_count,
|
|
reason=reason,
|
|
)
|
|
|
|
def _run_stage(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
previous_outputs: dict[str, str],
|
|
retry_notes: list[str],
|
|
retry_count: int = 0,
|
|
) -> StageResult:
|
|
if stage.type in {"agent", "agent_review", "review"}:
|
|
context = self.context.read_context(task, retry_notes)
|
|
result = self.agent_executor.run_stage(
|
|
self._stage_for_retry_agent(stage, retry_count),
|
|
task,
|
|
_review_previous_outputs(previous_outputs) if stage.type in {"agent_review", "review"} else previous_outputs,
|
|
retry_notes,
|
|
project_context=context.project_context,
|
|
task_context=context.task_context,
|
|
retry_context=context.retry_context,
|
|
)
|
|
if stage.type == "agent":
|
|
resource_result = self._maybe_satisfy_resource_request(stage, task, result)
|
|
if resource_result is not None:
|
|
return resource_result
|
|
return self._maybe_rerun_agent_with_repo_lookup(
|
|
stage,
|
|
task,
|
|
result,
|
|
previous_outputs,
|
|
retry_notes,
|
|
context.project_context,
|
|
context.task_context,
|
|
context.retry_context,
|
|
)
|
|
if stage.type in {"agent_review", "review"} and _is_malformed_review_result(result):
|
|
return self._rerun_malformed_review(
|
|
stage,
|
|
task,
|
|
result,
|
|
previous_outputs,
|
|
retry_notes,
|
|
retry_count,
|
|
context.project_context,
|
|
context.task_context,
|
|
)
|
|
return result
|
|
if stage.type in COMMAND_STAGE_TYPES:
|
|
return self.command_executor.run_stage(_stage_with_attempt_output(stage, retry_count), task.id)
|
|
if stage.type == "code_writer":
|
|
return self._run_code_writer_stage(stage, task, previous_outputs, retry_notes, retry_count)
|
|
if stage.type == "file_writer":
|
|
return self._run_file_writer_stage(stage, task, previous_outputs, retry_notes, retry_count)
|
|
if stage.type == "patch_normalizer":
|
|
return self._run_patch_normalizer_stage(stage, task, previous_outputs, retry_notes, retry_count)
|
|
if stage.type == "patch_validator":
|
|
return self._run_patch_validator_stage(stage, task, previous_outputs, retry_count)
|
|
if stage.type == "patch_apply":
|
|
return self._run_patch_apply_stage(stage, task, previous_outputs, retry_count)
|
|
if stage.type == "repo_context":
|
|
output_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
stage.output or "context-pack.md",
|
|
self._build_context_pack(task),
|
|
)
|
|
self.logger.event(
|
|
"artifact.write",
|
|
"Wrote context pack",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
artifact_path=output_path.relative_to(self.config.project.root),
|
|
)
|
|
return StageResult(
|
|
stage_id=stage.id,
|
|
status="pass",
|
|
reason="Context pack written.",
|
|
output_path=str(output_path.relative_to(self.config.project.root)),
|
|
)
|
|
if stage.type == "semantic_context":
|
|
index = build_semantic_index(self.config.project.root, self.config.safety)
|
|
index_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
"semantic-index.md",
|
|
format_semantic_index(index),
|
|
)
|
|
query = " ".join([task.title, task.description, *task.acceptance_criteria])
|
|
results = _task_semantic_results(index, query, task.id)
|
|
context_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
stage.output or "semantic-context.md",
|
|
format_search_results(results, query),
|
|
)
|
|
self.logger.event(
|
|
"artifact.write",
|
|
"Wrote semantic context",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
artifact_path=context_path.relative_to(self.config.project.root),
|
|
)
|
|
return StageResult(
|
|
stage_id=stage.id,
|
|
status="pass",
|
|
reason="Semantic context written.",
|
|
output_path=str(context_path.relative_to(self.config.project.root)),
|
|
context_update=f"Semantic index: {index_path.relative_to(self.config.project.root).as_posix()}",
|
|
)
|
|
if stage.type == "summarize":
|
|
output_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
stage.output or "final-notes.md",
|
|
format_summary_stage(task, previous_outputs, retry_notes),
|
|
)
|
|
return StageResult(
|
|
stage_id=stage.id,
|
|
status="pass",
|
|
reason="Summary written.",
|
|
output_path=str(output_path.relative_to(self.config.project.root)),
|
|
)
|
|
raise PipelineError(f"Pipeline error: unsupported stage type '{stage.type}'.")
|
|
|
|
def _write_project_context_chart(self) -> Path:
|
|
chart = build_project_context_chart(self.config.project.root, self.config.safety)
|
|
self.artifacts.initialize_run()
|
|
self.artifacts.project_context_chart_path.write_text(chart, encoding="utf-8")
|
|
self.logger.event(
|
|
"artifact.write",
|
|
"Wrote project context chart",
|
|
artifact_path=self.artifacts.project_context_chart_path.relative_to(self.config.project.root),
|
|
)
|
|
return self.artifacts.project_context_chart_path
|
|
|
|
def _run_code_writer_stage(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
previous_outputs: dict[str, str],
|
|
retry_notes: list[str],
|
|
retry_count: int = 0,
|
|
) -> StageResult:
|
|
if stage.agent is None:
|
|
raise PipelineError(f"Pipeline error: code_writer stage '{stage.id}' must reference an agent.")
|
|
enriched_outputs = dict(previous_outputs)
|
|
context_pack_path = self._latest_task_artifact(task.id, "context-pack.md")
|
|
if context_pack_path is not None:
|
|
enriched_outputs["context-pack.md"] = context_pack_path.read_text(encoding="utf-8", errors="replace")
|
|
chart_path = self.artifacts.project_context_chart_path
|
|
if chart_path.exists():
|
|
enriched_outputs["project-context-chart.md"] = chart_path.read_text(encoding="utf-8", errors="replace")
|
|
context = self.context.read_context(task, retry_notes)
|
|
agent_stage = self._writer_agent_stage(stage, retry_count)
|
|
result = self.agent_executor.run_stage(
|
|
agent_stage,
|
|
task,
|
|
enriched_outputs,
|
|
retry_notes,
|
|
project_context=context.project_context,
|
|
task_context=context.task_context,
|
|
retry_context=context.retry_context,
|
|
)
|
|
stdout = self._read_agent_stdout(result.output_path)
|
|
lookup_requests = parse_lookup_requests(stdout)
|
|
if lookup_requests and "diff --git " not in stdout:
|
|
lookup_context = self.repo_tools.execute_requests(
|
|
task.id,
|
|
lookup_requests,
|
|
filename="implementation-files-inspected.md",
|
|
)
|
|
self.logger.event(
|
|
"agent.rerun",
|
|
"Re-running code writer with repo lookup context",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
lookup_count=len(lookup_requests),
|
|
)
|
|
rerun_outputs = dict(enriched_outputs)
|
|
rerun_outputs["repo_lookup_results"] = lookup_context
|
|
rerun_notes = [
|
|
*retry_notes,
|
|
"Repository lookup results have been provided. Return the unified diff now; do not request more lookups.",
|
|
]
|
|
result = self.agent_executor.run_stage(
|
|
agent_stage,
|
|
task,
|
|
rerun_outputs,
|
|
rerun_notes,
|
|
project_context=context.project_context,
|
|
task_context=context.task_context,
|
|
retry_context="\n".join(f"- {note}" for note in rerun_notes),
|
|
)
|
|
stdout = self._read_agent_stdout(result.output_path)
|
|
try:
|
|
patch = extract_unified_diff(stdout)
|
|
except PipelineError as exc:
|
|
self.artifacts.write_stage_output(
|
|
task.id,
|
|
"implementation-summary.md",
|
|
f"# Implementation Summary\n\nStatus: fail\nReason: {exc}\n",
|
|
)
|
|
return StageResult(stage.id, "fail", str(exc), output_path=result.output_path)
|
|
patch_filename = _writer_patch_filename(stage, retry_count)
|
|
summary_filename = _writer_summary_filename(stage, retry_count)
|
|
proposed_path = self.artifacts.write_stage_output(task.id, patch_filename, patch)
|
|
summary_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
summary_filename,
|
|
format_implementation_summary(
|
|
stage.id,
|
|
proposed_path.relative_to(self.config.project.root).as_posix(),
|
|
retry_count=retry_count,
|
|
retry_notes=retry_notes,
|
|
),
|
|
)
|
|
self.logger.event(
|
|
"artifact.write",
|
|
"Wrote proposed patch",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
artifact_path=proposed_path.relative_to(self.config.project.root),
|
|
)
|
|
return StageResult(
|
|
stage.id,
|
|
"pass",
|
|
"Proposed patch written.",
|
|
output_path=str(proposed_path.relative_to(self.config.project.root)),
|
|
context_update=f"Implementation summary: {summary_path.relative_to(self.config.project.root).as_posix()}",
|
|
)
|
|
|
|
def _run_file_writer_stage(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
previous_outputs: dict[str, str],
|
|
retry_notes: list[str],
|
|
retry_count: int = 0,
|
|
) -> StageResult:
|
|
if stage.agent is None:
|
|
raise PipelineError(f"Pipeline error: file_writer stage '{stage.id}' must reference an agent.")
|
|
if _is_state_update_stage(stage):
|
|
enriched_outputs = _state_update_previous_outputs(previous_outputs)
|
|
allowed_file_contents = self._allowed_file_contents(stage)
|
|
if allowed_file_contents:
|
|
enriched_outputs["current_allowed_files"] = allowed_file_contents
|
|
elif _is_scene_edit_stage(stage):
|
|
enriched_outputs = _file_writer_previous_outputs(previous_outputs, retry_count)
|
|
current_scene = self._task_scene_file_contents(task)
|
|
if current_scene:
|
|
enriched_outputs["current_scene_file"] = current_scene
|
|
else:
|
|
enriched_outputs = _file_writer_previous_outputs(previous_outputs, retry_count)
|
|
context_pack_path = self._latest_task_artifact(task.id, "context-pack.md")
|
|
if context_pack_path is not None:
|
|
enriched_outputs["context-pack.md"] = context_pack_path.read_text(encoding="utf-8", errors="replace")
|
|
chart_path = self.artifacts.project_context_chart_path
|
|
if chart_path.exists():
|
|
enriched_outputs["project-context-chart.md"] = chart_path.read_text(encoding="utf-8", errors="replace")
|
|
context = self.context.read_context(task, retry_notes)
|
|
agent_stage = self._writer_agent_stage(stage, retry_count)
|
|
result = self.agent_executor.run_stage(
|
|
agent_stage,
|
|
task,
|
|
enriched_outputs,
|
|
retry_notes,
|
|
project_context=context.project_context,
|
|
task_context=context.task_context,
|
|
retry_context=context.retry_context,
|
|
)
|
|
stdout = self._read_agent_stdout(result.output_path)
|
|
lookup_requests = parse_lookup_requests(stdout)
|
|
if lookup_requests and "```file:" not in stdout.lower() and "```path:" not in stdout.lower():
|
|
lookup_context = self.repo_tools.execute_requests(
|
|
task.id,
|
|
lookup_requests,
|
|
filename="implementation-files-inspected.md",
|
|
)
|
|
self.logger.event(
|
|
"agent.rerun",
|
|
"Re-running file writer with repo lookup context",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
lookup_count=len(lookup_requests),
|
|
)
|
|
rerun_outputs = dict(enriched_outputs)
|
|
rerun_outputs["repo_lookup_results"] = lookup_context
|
|
rerun_notes = [
|
|
*retry_notes,
|
|
"Repository lookup results have been provided. Return complete file blocks now; do not request more lookups.",
|
|
]
|
|
result = self.agent_executor.run_stage(
|
|
agent_stage,
|
|
task,
|
|
rerun_outputs,
|
|
rerun_notes,
|
|
project_context=context.project_context,
|
|
task_context=context.task_context,
|
|
retry_context="\n".join(f"- {note}" for note in rerun_notes),
|
|
)
|
|
stdout = self._read_agent_stdout(result.output_path)
|
|
invalid_rerun_done = False
|
|
candidate_index_path: Path | None = None
|
|
warning_path: Path | None = None
|
|
while True:
|
|
updates: tuple[FileUpdate, ...] = ()
|
|
try:
|
|
updates = parse_file_updates(stdout)
|
|
candidate_index_path = self._write_file_writer_candidates(
|
|
task.id,
|
|
stage,
|
|
updates,
|
|
retry_count,
|
|
)
|
|
if _is_writing_file_writer_stage(stage):
|
|
validate_writing_file_updates(updates, self.config.project.root)
|
|
warning_path = self._write_file_writer_warnings(task.id, stage, updates, retry_count)
|
|
patch = generate_patch_from_file_updates(
|
|
updates,
|
|
self.config.project.root,
|
|
self.config.safety,
|
|
allowed_paths=stage.allowed_paths,
|
|
forbidden_paths=stage.forbidden_paths or DEFAULT_FORBIDDEN_PATHS,
|
|
)
|
|
patch_reason = "Deterministic patch written from file blocks."
|
|
log_message = "Wrote deterministic patch from file blocks"
|
|
break
|
|
except PipelineError as exc:
|
|
reason = _file_writer_error_reason(stage, str(exc))
|
|
allowed_updates = _filter_allowed_file_updates(updates, stage)
|
|
if (
|
|
allowed_updates
|
|
and len(allowed_updates) < len(updates)
|
|
and "not allowed for this stage" in str(exc)
|
|
):
|
|
if _is_writing_file_writer_stage(stage):
|
|
validate_writing_file_updates(allowed_updates, self.config.project.root)
|
|
warning_path = self._write_file_writer_warnings(
|
|
task.id,
|
|
stage,
|
|
allowed_updates,
|
|
retry_count,
|
|
)
|
|
patch = generate_patch_from_file_updates(
|
|
allowed_updates,
|
|
self.config.project.root,
|
|
self.config.safety,
|
|
allowed_paths=stage.allowed_paths,
|
|
forbidden_paths=stage.forbidden_paths or DEFAULT_FORBIDDEN_PATHS,
|
|
)
|
|
patch_reason = "Deterministic patch written from allowed file blocks; disallowed file blocks were ignored."
|
|
log_message = "Wrote deterministic patch from allowed file blocks"
|
|
self.logger.event(
|
|
"file_writer.disallowed_blocks_ignored",
|
|
"Ignored disallowed file blocks from file writer output",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
)
|
|
break
|
|
if (
|
|
"no file blocks found" in str(exc)
|
|
and "diff --git " not in stdout
|
|
and not invalid_rerun_done
|
|
):
|
|
invalid_rerun_done = True
|
|
self.logger.event(
|
|
"agent.rerun",
|
|
"Re-running file writer after invalid output",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
)
|
|
rerun_outputs = dict(enriched_outputs)
|
|
rerun_outputs["invalid_file_writer_output_summary"] = _invalid_file_writer_output_summary(
|
|
stdout,
|
|
reason,
|
|
)
|
|
strict_notes = [
|
|
*retry_notes,
|
|
"Previous file_writer output was invalid. Return complete file blocks now. Do not output lookup_requests, prose, or 'lookup failed'.",
|
|
_file_writer_repair_format_note(stage),
|
|
]
|
|
result = self.agent_executor.run_stage(
|
|
agent_stage,
|
|
task,
|
|
rerun_outputs,
|
|
strict_notes,
|
|
project_context=context.project_context,
|
|
task_context=context.task_context,
|
|
retry_context="\n".join(f"- {note}" for note in strict_notes),
|
|
)
|
|
stdout = self._read_agent_stdout(result.output_path)
|
|
continue
|
|
try:
|
|
patch = normalize_patch_text(stdout)
|
|
except PipelineError:
|
|
summary_filename = _writer_summary_filename(stage, retry_count)
|
|
if "generated patch has no changes" in reason:
|
|
next_stage = self._stage_after_patch_flow(stage.id)
|
|
reason = self._no_changes_reason(retry_count)
|
|
summary_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
summary_filename,
|
|
f"# Implementation Summary\n\nStatus: pass\nReason: {reason}\n",
|
|
)
|
|
return StageResult(
|
|
stage.id,
|
|
"pass",
|
|
reason,
|
|
output_path=result.output_path,
|
|
next_stage=next_stage,
|
|
context_update=(
|
|
f"Implementation summary: "
|
|
f"{summary_path.relative_to(self.config.project.root).as_posix()}"
|
|
),
|
|
)
|
|
self.artifacts.write_stage_output(
|
|
task.id,
|
|
summary_filename,
|
|
f"# Implementation Summary\n\nStatus: fail\nReason: {reason}\n",
|
|
)
|
|
return StageResult(stage.id, "fail", reason, output_path=result.output_path)
|
|
patch_reason = "Fallback patch written from unified diff output."
|
|
log_message = "Wrote fallback patch from unified diff output"
|
|
break
|
|
patch_filename = _writer_patch_filename(stage, retry_count)
|
|
summary_filename = _writer_summary_filename(stage, retry_count)
|
|
proposed_path = self.artifacts.write_stage_output(task.id, patch_filename, patch)
|
|
summary_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
summary_filename,
|
|
format_implementation_summary(
|
|
stage.id,
|
|
proposed_path.relative_to(self.config.project.root).as_posix(),
|
|
retry_count=retry_count,
|
|
retry_notes=retry_notes,
|
|
candidate_index_path=(
|
|
candidate_index_path.relative_to(self.config.project.root).as_posix()
|
|
if candidate_index_path
|
|
else None
|
|
),
|
|
),
|
|
)
|
|
self.logger.event(
|
|
"artifact.write",
|
|
log_message,
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
artifact_path=proposed_path.relative_to(self.config.project.root),
|
|
)
|
|
return StageResult(
|
|
stage.id,
|
|
"pass",
|
|
patch_reason,
|
|
output_path=str(proposed_path.relative_to(self.config.project.root)),
|
|
context_update=_format_writer_context_update(
|
|
self.config.project.root,
|
|
summary_path,
|
|
warning_path,
|
|
),
|
|
)
|
|
|
|
def _write_file_writer_candidates(
|
|
self,
|
|
task_id: str,
|
|
stage: StageConfig,
|
|
updates: tuple[FileUpdate, ...],
|
|
retry_count: int,
|
|
) -> Path:
|
|
if not updates:
|
|
raise PipelineError("File writer error: no candidate file blocks found.")
|
|
base = f"candidate-files/{stage.id}"
|
|
if retry_count:
|
|
base += f"-retry-{retry_count}"
|
|
lines = [
|
|
"# Candidate Files",
|
|
"",
|
|
f"Stage: `{stage.id}`",
|
|
f"Retry: {retry_count}",
|
|
"",
|
|
"These are raw file blocks extracted before patch validation or apply.",
|
|
"",
|
|
"## Files",
|
|
"",
|
|
]
|
|
seen: set[str] = set()
|
|
for index, update in enumerate(updates, start=1):
|
|
filename = f"{index:03d}-{_candidate_artifact_name(update.path)}"
|
|
while filename in seen:
|
|
filename = f"{index:03d}-{len(seen):03d}-{_candidate_artifact_name(update.path)}"
|
|
seen.add(filename)
|
|
artifact_name = f"{base}/{filename}"
|
|
artifact_path = self.artifacts.write_stage_output(task_id, artifact_name, update.content)
|
|
relative = artifact_path.relative_to(self.config.project.root).as_posix()
|
|
lines.extend(
|
|
[
|
|
f"- Source path: `{update.path}`",
|
|
f" Artifact: `{relative}`",
|
|
]
|
|
)
|
|
lines.append("")
|
|
return self.artifacts.write_stage_output(task_id, f"{base}/index.md", "\n".join(lines))
|
|
|
|
def _write_file_writer_warnings(
|
|
self,
|
|
task_id: str,
|
|
stage: StageConfig,
|
|
updates: tuple[FileUpdate, ...],
|
|
retry_count: int,
|
|
) -> Path | None:
|
|
warnings = collect_writing_warnings(updates, self.config.project.root)
|
|
if not warnings:
|
|
return None
|
|
filename = _attempt_filename(f"{stage.id}-warnings.md", retry_count)
|
|
lines = [
|
|
"# Writing Warnings",
|
|
"",
|
|
f"Stage: `{stage.id}`",
|
|
"",
|
|
"These are soft writing concerns. They do not block artifact creation.",
|
|
"",
|
|
"## Warnings",
|
|
"",
|
|
*[f"- {warning}" for warning in warnings],
|
|
"",
|
|
]
|
|
return self.artifacts.write_stage_output(task_id, filename, "\n".join(lines))
|
|
|
|
def _allowed_file_contents(self, stage: StageConfig, max_chars: int = 2400) -> str:
|
|
sections: list[str] = []
|
|
for path_text in stage.allowed_paths:
|
|
path = self.config.project.root / path_text
|
|
if not path.is_file():
|
|
continue
|
|
content = path.read_text(encoding="utf-8", errors="replace")
|
|
sections.extend(
|
|
[
|
|
f"## {path_text}",
|
|
"",
|
|
"```text",
|
|
_compact_previous_output(content, max_chars=max_chars).rstrip(),
|
|
"```",
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(sections).strip()
|
|
|
|
def _task_scene_file_contents(self, task: Task, max_chars: int = 10000) -> str:
|
|
sections: list[str] = []
|
|
for path_text in _task_story_chapter_paths(task):
|
|
path = self.config.project.root / path_text
|
|
if not path.is_file():
|
|
continue
|
|
content = path.read_text(encoding="utf-8", errors="replace")
|
|
sections.extend(
|
|
[
|
|
f"## {path_text}",
|
|
"",
|
|
"```text",
|
|
_compact_previous_output(content, max_chars=max_chars).rstrip(),
|
|
"```",
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(sections).strip()
|
|
|
|
def _writer_agent_stage(self, stage: StageConfig, retry_count: int) -> StageConfig:
|
|
suffix = f"-{retry_count}" if retry_count else ""
|
|
return replace(
|
|
self._stage_for_retry_agent(stage, retry_count),
|
|
output=f"{stage.id}-agent-output{suffix}.md",
|
|
)
|
|
|
|
def _stage_after_patch_flow(self, current_stage_id: str) -> str | None:
|
|
stages = list(self.config.pipeline.stages)
|
|
stage_indexes = {stage.id: index for index, stage in enumerate(stages)}
|
|
start = stage_indexes.get(current_stage_id)
|
|
if start is None:
|
|
return None
|
|
patch_stage_types = {"patch_normalizer", "patch_validator", "patch_apply"}
|
|
for stage in stages[start + 1:]:
|
|
if stage.type in patch_stage_types:
|
|
continue
|
|
return stage.id
|
|
return None
|
|
|
|
def _no_changes_reason(self, retry_count: int) -> str:
|
|
if retry_count:
|
|
return (
|
|
"File writer produced no changes relative to the current workspace. "
|
|
"The previous patch may already be applied; skipping patch stages and "
|
|
"continuing with verification."
|
|
)
|
|
return (
|
|
"File writer produced no changes relative to the current workspace. "
|
|
"The task may already be applied locally; skipping patch stages and "
|
|
"continuing with verification."
|
|
)
|
|
|
|
def _run_patch_normalizer_stage(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
previous_outputs: dict[str, str],
|
|
retry_notes: list[str],
|
|
retry_count: int = 0,
|
|
) -> StageResult:
|
|
source = _latest_patch_like_output(previous_outputs)
|
|
if stage.agent is not None:
|
|
result = self.agent_executor.run_stage(
|
|
stage,
|
|
task,
|
|
{"patch_input": source, **previous_outputs},
|
|
retry_notes,
|
|
project_context=self.context.read_context(task, retry_notes).project_context,
|
|
task_context=self.context.read_context(task, retry_notes).task_context,
|
|
retry_context=self.context.read_context(task, retry_notes).retry_context,
|
|
)
|
|
source = self._read_agent_stdout(result.output_path)
|
|
try:
|
|
patch = normalize_patch_text(source)
|
|
except PipelineError as exc:
|
|
return StageResult(stage.id, "fail", str(exc))
|
|
output_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
_attempt_filename(stage.output or "normalized.patch", retry_count),
|
|
patch,
|
|
)
|
|
self.logger.event(
|
|
"artifact.write",
|
|
"Wrote normalized patch",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
artifact_path=output_path.relative_to(self.config.project.root),
|
|
)
|
|
return StageResult(
|
|
stage.id,
|
|
"pass",
|
|
"Normalized patch written.",
|
|
output_path=str(output_path.relative_to(self.config.project.root)),
|
|
)
|
|
|
|
def _run_patch_validator_stage(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
previous_outputs: dict[str, str],
|
|
retry_count: int = 0,
|
|
) -> StageResult:
|
|
output_filename = _attempt_filename(stage.output or "patch-validation.md", retry_count)
|
|
source = _latest_patch_like_output(previous_outputs)
|
|
try:
|
|
patch = normalize_patch_text(source)
|
|
result = validate_patch(
|
|
patch,
|
|
self.config.project.root,
|
|
self.config.safety,
|
|
max_files=stage.max_files or DEFAULT_MAX_FILES,
|
|
max_changed_lines=stage.max_lines or DEFAULT_MAX_CHANGED_LINES,
|
|
max_delete_ratio=stage.max_delete_ratio,
|
|
allowed_paths=stage.allowed_paths,
|
|
forbidden_paths=stage.forbidden_paths or DEFAULT_FORBIDDEN_PATHS,
|
|
)
|
|
except PipelineError as exc:
|
|
output_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
output_filename,
|
|
f"# Patch Validation\n\nStatus: fail\nReason: {exc}\n",
|
|
)
|
|
return StageResult(
|
|
stage.id,
|
|
"fail",
|
|
str(exc),
|
|
output_path=str(output_path.relative_to(self.config.project.root)),
|
|
)
|
|
output_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
output_filename,
|
|
format_validation_result(result),
|
|
)
|
|
return StageResult(
|
|
stage.id,
|
|
"pass",
|
|
"Patch validation passed.",
|
|
output_path=str(output_path.relative_to(self.config.project.root)),
|
|
)
|
|
|
|
def _run_patch_apply_stage(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
previous_outputs: dict[str, str],
|
|
retry_count: int = 0,
|
|
) -> StageResult:
|
|
output_filename = _attempt_filename(stage.output or "patch-apply-output.txt", retry_count)
|
|
source = _latest_patch_like_output(previous_outputs)
|
|
try:
|
|
patch = normalize_patch_text(source)
|
|
validate_patch(
|
|
patch,
|
|
self.config.project.root,
|
|
self.config.safety,
|
|
max_files=stage.max_files or DEFAULT_MAX_FILES,
|
|
max_changed_lines=stage.max_lines or DEFAULT_MAX_CHANGED_LINES,
|
|
max_delete_ratio=stage.max_delete_ratio,
|
|
allowed_paths=stage.allowed_paths,
|
|
forbidden_paths=stage.forbidden_paths or DEFAULT_FORBIDDEN_PATHS,
|
|
)
|
|
except PipelineError as exc:
|
|
output_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
output_filename,
|
|
f"# Patch Apply\n\nStatus: fail\nReason: {exc}\n",
|
|
)
|
|
return StageResult(
|
|
stage.id,
|
|
"fail",
|
|
str(exc),
|
|
output_path=str(output_path.relative_to(self.config.project.root)),
|
|
)
|
|
|
|
applied_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
_attempt_filename("applied.patch", retry_count),
|
|
patch,
|
|
)
|
|
write_git_artifacts(self.artifacts, task.id, "before-patch-apply")
|
|
mode = stage.mode or "dry_run"
|
|
apply_result = apply_patch_with_git(applied_path, self.config.project.root, mode=mode)
|
|
write_git_artifacts(self.artifacts, task.id, "after-patch-apply")
|
|
output_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
output_filename,
|
|
format_patch_apply_result(
|
|
apply_result,
|
|
applied_path.relative_to(self.config.project.root).as_posix(),
|
|
),
|
|
)
|
|
if apply_result.status != "pass":
|
|
return StageResult(
|
|
stage.id,
|
|
"fail",
|
|
f"Patch apply failed with code {apply_result.exit_code}.",
|
|
output_path=str(output_path.relative_to(self.config.project.root)),
|
|
context_update=apply_result.stderr.strip() or apply_result.stdout.strip(),
|
|
)
|
|
reason = "Patch dry run passed." if mode == "dry_run" else "Patch applied."
|
|
return StageResult(
|
|
stage.id,
|
|
"pass",
|
|
reason,
|
|
output_path=str(output_path.relative_to(self.config.project.root)),
|
|
)
|
|
|
|
def _latest_task_artifact(self, task_id: str, filename: str) -> Path | None:
|
|
path = self.artifacts.create_task_dir(task_id).directory / filename
|
|
return path if path.exists() else None
|
|
|
|
def _stage_for_retry_agent(self, stage: StageConfig, retry_count: int) -> StageConfig:
|
|
if not stage.agent_pool:
|
|
return stage
|
|
index = min(retry_count, len(stage.agent_pool) - 1)
|
|
return replace(stage, agent=stage.agent_pool[index])
|
|
|
|
def _maybe_satisfy_resource_request(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
result: StageResult,
|
|
) -> StageResult | None:
|
|
requests = parse_resource_requests(self._read_agent_stdout(result.output_path))
|
|
if not requests:
|
|
return None
|
|
paths = satisfy_resource_requests(self.artifacts, task.id, requests)
|
|
report_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
"resource-requests.md",
|
|
format_resource_report(requests, paths, self.config.project.root),
|
|
)
|
|
return StageResult(
|
|
stage.id,
|
|
"pass",
|
|
"Blocked resource requests were satisfied in the active run directory.",
|
|
output_path=str(report_path.relative_to(self.config.project.root)),
|
|
context_update=(
|
|
"Generated run-local resources: "
|
|
+ ", ".join(path.relative_to(self.config.project.root).as_posix() for path in paths)
|
|
),
|
|
)
|
|
|
|
def _write_failure_diagnostics(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
result: StageResult,
|
|
retry_count: int,
|
|
) -> str:
|
|
output = self._read_output(result.output_path)
|
|
if not output and not result.reason:
|
|
return ""
|
|
exit_code = _extract_exit_code(output) or _extract_exit_code(result.reason)
|
|
modified_files = self._modified_files()
|
|
classification = classify_failure(
|
|
"\n".join([result.reason, output]),
|
|
exit_code=exit_code,
|
|
modified_files=modified_files,
|
|
)
|
|
filename = f"diagnostics/{stage.id}-failure"
|
|
if retry_count:
|
|
filename += f"-retry-{retry_count}"
|
|
filename += ".md"
|
|
diagnostic_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
filename,
|
|
format_failure_classification(
|
|
classification,
|
|
exit_code=exit_code,
|
|
modified_files=modified_files,
|
|
),
|
|
)
|
|
if classification.category == "missing dependency":
|
|
dependency_path = self.artifacts.write_stage_output(
|
|
task.id,
|
|
"diagnostics/dependency-diagnostic.md",
|
|
format_dependency_diagnostic(
|
|
diagnose_python_dependencies(self.config.project.root, output)
|
|
),
|
|
)
|
|
return (
|
|
f"Failure classification: {classification.category}; "
|
|
f"diagnostic: {diagnostic_path.relative_to(self.config.project.root).as_posix()}; "
|
|
f"dependency diagnostic: {dependency_path.relative_to(self.config.project.root).as_posix()}."
|
|
)
|
|
return (
|
|
f"Failure classification: {classification.category}; "
|
|
f"root cause: {classification.probable_root_cause}; "
|
|
f"diagnostic: {diagnostic_path.relative_to(self.config.project.root).as_posix()}."
|
|
)
|
|
|
|
def _run_debugger_if_configured(
|
|
self,
|
|
task: Task,
|
|
result: StageResult,
|
|
retry_notes: list[str],
|
|
) -> str:
|
|
debugger_id = next(
|
|
(
|
|
agent_id
|
|
for agent_id, agent in self.config.agents.items()
|
|
if agent.role == "debugger" or agent_id == "debugger"
|
|
),
|
|
None,
|
|
)
|
|
if debugger_id is None:
|
|
return ""
|
|
stage = StageConfig(
|
|
id="debugger",
|
|
type="agent",
|
|
agent=debugger_id,
|
|
output="debugger.md",
|
|
)
|
|
output = self._read_output(result.output_path)
|
|
context = self.context.read_context(task, retry_notes)
|
|
debug_result = self.agent_executor.run_stage(
|
|
stage,
|
|
task,
|
|
{"failed_stage": result.reason, "failure_output": output},
|
|
retry_notes,
|
|
project_context=context.project_context,
|
|
task_context=context.task_context,
|
|
retry_context=context.retry_context,
|
|
)
|
|
return f"Debugger output: {debug_result.output_path or 'none'}."
|
|
|
|
def _rerun_malformed_review(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
malformed_result: StageResult,
|
|
previous_outputs: dict[str, str],
|
|
retry_notes: list[str],
|
|
retry_count: int,
|
|
project_context: str,
|
|
task_context: str,
|
|
) -> StageResult:
|
|
output_name = _attempt_filename(stage.output or f"{stage.id}.md", retry_count + 1)
|
|
strict_stage = replace(
|
|
self._stage_for_retry_agent(stage, retry_count),
|
|
output=output_name,
|
|
)
|
|
self.logger.event(
|
|
"agent.rerun",
|
|
"Re-running review after malformed output",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
)
|
|
strict_notes = [
|
|
*retry_notes,
|
|
"Previous review output was malformed. Return exactly four lines: status, reason, next_stage, context_update. Do not return prose, headings, or analysis.",
|
|
]
|
|
strict_outputs = _review_previous_outputs(previous_outputs)
|
|
malformed_stdout = self._read_agent_stdout(malformed_result.output_path).strip()
|
|
strict_outputs["malformed_review_output"] = _compact_previous_output(
|
|
malformed_stdout if malformed_stdout else self._read_output(malformed_result.output_path),
|
|
max_chars=800,
|
|
)
|
|
result = self.agent_executor.run_stage(
|
|
strict_stage,
|
|
task,
|
|
strict_outputs,
|
|
strict_notes,
|
|
project_context=project_context,
|
|
task_context=task_context,
|
|
retry_context="\n".join(f"- {note}" for note in strict_notes),
|
|
)
|
|
if _is_malformed_review_result(result):
|
|
if stage.id == "style_review" and _previous_continuity_review_passed(previous_outputs):
|
|
return StageResult(
|
|
result.stage_id,
|
|
"pass",
|
|
(
|
|
"Style review output remained malformed after strict retry; "
|
|
"continuing because continuity review passed and deterministic validators already ran."
|
|
),
|
|
output_path=result.output_path,
|
|
context_update="Style review was malformed twice; treated as soft-pass after continuity passed.",
|
|
)
|
|
return StageResult(
|
|
result.stage_id,
|
|
"fail",
|
|
(
|
|
"Review output remained malformed after a strict formatting retry. "
|
|
"Stopping without redrafting; inspect the applied draft and review artifact."
|
|
),
|
|
output_path=result.output_path,
|
|
context_update=result.context_update,
|
|
)
|
|
return result
|
|
|
|
def _modified_files(self) -> tuple[str, ...]:
|
|
completed = subprocess.run(
|
|
["git", "status", "--short"],
|
|
cwd=self.config.project.root,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
if completed.returncode != 0:
|
|
return ()
|
|
files: list[str] = []
|
|
for line in completed.stdout.splitlines():
|
|
if len(line) > 3:
|
|
files.append(line[3:].strip())
|
|
return tuple(files)
|
|
|
|
def _telemetry_entry(
|
|
self,
|
|
stage: StageConfig,
|
|
result: StageResult,
|
|
retry_count: int,
|
|
) -> TelemetryEntry:
|
|
effective_stage = self._stage_for_retry_agent(stage, retry_count)
|
|
agent = self.config.agents.get(effective_stage.agent) if effective_stage.agent else None
|
|
return telemetry_from_stage_output(
|
|
stage_id=result.stage_id,
|
|
stage_type=stage.type,
|
|
status=result.status,
|
|
output=self._read_output(result.output_path),
|
|
retry_count=retry_count,
|
|
agent_id=agent.id if agent else None,
|
|
model=agent.model if agent else None,
|
|
)
|
|
|
|
def _write_telemetry(self, task_id: str, entries: list[TelemetryEntry]) -> None:
|
|
summary = format_telemetry_summary(tuple(entries))
|
|
self.artifacts.write_stage_output(task_id, "telemetry-summary.md", summary)
|
|
self.artifacts.run_dir.joinpath("telemetry-summary.md").write_text(summary, encoding="utf-8")
|
|
|
|
def _maybe_rerun_agent_with_repo_lookup(
|
|
self,
|
|
stage: StageConfig,
|
|
task: Task,
|
|
result: StageResult,
|
|
previous_outputs: dict[str, str],
|
|
retry_notes: list[str],
|
|
project_context: str,
|
|
task_context: str,
|
|
retry_context: str | None,
|
|
) -> StageResult:
|
|
if result.status != "pass" or result.output_path is None:
|
|
return result
|
|
requests = parse_lookup_requests(self._read_agent_stdout(result.output_path))
|
|
if not requests:
|
|
return result
|
|
lookup_context = self.repo_tools.execute_requests(
|
|
task.id,
|
|
requests,
|
|
filename="files-inspected.md",
|
|
)
|
|
self.logger.event(
|
|
"agent.rerun",
|
|
"Re-running agent with repo lookup context",
|
|
stage_id=stage.id,
|
|
task_id=task.id,
|
|
lookup_count=len(requests),
|
|
)
|
|
rerun_outputs = dict(previous_outputs)
|
|
rerun_outputs["repo_lookup_results"] = lookup_context
|
|
rerun_retry_notes = [
|
|
*retry_notes,
|
|
"Repository lookup results have been provided. Write the final plan now; do not request more lookups.",
|
|
]
|
|
rerun_result = self.agent_executor.run_stage(
|
|
stage,
|
|
task,
|
|
rerun_outputs,
|
|
rerun_retry_notes,
|
|
project_context=project_context,
|
|
task_context=task_context,
|
|
retry_context="\n".join(f"- {note}" for note in rerun_retry_notes),
|
|
)
|
|
return StageResult(
|
|
stage_id=rerun_result.stage_id,
|
|
status=rerun_result.status,
|
|
reason=(
|
|
"Agent completed after repo lookup."
|
|
if rerun_result.status == "pass"
|
|
else rerun_result.reason
|
|
),
|
|
output_path=rerun_result.output_path,
|
|
next_stage=rerun_result.next_stage,
|
|
context_update=rerun_result.context_update,
|
|
)
|
|
|
|
def _build_context_pack(self, task: Task) -> str:
|
|
terms = _task_search_terms(task)
|
|
lookup_paths = self.config.safety.scoped_paths or (".",)
|
|
files = self._list_context_files(lookup_paths, task.id)
|
|
grep_sections: list[str] = []
|
|
for term in terms[:5]:
|
|
scoped_results = []
|
|
for path in lookup_paths:
|
|
grep_output = self.repo_tools.grep(re.escape(term), path, max_matches=20).rstrip()
|
|
grep_output = _filter_future_task_test_lines(grep_output, task.id)
|
|
scoped_results.append(
|
|
f"#### Path: {path}\n\n"
|
|
"```text\n"
|
|
f"{grep_output}\n"
|
|
"```"
|
|
)
|
|
grep_sections.extend(
|
|
[
|
|
f"### Search: {term}",
|
|
"",
|
|
*scoped_results,
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(
|
|
[
|
|
"# Context Pack",
|
|
"",
|
|
f"Task: `{task.id}`",
|
|
f"Title: {task.title}",
|
|
"",
|
|
"## Acceptance Criteria",
|
|
"",
|
|
"\n".join(f"- {item}" for item in task.acceptance_criteria) or "- None",
|
|
"",
|
|
"## Constraints",
|
|
"",
|
|
f"- Scoped paths: {', '.join(self.config.safety.scoped_paths) or '.'}",
|
|
"- Repository lookups are read-only.",
|
|
"- Excerpts are line-numbered where files are read directly.",
|
|
"",
|
|
"## Relevant Files",
|
|
"",
|
|
"```text",
|
|
files,
|
|
"```",
|
|
"",
|
|
"## Search Results",
|
|
"",
|
|
*grep_sections,
|
|
]
|
|
)
|
|
|
|
def _list_context_files(self, paths: tuple[str, ...], task_id: str) -> str:
|
|
sections: list[str] = []
|
|
for path in paths:
|
|
files = self.repo_tools.list_files(path, pattern="*", max_files=80).rstrip()
|
|
files = _filter_future_task_test_lines(files, task_id)
|
|
sections.extend(
|
|
[
|
|
f"## Path: {path}",
|
|
files,
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(sections).strip() or "No files found."
|
|
|
|
def _read_output(self, output_path: str | None) -> str:
|
|
if output_path is None:
|
|
return ""
|
|
path = self.config.project.root / Path(output_path)
|
|
if not path.exists():
|
|
return ""
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
def _read_context_output(self, output_path: str | None) -> str:
|
|
stdout = self._read_agent_stdout(output_path)
|
|
return stdout if stdout else self._read_output(output_path)
|
|
|
|
def _read_agent_stdout(self, output_path: str | None) -> str:
|
|
if output_path is None:
|
|
return ""
|
|
path = self.config.project.root / Path(output_path)
|
|
json_path = _agent_invocation_json_path(path)
|
|
if json_path.exists():
|
|
try:
|
|
data = json.loads(json_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
data = {}
|
|
stdout = data.get("stdout")
|
|
if isinstance(stdout, str):
|
|
return stdout
|
|
return extract_agent_stdout(self._read_output(output_path))
|
|
|
|
def _format_retry_note(
|
|
self,
|
|
retry_count: int,
|
|
stage: StageConfig,
|
|
result: StageResult,
|
|
target_stage: str,
|
|
) -> str:
|
|
note = (
|
|
f"Retry {retry_count}: stage '{stage.id}' returned "
|
|
f"{result.status} ({result.reason}); redirecting to '{target_stage}'."
|
|
)
|
|
if (
|
|
target_stage == "update_state"
|
|
and "deletion-heavy patch" in result.reason.lower()
|
|
):
|
|
note = (
|
|
f"{note}\n"
|
|
"Repair guidance: preserve existing durable state text unless it directly conflicts "
|
|
"with the accepted scene. Make minimal additive edits instead of replacing whole "
|
|
"sections or compressing character/world files."
|
|
)
|
|
excerpt = self._failure_excerpt(result.output_path)
|
|
if not excerpt:
|
|
return note
|
|
return f"{note}\n\nRelevant failure output:\n```text\n{excerpt}\n```"
|
|
|
|
def _failure_excerpt(self, output_path: str | None, max_chars: int = 3500) -> str:
|
|
content = self._read_output(output_path)
|
|
if not content.strip():
|
|
return ""
|
|
cleaned_content = re.sub(r"\n{4,}", "\n\n\n", content.strip())
|
|
if len(cleaned_content) <= max_chars:
|
|
return cleaned_content
|
|
patterns = (
|
|
"error",
|
|
"fail",
|
|
"traceback",
|
|
"assertionerror",
|
|
"exception",
|
|
"exit code",
|
|
"stderr",
|
|
"stdout",
|
|
"timed out",
|
|
)
|
|
lines = content.splitlines()
|
|
selected = [
|
|
line
|
|
for line in lines
|
|
if any(pattern in line.lower() for pattern in patterns)
|
|
]
|
|
excerpt = "\n".join(selected).strip()
|
|
if len(excerpt) < 400:
|
|
excerpt = content.strip()
|
|
excerpt = re.sub(r"\n{4,}", "\n\n\n", excerpt)
|
|
if len(excerpt) <= max_chars:
|
|
return excerpt
|
|
return excerpt[:max_chars].rstrip() + "\n... <truncated>"
|
|
|
|
def format_summary_stage(
|
|
task: Task,
|
|
previous_outputs: dict[str, str],
|
|
retry_notes: list[str],
|
|
) -> str:
|
|
outputs = "\n".join(f"- {stage_id}" for stage_id in previous_outputs)
|
|
retries = "\n".join(f"- {note}" for note in retry_notes) or "- None"
|
|
return "\n".join(
|
|
[
|
|
"# Final Notes",
|
|
"",
|
|
f"Task: `{task.id}`",
|
|
f"Title: {task.title}",
|
|
"",
|
|
"## Stage Outputs",
|
|
"",
|
|
outputs or "- None",
|
|
"",
|
|
"## Retry Notes",
|
|
"",
|
|
retries,
|
|
"",
|
|
]
|
|
)
|
|
|
|
|
|
def format_task_completion(task: Task, status: str, changed: bool) -> str:
|
|
return "\n".join(
|
|
[
|
|
"# Task Completion",
|
|
"",
|
|
f"Task: `{task.id}`",
|
|
f"Pipeline status: {status}",
|
|
f"Marked complete: {str(changed).lower()}",
|
|
"",
|
|
]
|
|
)
|
|
|
|
|
|
def format_implementation_summary(
|
|
stage_id: str,
|
|
patch_path: str,
|
|
retry_count: int = 0,
|
|
retry_notes: list[str] | None = None,
|
|
candidate_index_path: str | None = None,
|
|
) -> str:
|
|
notes = retry_notes or []
|
|
lines = [
|
|
"# Implementation Summary",
|
|
"",
|
|
f"Stage: `{stage_id}`",
|
|
"Status: pass",
|
|
f"Repair attempt: {retry_count}",
|
|
f"Patch: `{patch_path}`",
|
|
f"Candidate files: `{candidate_index_path}`" if candidate_index_path else "Candidate files: <none>",
|
|
"",
|
|
"## Retry Feedback",
|
|
"",
|
|
]
|
|
lines.extend(f"- {note}" for note in notes[-5:]) if notes else lines.append("- None")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _format_writer_context_update(
|
|
project_root: Path,
|
|
summary_path: Path,
|
|
warning_path: Path | None,
|
|
) -> str:
|
|
summary = summary_path.relative_to(project_root).as_posix()
|
|
if warning_path is None:
|
|
return f"Implementation summary: {summary}"
|
|
warnings = warning_path.relative_to(project_root).as_posix()
|
|
return f"Implementation summary: {summary}; writing warnings: {warnings}"
|
|
|
|
|
|
def _latest_patch_like_output(previous_outputs: dict[str, str]) -> str:
|
|
for name in ("normalized.patch", "applied.patch", "proposed.patch", "patch_input"):
|
|
if name in previous_outputs and previous_outputs[name].strip():
|
|
return previous_outputs[name]
|
|
for stage_id, content in reversed(list(previous_outputs.items())):
|
|
if stage_id.endswith(".patch") or "diff --git " in content or "\n--- " in content:
|
|
return content
|
|
raise PipelineError("Patch error: no previous patch output found.")
|
|
|
|
|
|
def _writer_patch_filename(stage: StageConfig, retry_count: int) -> str:
|
|
if retry_count <= 0:
|
|
return stage.output or "proposed.patch"
|
|
if stage.type == "code_writer" or stage.id == "implement":
|
|
return f"repair-{retry_count}.patch"
|
|
return _attempt_filename(stage.output or f"{stage.id}.patch", retry_count)
|
|
|
|
|
|
def _writer_summary_filename(stage: StageConfig, retry_count: int) -> str:
|
|
if stage.type == "code_writer" or stage.id == "implement":
|
|
return "implementation-summary.md" if retry_count <= 0 else f"repair-summary-{retry_count}.md"
|
|
base = f"{stage.id}-summary.md"
|
|
return base if retry_count <= 0 else _attempt_filename(base, retry_count)
|
|
|
|
|
|
def _attempt_filename(filename: str, retry_count: int) -> str:
|
|
if retry_count <= 0:
|
|
return filename
|
|
path = Path(filename)
|
|
suffix = "".join(path.suffixes)
|
|
if suffix:
|
|
stem = path.name[: -len(suffix)]
|
|
name = f"{stem}-{retry_count}{suffix}"
|
|
else:
|
|
name = f"{path.name}-{retry_count}"
|
|
return path.with_name(name).as_posix()
|
|
|
|
|
|
def _stage_with_attempt_output(stage: StageConfig, retry_count: int) -> StageConfig:
|
|
if retry_count <= 0:
|
|
return stage
|
|
output = _attempt_filename(stage.output or f"{stage.id}-output.txt", retry_count)
|
|
return replace(stage, output=output)
|
|
|
|
|
|
def _extract_exit_code(text: str) -> int | None:
|
|
match = re.search(r"Exit code:\s*(-?\d+)|code\s+(-?\d+)", text)
|
|
if not match:
|
|
return None
|
|
value = match.group(1) or match.group(2)
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _task_semantic_results(index, query: str, task_id: str):
|
|
current_test_path = _current_task_test_path(task_id)
|
|
current = tuple(item for item in index if item.path == current_test_path)
|
|
current_paths = {item.path for item in current}
|
|
searched = search_index(index, query, limit=8)
|
|
filtered = tuple(
|
|
item
|
|
for item in searched
|
|
if item.path not in current_paths and not _future_task_test_path(item.path, current_test_path)
|
|
)
|
|
return (*current, *filtered)[:8]
|
|
|
|
|
|
def _future_task_test_path(path: str, current_test_path: str) -> bool:
|
|
return bool(re.fullmatch(r"tests/test_task\d+\.py", path)) and path != current_test_path
|
|
|
|
|
|
def _current_task_test_path(task_id: str) -> str:
|
|
return f"tests/test_{task_id.lower().replace('-', '')}.py"
|
|
|
|
|
|
def _filter_future_task_test_lines(text: str, task_id: str) -> str:
|
|
current_test_path = _current_task_test_path(task_id)
|
|
kept: list[str] = []
|
|
for line in text.splitlines():
|
|
normalized = line.replace("\\", "/")
|
|
matches = re.findall(r"tests/test_task\d+\.py", normalized)
|
|
if matches and all(path != current_test_path for path in matches):
|
|
continue
|
|
kept.append(line)
|
|
return "\n".join(kept)
|
|
|
|
|
|
def _invalid_file_writer_output_summary(output: str, reason: str, max_chars: int = 1200) -> str:
|
|
lines = [
|
|
f"Reason: {reason}",
|
|
f"Output length: {len(output)} characters",
|
|
]
|
|
lowered = output.lower()
|
|
if "```file:" in lowered or "```path:" in lowered:
|
|
lines.append("The output started a file block, but NightShift could not parse a complete closed block.")
|
|
else:
|
|
lines.append("The output did not contain a parseable file block.")
|
|
excerpt = output.strip()
|
|
if excerpt:
|
|
if len(excerpt) > max_chars:
|
|
excerpt = excerpt[:max_chars].rstrip() + "\n... <truncated>"
|
|
lines.extend(["", "Excerpt:", "```text", excerpt, "```"])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _is_malformed_review_result(result: StageResult) -> bool:
|
|
return result.status == "fail" and (
|
|
"Review output did not include a valid status" in result.reason
|
|
or "Review output remained malformed" in result.reason
|
|
)
|
|
|
|
|
|
def _failure_target_stage(stage: StageConfig, result: StageResult) -> str | None:
|
|
if stage.type not in {"agent_review", "review"}:
|
|
return result.next_stage or stage.on_fail
|
|
if _is_malformed_review_result(result):
|
|
return None
|
|
if result.next_stage and result.next_stage != stage.id:
|
|
return result.next_stage
|
|
return stage.on_fail
|
|
|
|
|
|
def _previous_continuity_review_passed(previous_outputs: dict[str, str]) -> bool:
|
|
for name, output in previous_outputs.items():
|
|
if "continuity" in name and re.search(r"(?im)^status:\s*pass\s*$", output):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _review_previous_outputs(previous_outputs: dict[str, str], max_chars: int = 1600) -> dict[str, str]:
|
|
compacted: dict[str, str] = {}
|
|
priority_names = {
|
|
"applied.patch",
|
|
"normalized-draft.patch",
|
|
"scene-draft.patch",
|
|
"draft_scene",
|
|
"apply_draft",
|
|
"validate_draft",
|
|
"test",
|
|
"review",
|
|
}
|
|
for name, output in previous_outputs.items():
|
|
if name in priority_names or name.endswith(".patch") or "draft" in name or "apply" in name:
|
|
compacted[name] = _compact_previous_output(output, max_chars=max_chars)
|
|
continue
|
|
if name in {"plan", "semantic_context", "context"}:
|
|
compacted[name] = _compact_previous_output(output, max_chars=500)
|
|
continue
|
|
compacted[name] = _compact_previous_output(output, max_chars=800)
|
|
return compacted
|
|
|
|
|
|
def _file_writer_error_reason(stage: StageConfig, reason: str) -> str:
|
|
guidance = _file_writer_stage_guidance(stage)
|
|
if not guidance or "not allowed for this stage" not in reason:
|
|
return reason
|
|
return f"{reason} {guidance}"
|
|
|
|
|
|
def _file_writer_stage_guidance(stage: StageConfig) -> str:
|
|
allowed = tuple(path.replace("\\", "/").rstrip("/") for path in stage.allowed_paths)
|
|
if allowed == ("story/chapters",):
|
|
return (
|
|
"This is the drafting stage: write only scene prose under `story/chapters/`. "
|
|
"Do not update plot state, characters, timeline, unresolved threads, or other story state files."
|
|
)
|
|
state_paths = {
|
|
"story/plot-state.md",
|
|
"story/characters.md",
|
|
"story/timeline.md",
|
|
"story/unresolved-threads.md",
|
|
}
|
|
if set(allowed).issubset(state_paths) and allowed:
|
|
return (
|
|
"This is the state update stage: update only durable story state files. "
|
|
"Do not rewrite scene prose or chapter files."
|
|
)
|
|
if allowed:
|
|
return "Return file blocks only for the allowed paths configured on this stage."
|
|
return ""
|
|
|
|
|
|
def _filter_allowed_file_updates(updates: tuple[FileUpdate, ...], stage: StageConfig) -> tuple[FileUpdate, ...]:
|
|
if not updates or not stage.allowed_paths:
|
|
return ()
|
|
allowed = tuple(path.replace("\\", "/").strip().strip("/") for path in stage.allowed_paths)
|
|
kept: list[FileUpdate] = []
|
|
for update in updates:
|
|
path = update.path.replace("\\", "/").strip().strip("/")
|
|
if any(path == root or path.startswith(root.rstrip("/") + "/") for root in allowed):
|
|
kept.append(update)
|
|
return tuple(kept)
|
|
|
|
|
|
def _file_writer_repair_format_note(stage: StageConfig) -> str:
|
|
if _is_state_update_stage(stage):
|
|
return (
|
|
"Use delimiter file blocks only: FILE: path, ---CONTENT---, complete file content, "
|
|
"---END---. Do not use markdown code fences for state update output."
|
|
)
|
|
return "Use complete fenced file blocks with both the opening ```file:path and closing ``` fence."
|
|
|
|
|
|
def _candidate_artifact_name(path_text: str) -> str:
|
|
name = path_text.replace("\\", "/").strip().strip("/")
|
|
name = re.sub(r"[^A-Za-z0-9_.-]+", "_", name)
|
|
name = name.strip("._-")
|
|
return name or "candidate.txt"
|
|
|
|
|
|
def _file_writer_previous_outputs(
|
|
previous_outputs: dict[str, str],
|
|
retry_count: int,
|
|
max_chars: int = 1200,
|
|
) -> dict[str, str]:
|
|
compacted: dict[str, str] = {}
|
|
for name, output in previous_outputs.items():
|
|
clean_output = _compact_agent_artifact_output(output)
|
|
if retry_count <= 0:
|
|
compacted[name] = clean_output
|
|
continue
|
|
compacted[name] = _compact_previous_output(clean_output, max_chars=max_chars)
|
|
return compacted
|
|
|
|
|
|
def _is_state_update_stage(stage: StageConfig) -> bool:
|
|
state_paths = {
|
|
"story/plot-state.md",
|
|
"story/characters.md",
|
|
"story/timeline.md",
|
|
"story/unresolved-threads.md",
|
|
}
|
|
allowed = {path.replace("\\", "/").rstrip("/") for path in stage.allowed_paths}
|
|
return stage.type == "file_writer" and bool(allowed) and allowed.issubset(state_paths)
|
|
|
|
|
|
def _is_scene_edit_stage(stage: StageConfig) -> bool:
|
|
allowed = {path.replace("\\", "/").rstrip("/") for path in stage.allowed_paths}
|
|
return stage.type == "file_writer" and stage.id.startswith("edit_") and "story/chapters" in allowed
|
|
|
|
|
|
def _is_writing_file_writer_stage(stage: StageConfig) -> bool:
|
|
allowed = {path.replace("\\", "/").rstrip("/") for path in stage.allowed_paths}
|
|
writing_paths = {
|
|
"story/chapters",
|
|
"story/plot-state.md",
|
|
"story/characters.md",
|
|
"story/timeline.md",
|
|
"story/unresolved-threads.md",
|
|
}
|
|
return stage.type == "file_writer" and bool(allowed & writing_paths)
|
|
|
|
|
|
def _task_story_chapter_paths(task: Task) -> tuple[str, ...]:
|
|
paths: list[str] = []
|
|
seen: set[str] = set()
|
|
for match in re.finditer(r"story/chapters/[^\s`]+?\.md", task.raw_markdown):
|
|
path = match.group(0).strip().strip("`")
|
|
if path not in seen:
|
|
paths.append(path)
|
|
seen.add(path)
|
|
return tuple(paths)
|
|
|
|
|
|
def _state_update_previous_outputs(previous_outputs: dict[str, str]) -> dict[str, str]:
|
|
compacted: dict[str, str] = {}
|
|
for name in ("draft_scene", "apply_draft", "continuity_review", "style_review"):
|
|
output = previous_outputs.get(name)
|
|
if output:
|
|
compacted[name] = _compact_previous_output(_compact_agent_artifact_output(output), max_chars=1800)
|
|
for name, output in previous_outputs.items():
|
|
if name in compacted or name in {"plan", "semantic_context", "context"}:
|
|
continue
|
|
if "draft" in name or "review" in name or "apply" in name:
|
|
compacted[name] = _compact_previous_output(_compact_agent_artifact_output(output), max_chars=1200)
|
|
return compacted
|
|
|
|
|
|
def _compact_agent_artifact_output(output: str) -> str:
|
|
if "# Agent Output:" not in output or "## Prompt" not in output:
|
|
return output
|
|
stdout = extract_agent_stdout(output).strip()
|
|
return stdout if stdout else output
|
|
|
|
|
|
def _agent_invocation_json_path(output_path: Path) -> Path:
|
|
if output_path.suffix:
|
|
return output_path.with_suffix(".json")
|
|
return output_path.with_name(output_path.name + ".json")
|
|
|
|
|
|
def _compact_previous_output(output: str, max_chars: int = 1200) -> str:
|
|
if len(output) <= max_chars:
|
|
return output
|
|
head_chars = max_chars // 2
|
|
tail_chars = max_chars - head_chars
|
|
return (
|
|
output[:head_chars].rstrip()
|
|
+ "\n\n... <previous output truncated for retry prompt> ...\n\n"
|
|
+ output[-tail_chars:].lstrip()
|
|
)
|
|
|
|
|
|
def _repeated_protected_path_violation(entries: tuple[RetryMemoryEntry, ...]) -> bool:
|
|
recent = entries[-2:]
|
|
if len(recent) < 2:
|
|
return False
|
|
return all(_is_protected_path_violation(entry.cause) for entry in recent)
|
|
|
|
|
|
def _is_protected_path_violation(text: str) -> bool:
|
|
lowered = text.lower()
|
|
return "not allowed for this stage" in lowered and "tests/" in lowered.replace("\\", "/")
|
|
|
|
|
|
def format_aggregate_run_summary(results: list[PipelineResult], status: str, reason: str) -> str:
|
|
lines = [
|
|
"# Run Summary",
|
|
"",
|
|
f"Status: {status}",
|
|
f"Reason: {reason}",
|
|
f"Tasks run: {len(results)}",
|
|
f"Completed tasks: {sum(1 for result in results if result.status == 'complete')}",
|
|
f"Failed tasks: {sum(1 for result in results if result.status != 'complete')}",
|
|
"",
|
|
"## Tasks",
|
|
"",
|
|
]
|
|
if not results:
|
|
lines.append("- None")
|
|
for result in results:
|
|
lines.append(
|
|
f"- `{result.task_id}`: {result.status} "
|
|
f"(retries: {result.retry_count}) - {result.reason}"
|
|
)
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def format_run_metadata(config: NightShiftConfig) -> str:
|
|
lines = [
|
|
"# Run Metadata",
|
|
"",
|
|
f"Project: {config.project.name}",
|
|
f"Experiment label: {config.experiment.label or ''}",
|
|
f"Prompt variant: {config.experiment.prompt_variant or ''}",
|
|
"",
|
|
"## Agents",
|
|
"",
|
|
]
|
|
for agent in config.agents.values():
|
|
lines.extend(
|
|
[
|
|
f"### {agent.id}",
|
|
"",
|
|
f"- Backend: {agent.backend}",
|
|
f"- Model: {agent.model or ''}",
|
|
f"- Temperature: {agent.temperature if agent.temperature is not None else ''}",
|
|
f"- Base URL: {agent.base_url or ''}",
|
|
f"- Command: {agent.command or ''}",
|
|
f"- System prompt: {agent.system_prompt}",
|
|
"",
|
|
]
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _task_search_terms(task: Task) -> list[str]:
|
|
source = " ".join([task.id, task.title, *task.acceptance_criteria])
|
|
words = re.findall(r"[A-Za-z_][A-Za-z0-9_]{2,}", source)
|
|
ignored = {
|
|
"the",
|
|
"and",
|
|
"for",
|
|
"with",
|
|
"that",
|
|
"this",
|
|
"task",
|
|
"add",
|
|
"use",
|
|
"can",
|
|
"should",
|
|
"must",
|
|
}
|
|
terms: list[str] = []
|
|
for word in words:
|
|
lowered = word.lower()
|
|
if lowered in ignored or lowered in terms:
|
|
continue
|
|
terms.append(lowered)
|
|
return terms or [task.id]
|