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(®istered.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}