agent-infra-cli 0.4.0

Command-line client for Agent Infra
Documentation
use std::{
    path::PathBuf,
    time::{Duration, Instant},
};

use agent_infra_sdk::{
    InfraClient,
    agent_contract::{CreateAgentRequest, ResourceRelations},
    context_contract::{ConversationRecord, ConversationScope},
    deploy_contract::{CreateRuntimeRepositoryRequest, RuntimeRepositoryStatus},
    runtime_identity_contract::{CreateRuntimeIdentityRequest, RuntimeIdentityKind},
    trace_contract::InitializeRuntimeBindingRequest,
};
use anyhow::{Result, anyhow, bail};
use serde_json::json;

use crate::{CommandOutput, runtime_repo_checkout};

pub(crate) struct InitArgs {
    pub name: String,
    pub template_id: Option<String>,
    pub dir: Option<PathBuf>,
    pub no_clone: bool,
    pub owner_email: Option<String>,
}

pub(crate) async fn execute(client: &InfraClient, args: InitArgs) -> Result<CommandOutput> {
    let name = normalize_init_name(&args.name)?;
    eprintln!("init: creating runtime {name}");
    let runtime = client
        .runtime_identity()
        .create(&CreateRuntimeIdentityRequest {
            id: None,
            name: name.clone(),
            kind: RuntimeIdentityKind::Code,
            agent_id: None,
            relations: Default::default(),
            metadata: Default::default(),
            idempotency_key: String::new(),
        })
        .await?;
    let runtime_id = runtime.identity.id.as_str().to_string();

    eprintln!("init: creating agent {name}");
    let agent = client
        .agents()
        .create(&CreateAgentRequest {
            name: name.clone(),
            description: format!("initialized by infra init ({name})"),
            relations: ResourceRelations {
                runtime_ids: vec![runtime_id.clone()],
                ..Default::default()
            },
            idempotency_key: String::new(),
        })
        .await?;
    let agent_id = agent.id.as_str().to_string();

    let template_id = resolve_template_id(client, args.template_id).await?;
    eprintln!("init: initializing runtime repository from {template_id}");
    let mut record = client
        .deploy()
        .create_runtime_repository(
            &CreateRuntimeRepositoryRequest {
                template_id,
                project_name: name.clone(),
                project_path: None,
                namespace: None,
                description: Some(format!("initialized by infra init ({name})")),
                visibility: Default::default(),
                owners: runtime_repo_checkout::merge_identity_owners(
                    Vec::new(),
                    Some(&agent_id),
                    Some(&runtime_id),
                ),
            },
            &format!("init-repo-{name}"),
        )
        .await?;
    let deadline = Instant::now() + Duration::from_secs(120);
    while record.status == RuntimeRepositoryStatus::Importing {
        if Instant::now() >= deadline {
            bail!(
                "runtime repository import timed out for {}",
                record.path_with_namespace
            );
        }
        tokio::time::sleep(Duration::from_secs(2)).await;
        record = client.deploy().runtime_repository(&record.id).await?;
    }
    if record.status == RuntimeRepositoryStatus::Failed {
        bail!(
            "{}",
            record
                .error_message
                .unwrap_or_else(|| "runtime repository import failed".to_string())
        );
    }
    let checkout = runtime_repo_checkout::checkout_runtime_repository(
        &record,
        &runtime_repo_checkout::CheckoutOptions {
            dir: args.dir,
            clone: !args.no_clone,
            agent_id: Some(agent_id.clone()),
            runtime_id: Some(runtime_id.clone()),
        },
    )?;
    eprintln!(
        "checked out {} ({})",
        checkout.display(),
        checkout.join(".env.local").display()
    );

    eprintln!("init: initializing trace runtime binding");
    let binding = client
        .trace()
        .initialize_runtime_binding(
            &runtime_id,
            &InitializeRuntimeBindingRequest {
                project_name: Some(name.clone()),
                agent_id: Some(agent.id.clone()),
                owner_email: args.owner_email,
            },
            &format!("init-trace-{runtime_id}"),
        )
        .await?;

    let conversation_id = format!("conv_init_{name}");
    let message_id = format!("msg_init_{name}");
    eprintln!("init: writing context conversation {conversation_id}");
    client
        .context()
        .append_messages(vec![ConversationRecord {
            id: message_id.clone(),
            scope: ConversationScope {
                conversation_id: conversation_id.clone(),
                user_id: Some(agent.owner_user_id.clone()),
                agent_id: Some(agent_id.clone()),
                agent_space_id: None,
                runtime_type: None,
                relations: ResourceRelations {
                    agent_ids: vec![agent.id.clone()],
                    runtime_ids: vec![runtime_id.clone()],
                    user_ids: vec![agent.owner_user_id.clone()],
                },
            },
            role: "system".into(),
            content: json!({ "text": "initialized by infra init" }),
            name: None,
            tool_call_id: None,
            sequence: 0,
            created_at_ms: 0,
            metadata: json!({ "source": "infra-init" }),
        }])
        .await?;
    let recent = client
        .context()
        .recent_messages(&conversation_id, Some(10), &[])
        .await?;
    if !recent.iter().any(|item| item.id == message_id) {
        bail!("context recent did not return initialized message {message_id}");
    }

    eprintln!("init: ensuring default model config");
    let model_config = client
        .model()
        .ensure_default_agent_config(&agent_id)
        .await?;
    let model_config_id = model_config
        .get("id")
        .and_then(|value| value.as_str())
        .unwrap_or("default")
        .to_string();

    CommandOutput::json(json!({
        "runtimeId": runtime_id,
        "agentId": agent_id,
        "rrepoId": record.id,
        "checkoutDir": checkout,
        "langfuseProjectId": binding.langfuse_project_id,
        "conversationId": conversation_id,
        "messageId": message_id,
        "modelConfigId": model_config_id,
    }))
}

fn normalize_init_name(name: &str) -> Result<String> {
    let normalized = name
        .trim()
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() {
                character.to_ascii_lowercase()
            } else {
                '_'
            }
        })
        .collect::<String>()
        .trim_matches('_')
        .to_string();
    if normalized.is_empty() {
        bail!("--name must contain at least one letter or digit");
    }
    Ok(normalized)
}

async fn resolve_template_id(client: &InfraClient, template_id: Option<String>) -> Result<String> {
    if let Some(template_id) = template_id {
        let template_id = template_id.trim();
        if template_id.is_empty() {
            bail!("--template-id must not be empty");
        }
        return Ok(template_id.to_string());
    }
    let page = client.deploy().list_runtime_templates(None, None).await?;
    match page.items.as_slice() {
        [template] => Ok(template.id.clone()),
        [] => Err(anyhow!(
            "--template-id is required because no enabled runtime template is available"
        )),
        _ => Err(anyhow!(
            "--template-id is required because multiple enabled runtime templates exist"
        )),
    }
}