Skip to main content

salvor_server/
agents.rs

1//! The agent-definition endpoints: register a definition, list registered
2//! ones, and read one back.
3//!
4//! # Why a server-side registry keyed by the definition hash
5//!
6//! Under the single built-in loop an agent is pure data, and pure data has a
7//! content hash: `agent_def_hash`, already recorded in every `RunStarted`
8//! event. The control plane leans on that. A client submits a definition once
9//! to `POST /v1/agents`; the server builds it (which validates it and computes
10//! the hash), stores the raw definition under that hash, and returns the hash
11//! as the agent's id. Starting a run then references the agent by hash and
12//! carries only the input, so the start payload stays tiny and the same
13//! definition drives every start, resume, and recover without the client
14//! resubmitting it.
15//!
16//! The alternative, making the client pass the full definition on every start,
17//! was declined: it would put the definition on the wire repeatedly, give the
18//! server no stable id to talk about an agent by, and separate the id a run
19//! records (`agent_def_hash`) from the id the API uses. Registering once and
20//! referencing by the same hash the log already speaks keeps those aligned.
21//!
22//! Registration is also the definition's validation point: building the agent
23//! spawns and immediately closes its MCP sessions, so a definition that cannot
24//! build is rejected here with a `400` rather than failing later at the first
25//! start.
26
27use axum::Json;
28use axum::body::Bytes;
29use axum::extract::{Path, State};
30use axum::http::{HeaderMap, StatusCode, header};
31use axum::response::IntoResponse;
32use serde_json::json;
33
34use crate::error::ApiError;
35use crate::state::{AgentDefinition, AppState, DefFormat, RegisteredAgent};
36
37/// Reads the definition format from the `Content-Type` header. TOML and JSON
38/// are the two the definition is accepted in; anything else is a `400`.
39fn format_from_headers(headers: &HeaderMap) -> Result<DefFormat, ApiError> {
40    let content_type = headers
41        .get(header::CONTENT_TYPE)
42        .and_then(|value| value.to_str().ok())
43        .unwrap_or("");
44    if content_type.contains("toml") {
45        Ok(DefFormat::Toml)
46    } else if content_type.contains("json") {
47        Ok(DefFormat::Json)
48    } else {
49        Err(ApiError::BadRequest(format!(
50            "unsupported Content-Type `{content_type}`; send application/toml or application/json"
51        )))
52    }
53}
54
55/// `POST /v1/agents`: register (and validate) an agent definition.
56///
57/// The body is the definition in the format named by `Content-Type`. The
58/// response is `{ "agent": "<agent_def_hash>", "created": <bool> }`, where
59/// `created` is `false` when the identical definition was already registered.
60pub async fn register(
61    State(state): State<AppState>,
62    headers: HeaderMap,
63    body: Bytes,
64) -> Result<impl IntoResponse, ApiError> {
65    let format = format_from_headers(&headers)?;
66    let definition = AgentDefinition {
67        format,
68        body: body.to_vec(),
69    };
70
71    // Building validates the definition and yields its content hash. The MCP
72    // sessions the build opened are closed at once: registration only needs
73    // the hash, and each start reopens fresh sessions.
74    let built = state
75        .build_agent(definition.clone())
76        .await
77        .map_err(ApiError::BadRequest)?;
78    let agent_hash = built.agent.def_hash().to_owned();
79    // The name, if the definition declared one, is read off the built agent
80    // (`AgentConfig::validate` already bounded it during the build above, the
81    // same parse-and-validate path a file-based `salvor run` goes through;
82    // this is what makes the bound a server-enforced one, not merely a
83    // client-side courtesy).
84    let name = built.agent.name().map(str::to_owned);
85    for server in built.servers {
86        if let Err(error) = server.close().await {
87            tracing::warn!(%error, "MCP session did not close cleanly after registration");
88        }
89    }
90
91    let created = state.agent(&agent_hash).is_none();
92    state.register_agent(RegisteredAgent {
93        definition,
94        agent_hash: agent_hash.clone(),
95        name,
96    });
97
98    Ok((
99        StatusCode::CREATED,
100        Json(json!({ "agent": agent_hash, "created": created })),
101    ))
102}
103
104/// `GET /v1/agents`: list the registered agent ids, each with its display
105/// name when the definition declared one (see the zero-vs-absent rule on
106/// [`get`]).
107pub async fn list(State(state): State<AppState>) -> impl IntoResponse {
108    let agents: Vec<_> = state
109        .agent_hashes()
110        .into_iter()
111        .map(|hash| {
112            let name = state.agent(&hash).and_then(|registered| registered.name);
113            let mut entry = json!({ "agent": hash });
114            if let Some(name) = name {
115                entry
116                    .as_object_mut()
117                    .expect("entry is a JSON object")
118                    .insert("name".to_owned(), json!(name));
119            }
120            entry
121        })
122        .collect();
123    Json(json!({ "agents": agents }))
124}
125
126/// `GET /v1/agents/{hash}`: read one registered definition back.
127///
128/// The response echoes the id, the format the definition was submitted in,
129/// and the definition body as text.
130///
131/// # The zero-vs-absent rule, extended to `name`
132///
133/// `name` is present only when the registered definition actually declared
134/// one; there is no such thing as a genuinely empty name to fall back to, so
135/// an agent registered with none omits the field entirely rather than
136/// emitting `"name": null`. The same rule [`GET /v1/runs`](crate::runs::list)
137/// already applies to `agent_def_hash` and `labels`.
138pub async fn get(
139    State(state): State<AppState>,
140    Path(hash): Path<String>,
141) -> Result<impl IntoResponse, ApiError> {
142    let registered = state
143        .agent(&hash)
144        .ok_or_else(|| ApiError::UnknownAgent(format!("no agent registered under `{hash}`")))?;
145    let format = match registered.definition.format {
146        DefFormat::Toml => "toml",
147        DefFormat::Json => "json",
148    };
149    let body = String::from_utf8_lossy(&registered.definition.body).into_owned();
150    let mut response = json!({
151        "agent": registered.agent_hash,
152        "format": format,
153        "definition": body,
154    });
155    if let Some(name) = registered.name {
156        response
157            .as_object_mut()
158            .expect("response is a JSON object")
159            .insert("name".to_owned(), json!(name));
160    }
161    Ok(Json(response))
162}