Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions crates/agent/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()>
}
ClientCommand::Discover => {
let discovery: Discovery = client::get(&socket, "/v1/discovery").await?;
if discovery.agents.is_empty() {
println!("No agents discovered");
if discovery.agents.is_empty() && discovery.model_runtimes.is_empty() {
println!("No agents or models discovered");
}
for agent in discovery.agents {
let version = agent.version.as_deref().unwrap_or("unknown version");
Expand All @@ -77,6 +77,11 @@ pub async fn run(command: ClientCommand, socket: PathBuf) -> anyhow::Result<()>
agent.executable.display()
);
}
for runtime in discovery.model_runtimes {
for model in runtime.models {
println!("{}\tmodel\t{}", runtime.kind, model.name);
}
}
}
ClientCommand::Config => {
let config: DaemonConfig = client::get(&socket, "/v1/config").await?;
Expand Down
7 changes: 7 additions & 0 deletions crates/agent/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,13 @@ where
"discovered program"
);
}
for runtime in &discovery.model_runtimes {
tracing::info!(
kind = %runtime.kind,
models = runtime.models.len(),
"discovered model runtime"
);
}
let (telemetry_sender, telemetry_receiver) = mpsc::channel(256);
let telemetry = config.controller.as_ref().map(|_| telemetry_sender.clone());
let (logout_sender, logout_receiver) = mpsc::channel(1);
Expand Down
3 changes: 3 additions & 0 deletions crates/agent/src/discovery/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ mod claude_code;
mod claude_desktop;
mod codex;
mod metadata;
mod ollama;
mod opencode;
mod vscode;

use agentdesktop_core::model::Discovery;

pub async fn discover() -> Discovery {
let ollama = ollama::discover().await;
let (codex, opencode, claude_code, claude_desktop, vscode) = (
codex::discover(),
opencode::discover(),
Expand All @@ -21,5 +23,6 @@ pub async fn discover() -> Discovery {
.into_iter()
.flatten()
.collect(),
model_runtimes: ollama.into_iter().collect(),
}
}
96 changes: 96 additions & 0 deletions crates/agent/src/discovery/ollama.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use std::time::Duration;

use agentdesktop_core::model::{LocalModel, ModelRuntime};
use serde::Deserialize;

const ENDPOINT: &str = "http://127.0.0.1:11434";

#[derive(Deserialize)]
struct TagsResponse {
models: Vec<OllamaModel>,
}

#[derive(Deserialize)]
struct OllamaModel {
name: String,
}

pub(super) async fn discover() -> Option<ModelRuntime> {
discover_at(ENDPOINT).await
}

async fn discover_at(endpoint: &str) -> Option<ModelRuntime> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(1))
.build()
.ok()?;
let response = client
.get(format!("{endpoint}/api/tags"))
.send()
.await
.ok()?
.error_for_status()
.ok()?
.json::<TagsResponse>()
.await
.ok()?;
let mut models = response
.models
.into_iter()
.map(|model| LocalModel { name: model.name })
.collect::<Vec<_>>();
models.sort_by(|left, right| left.name.cmp(&right.name));
models.dedup_by(|left, right| left.name == right.name);
Some(ModelRuntime {
kind: "ollama".to_owned(),
models,
})
}

#[cfg(test)]
mod tests {
use axum::{Json, Router, routing::get};
use serde_json::json;

use super::discover_at;

#[tokio::test]
async fn discovers_and_sorts_models() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(
listener,
Router::new().route(
"/api/tags",
get(|| async {
Json(json!({
"models": [
{ "name": "qwen3:8b" },
{ "name": "gemma3:4b" },
{ "name": "qwen3:8b" }
]
}))
}),
),
)
.await
.unwrap();
});

let runtime = discover_at(&format!("http://{address}"))
.await
.expect("discover Ollama");
assert_eq!(runtime.kind, "ollama");
assert_eq!(
runtime
.models
.iter()
.map(|model| model.name.as_str())
.collect::<Vec<_>>(),
["gemma3:4b", "qwen3:8b"]
);

server.abort();
}
}
15 changes: 15 additions & 0 deletions crates/agent/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,26 @@ async fn connect(
.collect(),
})
.collect(),
model_runtimes: discovered
.model_runtimes
.iter()
.map(|runtime| agentdesktop_proto::fleet::ModelRuntime {
kind: runtime.kind.clone(),
models: runtime
.models
.iter()
.map(|model| agentdesktop_proto::fleet::LocalModel {
name: model.name.clone(),
})
.collect(),
})
.collect(),
}),
)
.await?;
info!(
discoveries = discovered.agents.len(),
model_runtimes = discovered.model_runtimes.len(),
"reported inventory to controller"
);

Expand Down
8 changes: 8 additions & 0 deletions crates/controller/migrations/0002_model_runtimes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE model_runtimes (
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
models_json TEXT NOT NULL DEFAULT '[]',
PRIMARY KEY (device_id, kind)
);

CREATE INDEX model_runtimes_device_id_idx ON model_runtimes(device_id);
69 changes: 67 additions & 2 deletions crates/controller/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use anyhow::Context;
use sqlx::{AnyPool, any::AnyPoolOptions};
use std::{collections::BTreeMap, path::PathBuf};

use agentdesktop_core::model::{McpServer, Skill};
use agentdesktop_core::model::{LocalModel, McpServer, ModelRuntime, Skill};
use agentdesktop_proto::fleet::{ConfigStatus, Hello, Inventory, TelemetryEvent, telemetry_event};
use serde::Serialize;

Expand Down Expand Up @@ -114,9 +114,28 @@ pub struct DeviceDetail {
#[serde(flatten)]
pub device: DeviceSummary,
pub discoveries: Vec<DeviceDiscovery>,
pub model_runtimes: Vec<ModelRuntime>,
pub recent_events: Vec<TelemetryEventRecord>,
}

#[derive(sqlx::FromRow)]
struct ModelRuntimeRow {
kind: String,
models_json: String,
}

impl TryFrom<ModelRuntimeRow> for ModelRuntime {
type Error = anyhow::Error;

fn try_from(row: ModelRuntimeRow) -> Result<Self, Self::Error> {
Ok(Self {
kind: row.kind,
models: serde_json::from_str(&row.models_json)
.context("decode discovered local models")?,
})
}
}

#[derive(Clone, Debug, Serialize)]
pub struct TelemetryEventRecord {
pub id: String,
Expand Down Expand Up @@ -279,6 +298,18 @@ impl Database {
.into_iter()
.map(DeviceDiscovery::try_from)
.collect::<anyhow::Result<_>>()?;
let rows: Vec<ModelRuntimeRow> = sqlx::query_as(
"SELECT kind, models_json FROM model_runtimes
WHERE device_id = $1 ORDER BY kind ASC",
)
.bind(device_id)
.fetch_all(&self.pool)
.await
.context("load discovered model runtimes")?;
let model_runtimes = rows
.into_iter()
.map(ModelRuntime::try_from)
.collect::<anyhow::Result<_>>()?;
let mut device: DeviceSummary = device.into();
device.installed_tools = discoveries
.iter()
Expand All @@ -288,6 +319,7 @@ impl Database {
Ok(Some(DeviceDetail {
device,
discoveries,
model_runtimes,
recent_events,
}))
}
Expand Down Expand Up @@ -380,6 +412,30 @@ impl Database {
.execute(&mut *transaction)
.await?;
}
sqlx::query("DELETE FROM model_runtimes WHERE device_id = $1")
.bind(device_id)
.execute(&mut *transaction)
.await?;
for runtime in &inventory.model_runtimes {
let models = runtime
.models
.iter()
.map(|model| LocalModel {
name: model.name.clone(),
})
.collect::<Vec<_>>();
let models_json =
serde_json::to_string(&models).context("encode discovered local models")?;
sqlx::query(
"INSERT INTO model_runtimes (device_id, kind, models_json)
VALUES ($1, $2, $3)",
)
.bind(device_id)
.bind(&runtime.kind)
.bind(models_json)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok(())
}
Expand Down Expand Up @@ -514,7 +570,8 @@ mod tests {
use std::{collections::BTreeMap, path::PathBuf};

use agentdesktop_proto::fleet::{
Discovery, Inventory, McpServer, Skill, TelemetryEvent, ToolUseEvent, telemetry_event,
Discovery, Inventory, LocalModel, McpServer, ModelRuntime, Skill, TelemetryEvent,
ToolUseEvent, telemetry_event,
};

use super::Database;
Expand Down Expand Up @@ -561,6 +618,12 @@ mod tests {
front_matter_json: serde_json::to_vec(&front_matter).unwrap(),
}],
}],
model_runtimes: vec![ModelRuntime {
kind: "ollama".to_owned(),
models: vec![LocalModel {
name: "qwen3:8b".to_owned(),
}],
}],
},
)
.await
Expand Down Expand Up @@ -598,6 +661,8 @@ mod tests {
device.discoveries[0].skills[0].front_matter["name"],
"llm-research"
);
assert_eq!(device.model_runtimes[0].kind, "ollama");
assert_eq!(device.model_runtimes[0].models[0].name, "qwen3:8b");
assert_eq!(device.recent_events.len(), 1);
assert_eq!(device.recent_events[0].event_type, "tool.use");
assert_eq!(device.recent_events[0].payload["toolName"], "Bash");
Expand Down
8 changes: 7 additions & 1 deletion crates/controller/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -459,10 +459,16 @@ async fn handle_agent_message(
.iter()
.map(|discovery| discovery.skills.len())
.sum::<usize>();
let model_runtimes = inventory.model_runtimes.len();
let models = inventory
.model_runtimes
.iter()
.map(|runtime| runtime.models.len())
.sum::<usize>();
database.replace_inventory(device_id, &inventory).await?;
info!(
device_id,
discoveries, mcp_servers, skills, "stored device inventory"
discoveries, mcp_servers, skills, model_runtimes, models, "stored device inventory"
);
}
Some(agent_message::Message::ConfigStatus(status)) => {
Expand Down
18 changes: 18 additions & 0 deletions crates/core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,26 @@ use std::{collections::BTreeMap, path::PathBuf};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Discovery {
pub agents: Vec<Agent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub model_runtimes: Vec<ModelRuntime>,
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ModelRuntime {
/// Local runtime that owns the discovered models.
pub kind: String,
pub models: Vec<LocalModel>,
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LocalModel {
/// Runtime-scoped name used for inference requests.
pub name: String,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
Expand Down
10 changes: 10 additions & 0 deletions crates/proto/proto/fleet.proto
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ message Heartbeat {

message Inventory {
repeated Discovery discoveries = 1;
repeated ModelRuntime model_runtimes = 2;
}

message ModelRuntime {
string kind = 1;
repeated LocalModel models = 2;
}

message LocalModel {
string name = 1;
}

message Discovery {
Expand Down
Loading