feat: add agent publishing foundation

This commit is contained in:
a.tolmachev
2026-03-29 22:10:15 +03:00
parent d757adb192
commit 7b7699cf8b
18 changed files with 1520 additions and 105 deletions
+92
View File
@@ -5,6 +5,10 @@ use axum::{
use crate::{
routes::{
agents::{
create_agent, get_agent, get_agent_version, list_agents, publish_agent,
save_agent_bindings,
},
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
operations::{
create_operation, create_version, export_operation, generate_draft, get_operation,
@@ -57,6 +61,14 @@ pub fn build_app(state: AppState) -> Router {
post(generate_draft),
)
.route("/operations/{operation_id}/export", get(export_operation))
.route("/agents", get(list_agents).post(create_agent))
.route("/agents/{agent_id}", get(get_agent))
.route(
"/agents/{agent_id}/versions/{version}",
get(get_agent_version),
)
.route("/agents/{agent_id}/bindings", post(save_agent_bindings))
.route("/agents/{agent_id}/publish", post(publish_agent))
.route(
"/auth-profiles",
get(list_auth_profiles).post(create_auth_profile),
@@ -170,6 +182,86 @@ mod tests {
assert_eq!(test_run["response_preview"]["id"], "lead_123");
}
#[tokio::test]
async fn creates_binds_and_publishes_agent() {
let registry = test_registry().await;
let storage_root = test_storage_root("agent_lifecycle");
let upstream_base_url = spawn_upstream_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = reqwest::Client::new();
let operation = client
.post(format!("{base_url}/operations"))
.json(&test_operation_payload(
&upstream_base_url,
"crm_create_lead_agent",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
client
.post(format!("{base_url}/operations/{operation_id}/publish"))
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap();
let agent = client
.post(format!("{base_url}/agents"))
.json(&json!({
"slug": "sales-assistant",
"display_name": "Sales Assistant",
"description": "Curated sales toolset",
"instructions": {},
"tool_selection_policy": {}
}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let agent_id = agent["agent_id"].as_str().unwrap().to_owned();
let bindings = client
.post(format!("{base_url}/agents/{agent_id}/bindings"))
.json(&json!([
{
"operation_id": operation_id,
"operation_version": 1,
"tool_name": "crm_create_lead_agent",
"tool_title": "Create Lead",
"enabled": true
}
]))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let published = client
.post(format!("{base_url}/agents/{agent_id}/publish"))
.json(&json!({ "version": 1 }))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(
bindings["bindings"][0]["tool_name"],
"crm_create_lead_agent"
);
assert_eq!(published["published_version"], 1);
}
#[tokio::test]
async fn creates_publishes_and_tests_graphql_operation() {
let registry = test_registry().await;
+9
View File
@@ -99,6 +99,15 @@ impl From<RegistryError> for ApiError {
RegistryError::WorkspaceNotFound { workspace_id } => {
Self::not_found(format!("workspace {workspace_id} was not found"))
}
RegistryError::AgentNotFound { agent_id } => {
Self::not_found(format!("agent {agent_id} was not found"))
}
RegistryError::PublishedAgentNotFound {
workspace_slug,
agent_slug,
} => Self::not_found(format!(
"published agent {workspace_slug}/{agent_slug} was not found"
)),
RegistryError::OperationNotFound { operation_id } => {
Self::not_found(format!("operation {operation_id} was not found"))
}
+1
View File
@@ -1,3 +1,4 @@
pub mod agents;
pub mod auth_profiles;
pub mod operations;
pub mod workspaces;
+114
View File
@@ -0,0 +1,114 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
error::ApiError,
service::{AgentBindingPayload, AgentPayload, PublishPayload},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceAgentPath {
pub workspace_id: String,
pub agent_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceAgentVersionPath {
pub workspace_id: String,
pub agent_id: String,
pub version: u32,
}
pub async fn list_agents(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_agents(&path.workspace_id.as_str().into())
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn create_agent(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<AgentPayload>,
) -> Result<Json<Value>, ApiError> {
let created = state
.service
.create_agent(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!(created)))
}
pub async fn get_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let agent = state
.service
.get_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
)
.await?;
Ok(Json(json!(agent)))
}
pub async fn get_agent_version(
Path(path): Path<WorkspaceAgentVersionPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let version = state
.service
.get_agent_version(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
path.version,
)
.await?;
Ok(Json(json!(version)))
}
pub async fn save_agent_bindings(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Json(payload): Json<Vec<AgentBindingPayload>>,
) -> Result<Json<Value>, ApiError> {
let record = state
.service
.save_agent_bindings(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(record)))
}
pub async fn publish_agent(
Path(path): Path<WorkspaceAgentPath>,
State(state): State<AppState>,
Json(payload): Json<PublishPayload>,
) -> Result<Json<Value>, ApiError> {
let published = state
.service
.publish_agent(
&path.workspace_id.as_str().into(),
&path.agent_id.as_str().into(),
payload.version,
)
.await?;
Ok(Json(json!(published)))
}
+231 -7
View File
@@ -1,17 +1,19 @@
use std::path::PathBuf;
use crank_core::{
AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft,
GeneratedDraftStatus, OperationId, OperationStatus, Protocol, SampleId, Samples, Target,
Workspace, WorkspaceId, WorkspaceStatus,
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind,
AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus,
OperationId, OperationStatus, Protocol, SampleId, Samples, Target, Workspace, WorkspaceId,
WorkspaceStatus,
};
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
use crank_registry::{
CreateVersionRequest, CreateWorkspaceRequest, OperationSampleMetadata, OperationSummary,
OperationVersionRecord, PostgresRegistry, PublishRequest, RegistryOperation, SampleKind,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UpdateWorkspaceRequest, WorkspaceRecord,
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateVersionRequest,
CreateWorkspaceRequest, OperationSampleMetadata, OperationSummary, OperationVersionRecord,
PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind,
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, WorkspaceRecord,
};
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
use crank_schema::Schema;
@@ -94,6 +96,44 @@ pub struct UpdateWorkspacePayload {
pub settings: Option<Value>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AgentPayload {
pub slug: String,
pub display_name: String,
pub description: String,
#[serde(default)]
pub instructions: Value,
#[serde(default)]
pub tool_selection_policy: Value,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AgentBindingPayload {
pub operation_id: String,
pub operation_version: u32,
pub tool_name: String,
pub tool_title: String,
pub tool_description_override: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct CreatedAgentResponse {
pub agent_id: String,
pub workspace_id: String,
pub version: u32,
pub status: AgentStatus,
}
#[derive(Clone, Debug, Serialize)]
pub struct PublishAgentResponse {
pub agent_id: String,
pub workspace_id: String,
pub published_version: u32,
pub published_at: String,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GenerateDraftPayload {
#[serde(default)]
@@ -493,6 +533,173 @@ impl AdminService {
Ok(self.registry.list_auth_profiles(workspace_id).await?)
}
#[instrument(skip(self))]
pub async fn list_agents(
&self,
workspace_id: &WorkspaceId,
) -> Result<Vec<AgentSummary>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
Ok(self.registry.list_agents(workspace_id).await?)
}
#[instrument(skip(self))]
pub async fn get_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<AgentSummary, ApiError> {
self.registry
.get_agent_summary(workspace_id, agent_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!("agent {} was not found", agent_id.as_str()))
})
}
#[instrument(skip(self))]
pub async fn get_agent_version(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
version: u32,
) -> Result<AgentVersionRecord, ApiError> {
self.registry
.get_agent_version(workspace_id, agent_id, version)
.await?
.ok_or_else(|| {
ApiError::not_found(format!(
"agent version {version} for {} was not found",
agent_id.as_str()
))
})
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_slug = %payload.slug))]
pub async fn create_agent(
&self,
workspace_id: &WorkspaceId,
payload: AgentPayload,
) -> Result<CreatedAgentResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
if self
.find_agent_by_slug(workspace_id, &payload.slug)
.await?
.is_some()
{
return Err(ApiError::conflict(format!(
"agent with slug {} already exists",
payload.slug
)));
}
let now = now_string()?;
let agent_id = AgentId::new(new_prefixed_id("agent"));
let agent = Agent {
id: agent_id.clone(),
workspace_id: workspace_id.clone(),
slug: payload.slug,
display_name: payload.display_name,
description: payload.description,
status: AgentStatus::Draft,
current_draft_version: 1,
latest_published_version: None,
created_at: now.clone(),
updated_at: now.clone(),
published_at: None,
};
let version = AgentVersion {
agent_id: agent_id.clone(),
version: 1,
status: AgentStatus::Draft,
instructions: payload.instructions,
tool_selection_policy: payload.tool_selection_policy,
created_at: now,
};
self.registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: &[],
})
.await?;
info!(agent_id = %agent_id.as_str(), version = 1, "agent created");
Ok(CreatedAgentResponse {
agent_id: agent_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
version: 1,
status: AgentStatus::Draft,
})
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str()))]
pub async fn save_agent_bindings(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
payload: Vec<AgentBindingPayload>,
) -> Result<AgentVersionRecord, ApiError> {
let agent = self.get_agent(workspace_id, agent_id).await?;
let bindings = payload
.into_iter()
.map(|binding| AgentOperationBinding {
operation_id: OperationId::new(binding.operation_id),
operation_version: binding.operation_version,
tool_name: binding.tool_name,
tool_title: binding.tool_title,
tool_description_override: binding.tool_description_override,
enabled: binding.enabled,
})
.collect::<Vec<_>>();
self.registry
.save_agent_bindings(SaveAgentBindingsRequest {
workspace_id,
agent_id,
agent_version: agent.current_draft_version,
bindings: &bindings,
})
.await?;
info!(
agent_id = %agent_id.as_str(),
version = agent.current_draft_version,
binding_count = bindings.len(),
"agent bindings saved"
);
self.get_agent_version(workspace_id, agent_id, agent.current_draft_version)
.await
}
#[instrument(skip(self), fields(workspace_id = %workspace_id.as_str(), agent_id = %agent_id.as_str(), version))]
pub async fn publish_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
version: u32,
) -> Result<PublishAgentResponse, ApiError> {
let published_at = now_string()?;
self.registry
.publish_agent(PublishAgentRequest {
workspace_id,
agent_id,
version,
published_at: &published_at,
published_by: None,
})
.await?;
info!(agent_id = %agent_id.as_str(), version, "agent published");
Ok(PublishAgentResponse {
agent_id: agent_id.as_str().to_owned(),
workspace_id: workspace_id.as_str().to_owned(),
published_version: version,
published_at,
})
}
#[instrument(skip(self, payload), fields(operation_id = %operation_id.as_str(), source_name = source_name.unwrap_or("descriptor-set.bin")))]
pub async fn upload_descriptor_set(
&self,
@@ -940,6 +1147,19 @@ impl AdminService {
.find(|operation| operation.name == name))
}
async fn find_agent_by_slug(
&self,
workspace_id: &WorkspaceId,
slug: &str,
) -> Result<Option<AgentSummary>, ApiError> {
Ok(self
.registry
.list_agents(workspace_id)
.await?
.into_iter()
.find(|agent| agent.slug == slug))
}
async fn ensure_workspace_exists(&self, workspace_id: &WorkspaceId) -> Result<(), ApiError> {
self.get_workspace(workspace_id).await.map(|_| ())
}
@@ -1012,6 +1232,10 @@ fn default_export_mode() -> ExportMode {
ExportMode::Portable
}
fn default_enabled() -> bool {
true
}
fn new_prefixed_id(prefix: &str) -> String {
format!("{prefix}_{}", Uuid::now_v7().simple())
}