nyx-agent-ai 0.1.0

Implementation-detail AI runtime adapters, prompts, and automation helpers for nyx-agent.
Documentation
//! Seed setup agent task.
//!
//! The seed setup agent prepares deterministic local state for live
//! pentesting: roles, owned objects, tenant boundaries, payment-like
//! fixtures, webhook fixtures, and markers that verification oracles
//! can look for. It should prefer app-native seed commands over raw DB
//! writes.

use nyx_agent_types::agent::{
    classify_tool_use, AgentResult, AgentTask, AgentTraceMetrics, AiError, Budget, BudgetKind,
    ExtractedAgentResult,
};
use nyx_agent_types::event::EventSink;
use nyx_agent_types::product::{ProjectLaunchProfile, SeedSetupPlan};
use serde_json::Value;

use crate::runtime::AiRuntime;
use crate::tasks::structured_output::json_values_from_text;

pub const SEED_SETUP_PROMPT_VERSION: &str = "phase25.seed_setup.v1";
pub const DEFAULT_SEED_SETUP_RUN_CAP_USD_MICROS: i64 = 3_000_000;
pub const DEFAULT_SEED_SETUP_MAX_TURNS: u32 = 24;
pub const DEFAULT_SEED_SETUP_TOOL_NAMES: &[&str] = &["Read", "Grep", "Bash", "record_seed_setup"];

#[derive(Debug, Clone)]
pub struct SeedSetupScope {
    pub project_id: String,
    pub project_name: String,
    pub task_id: String,
    pub target_base_url: Option<String>,
    pub workspace_roots: Vec<String>,
    pub launch_profile: Option<ProjectLaunchProfile>,
    pub run_cap_usd_micros: i64,
    pub max_turns: u32,
}

impl SeedSetupScope {
    pub fn new(project_id: impl Into<String>, project_name: impl Into<String>) -> Self {
        let project_id = project_id.into();
        Self {
            task_id: format!("seed-setup-{project_id}"),
            project_id,
            project_name: project_name.into(),
            target_base_url: None,
            workspace_roots: Vec::new(),
            launch_profile: None,
            run_cap_usd_micros: DEFAULT_SEED_SETUP_RUN_CAP_USD_MICROS,
            max_turns: DEFAULT_SEED_SETUP_MAX_TURNS,
        }
    }
}

#[derive(Debug, Clone)]
pub struct SeedSetupOutcome {
    pub plan: SeedSetupPlan,
    pub final_message: String,
    pub turns: u32,
    pub spent_usd_micros: i64,
    pub prompt_version: String,
    pub metrics: AgentTraceMetrics,
}

pub async fn run<R: AiRuntime + ?Sized>(
    runtime: &R,
    scope: &SeedSetupScope,
    sink: EventSink,
) -> Result<SeedSetupOutcome, AiError> {
    let task = build_agent_task(scope);
    let budget = Budget {
        run_id: scope.project_id.clone(),
        kind: BudgetKind::AgentLoop,
        cap_usd_micros: scope.run_cap_usd_micros,
    };
    let result = runtime.agent_loop(task, budget, sink).await?;
    lift_agent_result(result)
}

fn build_agent_task(scope: &SeedSetupScope) -> AgentTask {
    let workspace_roots = render_list(&scope.workspace_roots, "(no repo workspace roots found)");
    let target = scope.target_base_url.as_deref().unwrap_or("(not set)");
    let launch = scope
        .launch_profile
        .as_ref()
        .and_then(|profile| serde_json::to_string_pretty(profile).ok())
        .unwrap_or_else(|| "(none)".to_string());

    let system = "You are Nyx Agent's seed setup agent.\n\
        Prepare deterministic local test fixtures for an authorized local app. Prefer app-native seed commands, fixtures, scripts, factories, or APIs over direct DB inserts. \
        Do not use production services or raw secrets. Use stable markers such as `nyx-agent-owned-by-user-a` so later verification can prove object boundaries."
        .to_string();

    let objective = format!(
        "Project: {project_name} ({project_id})\n\
         Target hint: {target}\n\
         Workspace roots:\n{workspace_roots}\n\
         Current launch profile:\n{launch}\n\
         Max turns: {max_turns}\n\
         \n\
         Work:\n\
         1. Inspect seed scripts, test fixtures, factories, migrations, local database docs, package scripts, wrangler config, payment/webhook mocks, and auth/user model code.\n\
         2. Prefer creating or reusing a local app-native seed command such as `npm run nyx-agent:seed`, `rails db:seed`, `python manage.py seed`, or a repo-provided test fixture command. If a local command requires confirmation, include `stdin` like `\"y\\n\"` on the step.\n\
         3. Emit roles that auth setup should configure, usually `user_a`, `user_b`, and `admin` when the app supports them.\n\
         4. Emit owned objects with `name`, `id`, `route`, and `marker` so IDOR/authz checks can compare owner and peer access. Include tenant/workspace/org markers when visible.\n\
         5. Emit env vars only as local deterministic fixture values. Mark passwords/tokens/cookies as secret.\n\
         6. Emit exactly one `record_seed_setup` JSON line. The input may be either a plan object or `{{\"plan\": ...}}`. Put the JSON on its own line in the final answer; do not only describe the plan in prose.\n\
         \n\
         Example:\n\
         {{\"tool\":\"record_seed_setup\",\"input\":{{\"plan\":{{\"summary\":\"Prepared deterministic users and one project owned by user_a.\",\"seed_steps\":[{{\"command\":\"npm run nyx-agent:seed\",\"timeout_seconds\":120}}],\"reset_steps\":[{{\"command\":\"npm run dev:reset\",\"timeout_seconds\":120,\"stdin\":\"y\\n\"}}],\"env_vars\":[{{\"name\":\"NYX_AGENT_USER_A_USERNAME\",\"value\":\"user-a@example.test\",\"secret\":false}},{{\"name\":\"NYX_AGENT_USER_A_PASSWORD\",\"value\":\"nyx-agent-user-a-pass\",\"secret\":true}}],\"roles\":[\"user_a\",\"user_b\"],\"seeded_objects\":[{{\"name\":\"project\",\"id\":\"nyx-agent-project-a\",\"route\":\"/api/projects/{{id}}\",\"marker\":\"nyx-agent-owned-by-user-a\"}}],\"checks\":[\"seed script found in package.json\"],\"warnings\":[]}}}}}}\n",
        project_name = scope.project_name,
        project_id = scope.project_id,
        target = target,
        workspace_roots = workspace_roots,
        launch = launch,
        max_turns = scope.max_turns,
    );

    AgentTask {
        prompt_version: SEED_SETUP_PROMPT_VERSION.to_string(),
        task_id: scope.task_id.clone(),
        system,
        objective,
        tools: DEFAULT_SEED_SETUP_TOOL_NAMES.iter().map(|s| s.to_string()).collect(),
        working_directory: scope.workspace_roots.first().cloned(),
        max_turns: scope.max_turns,
    }
}

fn lift_agent_result(result: AgentResult) -> Result<SeedSetupOutcome, AiError> {
    let mut latest = None;
    for extracted in &result.extracted {
        if let ExtractedAgentResult::SeedSetupPlan { plan } = extracted {
            latest = Some(plan.clone());
        }
    }
    let latest = latest.or_else(|| seed_setup_from_final_message(&result.final_message));
    let Some(mut plan) = latest else {
        return Err(AiError::MalformedResponse(
            format!(
                "seed setup agent did not emit record_seed_setup or a parseable seed plan JSON; final message preview: {}",
                final_message_preview(&result.final_message)
            ),
        ));
    };
    normalise_plan(&mut plan);
    let metrics = AgentTraceMetrics::from_agent_result(&result);
    Ok(SeedSetupOutcome {
        plan,
        final_message: result.final_message,
        turns: result.turns,
        spent_usd_micros: result.cost_usd_micros,
        prompt_version: result.prompt_version,
        metrics,
    })
}

fn seed_setup_from_final_message(message: &str) -> Option<SeedSetupPlan> {
    json_values_from_text(message).into_iter().rev().find_map(|value| seed_setup_from_value(&value))
}

fn seed_setup_from_value(value: &Value) -> Option<SeedSetupPlan> {
    if let Some(plan) = seed_setup_from_tool_value(value) {
        return Some(plan);
    }
    if let Some(input) = value.get("input").or_else(|| value.get("arguments")) {
        if let Some(plan) = seed_setup_from_value(&coerce_json_string(input)) {
            return Some(plan);
        }
    }
    if let Some(record) = value.get("record_seed_setup") {
        if let Some(plan) = seed_setup_from_value(record) {
            return Some(plan);
        }
    }
    if let Some(plan_value) = value.get("plan") {
        return serde_json::from_value(plan_value.clone()).ok();
    }
    if looks_like_seed_plan(value) {
        return serde_json::from_value(value.clone()).ok();
    }
    value
        .as_array()
        .and_then(|items| items.iter().find_map(seed_setup_from_value))
        .or_else(|| value.as_object().and_then(|obj| obj.values().find_map(seed_setup_from_value)))
}

fn seed_setup_from_tool_value(value: &Value) -> Option<SeedSetupPlan> {
    if let Some(calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
        return calls.iter().find_map(seed_setup_from_value);
    }
    let name = value.get("tool").or_else(|| value.get("name"))?.as_str()?;
    let input = value
        .get("input")
        .or_else(|| value.get("arguments"))
        .map(coerce_json_string)
        .unwrap_or_else(|| serde_json::json!({}));
    match classify_tool_use(name, &input)? {
        ExtractedAgentResult::SeedSetupPlan { plan } => Some(plan),
        _ => None,
    }
}

fn looks_like_seed_plan(value: &Value) -> bool {
    value.is_object()
        && ["seed_steps", "reset_steps", "env_vars", "roles", "seeded_objects"]
            .iter()
            .any(|key| value.get(key).is_some())
}

fn coerce_json_string(value: &Value) -> Value {
    value
        .as_str()
        .and_then(|s| serde_json::from_str::<Value>(s).ok())
        .unwrap_or_else(|| value.clone())
}

fn final_message_preview(message: &str) -> String {
    let compact = message.split_whitespace().collect::<Vec<_>>().join(" ");
    compact.chars().take(280).collect()
}

fn normalise_plan(plan: &mut SeedSetupPlan) {
    plan.summary = plan.summary.trim().to_string();
    if plan.summary.is_empty() {
        plan.summary = "Prepared local seed fixtures.".to_string();
    }
    plan.roles = clean_strings(std::mem::take(&mut plan.roles));
    plan.seed_steps = clean_steps(std::mem::take(&mut plan.seed_steps));
    plan.reset_steps = clean_steps(std::mem::take(&mut plan.reset_steps));
    plan.checks = clean_strings(std::mem::take(&mut plan.checks));
    plan.warnings = clean_strings(std::mem::take(&mut plan.warnings));
    plan.seeded_objects.retain(|object| {
        !object.name.trim().is_empty()
            && (!object.id.trim().is_empty()
                || object.route.as_ref().is_some_and(|route| !route.trim().is_empty()))
    });
    plan.env_vars.retain(|var| !var.name.trim().is_empty());
}

fn clean_steps(
    steps: Vec<nyx_agent_types::product::LaunchStep>,
) -> Vec<nyx_agent_types::product::LaunchStep> {
    steps
        .into_iter()
        .filter_map(|mut step| {
            step.command = step.command.trim().to_string();
            if step.command.is_empty() {
                return None;
            }
            Some(step)
        })
        .collect()
}

fn clean_strings(values: Vec<String>) -> Vec<String> {
    let mut out = Vec::new();
    for value in values {
        let value = value.trim();
        if !value.is_empty() && !out.iter().any(|existing| existing == value) {
            out.push(value.to_string());
        }
    }
    out
}

fn render_list(items: &[String], empty: &str) -> String {
    if items.is_empty() {
        return empty.to_string();
    }
    items.iter().map(|item| format!("- {item}")).collect::<Vec<_>>().join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_pretty_fenced_seed_setup_tool_marker() {
        let text = r#"
```json
{
  "tool": "record_seed_setup",
  "input": {
    "plan": {
      "summary": "Prepared fixtures.",
      "seed_steps": [{"command": "npm run nyx-agent:seed"}],
      "reset_steps": [{"command": "npm run dev:reset", "stdin": "y\n"}],
      "env_vars": [],
      "roles": ["user_a", "user_b"],
      "seeded_objects": [],
      "checks": [],
      "warnings": []
    }
  }
}
```
"#;
        let plan = seed_setup_from_final_message(text).expect("parsed");
        assert_eq!(plan.roles, vec!["user_a", "user_b"]);
        assert_eq!(plan.reset_steps[0].stdin.as_deref(), Some("y\n"));
    }

    #[test]
    fn parses_direct_seed_plan_object() {
        let text = r#"
{
  "summary": "Direct plan.",
  "seed_steps": [{"command": "npm run seed"}],
  "reset_steps": [],
  "env_vars": [],
  "roles": ["admin"],
  "seeded_objects": [],
  "checks": [],
  "warnings": []
}
"#;
        let plan = seed_setup_from_final_message(text).expect("parsed");
        assert_eq!(plan.seed_steps[0].command, "npm run seed");
        assert_eq!(plan.roles, vec!["admin"]);
    }
}