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
@@ -58,7 +58,7 @@ fun SpawnScreen(
|
||||
var providerName by remember { mutableStateOf<String?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var model by remember { mutableStateOf("") }
|
||||
var providerModels by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var providerModels by remember { mutableStateOf<List<OfferedModel>>(emptyList()) }
|
||||
var providerModelsLoading by remember { mutableStateOf(false) }
|
||||
var providerModelsError by remember { mutableStateOf<String?>(null) }
|
||||
var cwd by remember { mutableStateOf("") }
|
||||
@@ -73,12 +73,13 @@ fun SpawnScreen(
|
||||
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
|
||||
// form worth keeping, and that one leaves nothing to fill in.
|
||||
var spawnError by remember { mutableStateOf<String?>(null) }
|
||||
// GGUFs on the chosen machine, for a llama provider to choose between. Kept separate from a
|
||||
// coding CLI's provider catalog and refetched when the machine changes.
|
||||
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
|
||||
var modelKey by remember { mutableStateOf<String?>(null) }
|
||||
var contextSize by remember { mutableStateOf("") }
|
||||
var temperature by remember { mutableStateOf("") }
|
||||
// Whether a model that carries a multi-token-prediction head drafts with it. Left to the
|
||||
// server by default, which turns it on exactly where the file has one -- see `SPECULATIVE` in
|
||||
// the llama driver. Here so a machine where drafting does not pay has a way out that is not
|
||||
// an edit to config.ron.
|
||||
var speculative by remember { mutableStateOf(SPECULATIVE_AUTO) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Separate from the machines fetch below and deliberately not fatal: failing to learn the
|
||||
@@ -126,17 +127,6 @@ fun SpawnScreen(
|
||||
is LoadState.Loaded -> state.value
|
||||
}
|
||||
val machine = machines.firstOrNull { it.name == machineName }
|
||||
// Whichever machine is chosen now, asked again when that changes. The old machine's list
|
||||
// is dropped first rather than left on screen: a file name from another machine looks
|
||||
// exactly like one from this one.
|
||||
LaunchedEffect(machine?.id) {
|
||||
models = emptyList()
|
||||
modelKey = null
|
||||
val id = machine?.id ?: return@LaunchedEffect
|
||||
models =
|
||||
runCatching { withContext(Dispatchers.IO) { fetchMachineModels(settings, id) } }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
val current = machine?.providers?.firstOrNull { it.name == providerName }
|
||||
// Coding CLIs take a working directory, model, permission mode and thinking level. Keying
|
||||
// the extra fields on the kind rather than the provider name keeps a second installation
|
||||
@@ -145,25 +135,35 @@ fun SpawnScreen(
|
||||
val isCodex = current?.kind == "codex_cli"
|
||||
val isCodingCli = isClaude || isCodex
|
||||
val isLlama = current?.kind == "llama_cpp"
|
||||
// Echo is the only kind with nothing to choose between.
|
||||
val offersModels = isCodingCli || isLlama
|
||||
// Where a session's tools act, which is the only thing a working directory decides.
|
||||
val takesCwd = isCodingCli || isLlama
|
||||
|
||||
// Whichever machine and provider are chosen now, asked again when either changes. The
|
||||
// previous answer is dropped first rather than left on screen: a model name from another
|
||||
// machine looks exactly like one from this one.
|
||||
LaunchedEffect(machine?.id, current?.name) {
|
||||
model = ""
|
||||
providerModels = emptyList()
|
||||
providerModelsError = null
|
||||
permissionMode = current?.defaultPermissionMode.orEmpty()
|
||||
if (isCodingCli) {
|
||||
providerModelsLoading = true
|
||||
try {
|
||||
providerModels =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchProviderModels(settings, machine.id, current.name)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
providerModelsError = e.message
|
||||
} finally {
|
||||
providerModelsLoading = false
|
||||
}
|
||||
} else {
|
||||
// Every kind that offers models at all, not only the coding CLIs: a llama provider
|
||||
// answers with the GGUFs on the machine it runs on, through the same call. One
|
||||
// question with one answer is what keeps the picker free of a branch on the kind.
|
||||
if (machine == null || current == null || !offersModels) {
|
||||
providerModelsLoading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
providerModelsLoading = true
|
||||
try {
|
||||
providerModels =
|
||||
withContext(Dispatchers.IO) {
|
||||
fetchProviderModels(settings, machine.id, current.name)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
providerModelsError = e.message
|
||||
} finally {
|
||||
providerModelsLoading = false
|
||||
}
|
||||
}
|
||||
@@ -220,29 +220,70 @@ fun SpawnScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (isLlama) {
|
||||
// A llama session names one of the models on the machine it will run on, so the
|
||||
// choice is that list rather than free text -- a name that is not on that machine's
|
||||
// disk is a session that cannot start.
|
||||
if (models.isEmpty()) {
|
||||
Text(
|
||||
"No models on ${machine.name}. The Models screen downloads " +
|
||||
"to the backend; another machine needs the file put there itself.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
ChipGroup(
|
||||
label = "Model",
|
||||
// The file, not the whole key: the repository is the same for every
|
||||
// quantisation of a model, so the file name is what tells two of them apart.
|
||||
options = models.map { it.file },
|
||||
selected = models.firstOrNull { it.key == modelKey }?.file,
|
||||
onSelect = { file -> modelKey = models.first { it.file == file }.key },
|
||||
)
|
||||
if (offersModels) {
|
||||
when {
|
||||
providerModelsLoading ->
|
||||
Text(
|
||||
"Loading model choices…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
providerModelsError != null ->
|
||||
Text(
|
||||
"Model choices unavailable: $providerModelsError",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
// A llama session cannot start without one, so this says what to do about it
|
||||
// rather than only that there is nothing -- the models it needs are on the
|
||||
// machine that will serve them, which is not always this backend.
|
||||
providerModels.isEmpty() && isLlama ->
|
||||
Text(
|
||||
"No models on ${machine?.name}. The Models screen downloads " +
|
||||
"to the backend; another machine needs the file put there itself.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
providerModels.isEmpty() ->
|
||||
Text(
|
||||
"This machine reported no selectable models.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ChipGroup(
|
||||
label = "Model",
|
||||
// The label, and the id is what is sent: for a llama model those differ,
|
||||
// since it is chosen by path and named by what is inside the file.
|
||||
options = providerModels.map { it.label },
|
||||
selected = providerModels.firstOrNull { it.id == model }?.label,
|
||||
onSelect = { chosen ->
|
||||
val id = providerModels.first { it.label == chosen }.id
|
||||
// A llama session has to have one, so choosing the same chip twice
|
||||
// must not clear it -- there is nothing to fall back to.
|
||||
model = if (model == id && !isLlama) "" else id
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (isCodingCli) {
|
||||
// Free text as well as the chips above: the catalog is a shortcut, and a CLI will
|
||||
// take a name it did not list.
|
||||
OutlinedTextField(
|
||||
value = model,
|
||||
onValueChange = { model = it },
|
||||
label = { Text("Model (blank = the CLI's default)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (isLlama) {
|
||||
OutlinedTextField(
|
||||
value = contextSize,
|
||||
onValueChange = { contextSize = it },
|
||||
@@ -260,48 +301,21 @@ fun SpawnScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (isCodingCli) {
|
||||
when {
|
||||
providerModelsLoading ->
|
||||
Text(
|
||||
"Loading model choices…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
providerModelsError != null ->
|
||||
Text(
|
||||
"Model choices unavailable: $providerModelsError",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
providerModels.isEmpty() ->
|
||||
Text(
|
||||
"This machine reported no selectable models.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
ChipGroup(
|
||||
label = "Model",
|
||||
options = providerModels,
|
||||
selected = model.ifEmpty { null },
|
||||
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = model,
|
||||
onValueChange = { model = it },
|
||||
label = { Text("Model (blank = the CLI's default)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
// Said as what it is rather than as "MTP": the reader is choosing whether the session
|
||||
// goes faster, and most models have nothing to turn on here at all.
|
||||
ChipGroup(
|
||||
label = "Speculative decoding (models that carry a draft head)",
|
||||
options = listOf(SPECULATIVE_AUTO, SPECULATIVE_OFF),
|
||||
selected = speculative,
|
||||
onSelect = { speculative = it },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Every session whose tools act on files needs one, which is both kinds that have
|
||||
// tools -- a llama session's built-in tools run in it exactly as a CLI's do.
|
||||
if (takesCwd) {
|
||||
OutlinedTextField(
|
||||
value = cwd,
|
||||
onValueChange = { cwd = it },
|
||||
@@ -311,7 +325,11 @@ fun SpawnScreen(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Offered wherever the provider has modes, rather than where this screen believes it
|
||||
// does: the server is what knows, and llama.cpp grew them without this line changing.
|
||||
if (current != null && current.permissionModes.isNotEmpty()) {
|
||||
ChipGroup(
|
||||
label = "Permissions",
|
||||
options = current.permissionModes,
|
||||
@@ -319,7 +337,9 @@ fun SpawnScreen(
|
||||
onSelect = { permissionMode = it },
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (isCodingCli) {
|
||||
// Says what it does to *later* spawns as well, because it does: the level chosen here
|
||||
// is stored as the default, which is the whole way that default is set. A picker that
|
||||
// quietly changed a global would be the same control with the fact left out.
|
||||
@@ -363,11 +383,9 @@ fun SpawnScreen(
|
||||
machine = machine.id,
|
||||
provider = chosen.name,
|
||||
title = title.trim(),
|
||||
model =
|
||||
if (isLlama) modelKey
|
||||
else model.trim().takeIf { isCodingCli },
|
||||
cwd = cwd.trim().takeIf { isCodingCli },
|
||||
permissionMode = permissionMode.takeIf { isCodingCli },
|
||||
model = model.trim().takeIf { offersModels },
|
||||
cwd = cwd.trim().takeIf { takesCwd },
|
||||
permissionMode = permissionMode.takeIf { it.isNotEmpty() },
|
||||
effort = effort.takeIf { isCodingCli },
|
||||
// Sent only when set, so blank means "whatever llama.cpp does
|
||||
// by default" rather than a zero.
|
||||
@@ -382,6 +400,13 @@ fun SpawnScreen(
|
||||
.trim()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { put("temperature", it) }
|
||||
// Only the choice that changes anything: "auto"
|
||||
// is the absence of the setting, not a value of
|
||||
// it, so a session spawned without an opinion
|
||||
// carries none.
|
||||
if (speculative == SPECULATIVE_OFF) {
|
||||
put("speculative", "off")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -393,13 +418,23 @@ fun SpawnScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !busy && current != null && !(isLlama && modelKey == null),
|
||||
// A llama session names the file to load, so there is nothing to spawn without one.
|
||||
enabled = !busy && current != null && !(isLlama && model.isEmpty()),
|
||||
) {
|
||||
Text(if (busy) "Spawning..." else "Spawn")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave the draft head to the server, which uses one wherever the model file has one. Spelled the
|
||||
* same as the absence of the `speculative` parameter, because that is what it means.
|
||||
*/
|
||||
private const val SPECULATIVE_AUTO = "auto"
|
||||
|
||||
/** The `speculative` parameter's only other value; see the llama driver's `SPECULATIVE`. */
|
||||
private const val SPECULATIVE_OFF = "off"
|
||||
|
||||
/**
|
||||
* A labeled row of choices that wraps onto as many lines as it needs.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user