Give llama.cpp sessions tools, web search and a model picker
A llama session was a chat box: no tools, a fixed model, no permission
mode, and a model name drawn as the path the file sits at. It now runs the
agent loop itself, which is what the pieces below all hang off.
Tools are `llama-server`'s own (`--tools all`), which that server both
publishes and runs -- `GET /tools` for the definitions, `POST /tools` to
call one. Web search is Exa's MCP server, reached from this backend rather
than from the machine serving the model: that is what llama.cpp's own web
UI does, and it puts the search on the machine with a route out instead of
the one with the GPU. `llama-server`'s `--mcp-servers-json` can only spawn
local commands, so using it would have meant a Node bridge on every
machine that serves a model.
Driving the loop is what makes the permission gate ours. Two modes,
`manual` and `bypassPermissions`, which is what the mechanism has: the web
UI asks before every call and remembers the tools you say "always" to. The
allowances fold back out of the transcript's own answers, so they survive
a restart and a model change without being stored anywhere else.
Also here, because tools made each of them matter:
- **Loading is a state.** A 12 GB model takes twenty seconds to reach
memory and refuses everything until it has; the session used to report
`running` for that whole time, and a message sent meanwhile came back as
an error. It is `loading` now, and the message waits.
- **The model can be changed.** A `llama-server` holds one model, so this
stops it and starts another. The conversation survives because it was
never in the server.
- **Models are named, not pathed.** `general.name` read out of the file
itself -- over ssh too, in the round trip the spawn was already making.
Where two models share a name the file name breaks the tie.
- **`-np 1`, and the MTP draft head where the file has one.** Measured on
the 27B here: 41.5 tok/s plain, 61.4 with `--spec-type draft-mtp` at one
slot, and 28 with it at four -- speculating against a split KV cache is
worse than not speculating. The flag is conditional because asking for a
head that is not there makes `llama-server` exit.
- **A refusal says what to do.** Tool results are thousands of tokens, so
an overrun context is now ordinary; it was "http status: 400" and is now
the server's own "exceeds the available context size, try increasing it".
`GET /machines/{id}/models` is gone: the provider models route answers the
same question, and two answers to one question is how a picker comes to
offer a model the spawn screen does not.
Verified end to end against real models: a tool call asked and allowed, an
Exa search, a shell command, a 27B loaded while a message waited on it, a
model switch mid-session, a second message queued behind a running turn,
and the whole of it again on a session running over ssh.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
392cc5413d
commit
ac476ab0c9
23 files changed
+3627
-1096
No files matched your search
+76
-11
@@ -15,6 +15,7 @@
|
||||
//! phone is not being given.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::{DriverKind, ProviderConfig};
|
||||
@@ -57,12 +58,7 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
// and nowhere else. Offering it on a remote machine would be a choice that
|
||||
// changes nothing.
|
||||
if matches!(transport, Transport::Here) {
|
||||
providers.push(ProviderConfig {
|
||||
name: crate::config::ECHO_PROVIDER.to_string(),
|
||||
kind: DriverKind::Echo,
|
||||
command: None,
|
||||
models: Vec::new(),
|
||||
});
|
||||
providers.push(crate::config::Config::echo_provider());
|
||||
}
|
||||
for (name, binary, kind) in PROBES {
|
||||
let path = found
|
||||
@@ -83,22 +79,88 @@ pub async fn discover(transport: &Transport) -> Result<Vec<ProviderConfig>> {
|
||||
DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
mcp_servers: mcp_defaults(*kind),
|
||||
});
|
||||
}
|
||||
Ok(providers)
|
||||
}
|
||||
|
||||
/// One model a picker can offer, and what to call it there.
|
||||
///
|
||||
/// Two fields rather than one string because for one provider they differ:
|
||||
/// a llama.cpp model is chosen by the path it lives at and read as the name
|
||||
/// its own metadata gives it. Every other provider's id is already the name,
|
||||
/// and says so by repeating it -- which is what keeps the picker free of a
|
||||
/// branch on the session kind.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OfferedModel {
|
||||
/// What a spawn or a model change is given. Opaque to the phone.
|
||||
pub id: String,
|
||||
/// What a person reads on the chip.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl OfferedModel {
|
||||
/// A model whose id is its own name, which is every provider but llama.
|
||||
fn plain(id: impl Into<String>) -> Self {
|
||||
let id = id.into();
|
||||
Self {
|
||||
label: id.clone(),
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The MCP servers a newly discovered provider of this kind starts with.
|
||||
///
|
||||
/// A default rather than something to be typed in: a llama session with no web
|
||||
/// search is the state somebody would then have to find out how to leave, and
|
||||
/// Exa is what llama.cpp's own web UI offers under the same name. It is an
|
||||
/// ordinary config entry once written, so removing it is deleting a line.
|
||||
///
|
||||
/// Only llama.cpp, because only a driver that runs its own agent loop can use
|
||||
/// one -- the coding CLIs configure MCP themselves and a second answer here
|
||||
/// would quietly disagree with theirs.
|
||||
fn mcp_defaults(kind: DriverKind) -> Vec<crate::config::McpServerConfig> {
|
||||
match kind {
|
||||
DriverKind::LlamaCpp => vec![crate::config::McpServerConfig {
|
||||
name: "exa".to_string(),
|
||||
url: crate::session::llama::EXA_MCP_URL.to_string(),
|
||||
}],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Models the selected provider currently offers on this machine.
|
||||
///
|
||||
/// Codex's catalog is account- and CLI-version-specific, so it is asked at the
|
||||
/// moment the picker opens rather than copied into `config.ron`. Other
|
||||
/// providers retain the shortcut list discovery stored for them.
|
||||
/// moment the picker opens rather than copied into `config.ron`. A llama.cpp
|
||||
/// provider offers the GGUFs on the machine it runs on, through this same call
|
||||
/// -- there was a second route answering that alone, and it went when this one
|
||||
/// learned to, because a picker offering a model the spawn screen does not, or
|
||||
/// naming it differently, is two answers to one question. Other providers
|
||||
/// retain the shortcut list discovery stored for them.
|
||||
pub async fn provider_models(
|
||||
transport: &Transport,
|
||||
provider: &ProviderConfig,
|
||||
) -> Result<Vec<String>> {
|
||||
models_dir: &std::path::Path,
|
||||
) -> Result<Vec<OfferedModel>> {
|
||||
if provider.kind == DriverKind::LlamaCpp {
|
||||
let dir = crate::models::dir_on(transport, models_dir);
|
||||
let found = crate::models::on_machine(transport, &dir).await?;
|
||||
let labels = crate::models::labels(&found);
|
||||
return Ok(found
|
||||
.into_iter()
|
||||
.zip(labels)
|
||||
.map(|(model, label)| OfferedModel {
|
||||
id: model.key,
|
||||
label,
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
if provider.kind != DriverKind::CodexCli {
|
||||
return Ok(provider.models.clone());
|
||||
return Ok(provider.models.iter().map(OfferedModel::plain).collect());
|
||||
}
|
||||
let transport = transport.clone();
|
||||
let program = provider.program().to_string();
|
||||
@@ -114,7 +176,10 @@ pub async fn provider_models(
|
||||
json!({"id": 2, "method": "model/list", "params": {"includeHidden": false, "limit": 100}}),
|
||||
];
|
||||
let answer = transport.request_json_blocking(&launch, &initial, &requests, 2)?;
|
||||
parse_codex_models(&answer)
|
||||
Ok(parse_codex_models(&answer)?
|
||||
.into_iter()
|
||||
.map(OfferedModel::plain)
|
||||
.collect())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user