Declare provider settings, and give the context figure a denominator

Two things a session could not say, and one it was saying wrongly.

**Every provider setting is reachable.** `-np 1`, the MTP draft depth, the
tool set, the sampling parameters -- most were hardcoded to what measured
best on this machine, which is right as a default and wrong as a constant:
the next machine has a different GPU and a different core count, and
nobody running this app can edit the source. `DriverKind::params` now
declares what a provider takes -- key, label, shape, what blank means, and
whether a change waits for a restart -- and the phone renders whatever
arrives, on the spawn form and in the session settings dialog. Adding a
setting to a driver is one entry in that table and no app change.
`POST /sessions/{id}/params` takes the whole map, so an absent key is the
instruction to unset; the sampling half applies at once and the session is
told in words which of the rest are waiting for a restart.

`tools` is one of them, because it is the biggest lever on a tight
context: the seven built-in definitions are ~1,300 tokens of every prompt
(2,191 against 887 with none). `"none"` omits the flag rather than passing
it on, since `--tools none` is `unknown tool "none"` and a server that
exits.

**The context figure has a denominator.** `Event::ContextWindow` carries
it, read from `llama-server`'s `/props` once the model is up -- the
measurement rather than the request, since a session that named no context
size gets the model's own. Neither coding CLI states its window, so those
keep the bare figure: "2,042" and "2,042 / 8,192" are deliberately
different-looking, and a missing ceiling is never drawn as a proportion of
an assumed one.

**And the numerator was wrong**, by the length of the last reply: it was
the prompt alone, so a five-word answer reported 2,042 against a slot
holding 2,355. It is the turn's total now, which matches `llama-server`'s
own `n_tokens` to within a token.

Two defects the review found, both of which would have shipped: changing
settings on a *stopped* session reported "no process running, so it can't
take new settings", when a stopped session is exactly when you would set
them for the next start; and `GET /tools` answers **403** rather than an
empty list on a server started without `--tools`, so reading it as a
failure made the no-tools session one that never started.

Verified against real models: settings spawned and changed live, the
restart note, a session with two tools and one with none, and the counter
checked against the server's own slot occupancy each time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-19 13:58:08 -04:00
1 parent ac476ab0c9
commit 81ab564a09
18 files changed
+831 -115

No files matched your search

+63 -1
View File
@@ -36,7 +36,8 @@ use crate::config::{
use claude::ClaudeDriver;
use codex::CodexDriver;
use driver::{
AttachmentRef, Driver, Event, EventSink, SessionCommand, SessionStatus, Unqueued, context_after,
AttachmentRef, Driver, Event, EventSink, SessionCommand, SessionStatus, Unqueued,
context_after, context_limit_after,
};
use echo::EchoDriver;
use llama::LlamaDriver;
@@ -213,6 +214,17 @@ pub struct SessionInfo {
/// are different answers and the phone draws them differently.
#[serde(skip_serializing_if = "Option::is_none")]
pub context_tokens: Option<u64>,
/// What that figure is out of -- see [`Event::ContextWindow`]. Absent
/// where the provider does not say, which is a third state again: not a
/// session with room to spare, and not one whose occupancy is unknown,
/// but one whose occupancy is known and whose ceiling is not.
#[serde(skip_serializing_if = "Option::is_none")]
pub context_limit: Option<u64>,
/// The provider settings this session was launched with, as the settings
/// dialog has to open on them -- what a control is *set to* is not
/// derivable from what the provider *offers*.
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub params: std::collections::BTreeMap<String, String>,
/// The longest edge an image should have by the time it gets here.
/// Absent rather than a large number, because "no limit" and "a limit
/// that happens to be big" are different answers.
@@ -415,6 +427,7 @@ struct Shared {
/// the session row so a phone opening a long conversation has the real
/// figure rather than whatever its newest page mentions.
context_tokens: Mutex<Option<u64>>,
context_limit: Mutex<Option<u64>>,
/// Mirrored out of the config so the pump can read it without taking
/// the manager's lock -- the pump runs underneath the manager, and
/// reaching back up would invert that.
@@ -579,6 +592,11 @@ impl LiveSession {
effort: current.effort.clone(),
takes_effort: kind.is_some_and(DriverKind::takes_effort),
context_tokens: *self.shared.context_tokens.lock().unwrap(),
context_limit: *self.shared.context_limit.lock().unwrap(),
// From the config for the reason `effort` above is: the settings
// the process was started with are what a restart-only control has
// to show, and this is where they are kept.
params: current.params.clone(),
notify: *self.shared.notify.lock().unwrap(),
auto_resume: current.auto_resume,
auto_resume_message: resume_message(current),
@@ -1104,6 +1122,8 @@ impl SessionManager {
takes_effort: kind_of(&inner.config, &meta.machine, &meta.provider)
.is_some_and(DriverKind::takes_effort),
context_tokens: None,
context_limit: None,
params: meta.params.clone(),
max_image_edge: kind_of(&inner.config, &meta.machine, &meta.provider)
.and_then(DriverKind::max_image_edge),
usage_provider: kind_of(&inner.config, &meta.machine, &meta.provider)
@@ -1319,6 +1339,43 @@ impl SessionManager {
Ok(())
}
/// Changes this session's provider settings, live and persisted.
///
/// The whole map rather than one key, because that is what a settings
/// screen has: a form is submitted as its contents, and merging one field
/// at a time would make clearing a field indistinguishable from not
/// mentioning it. An absent key *is* the instruction to unset it.
///
/// Persisted first for the reason the model is: the config answers what to
/// launch with next time, which is the whole of what a restart-only
/// setting means. The driver is then told, and takes what it can use now.
pub fn set_session_params(
&self,
id: &str,
params: std::collections::BTreeMap<String, String>,
) -> Result<()> {
let mut inner = self.inner.write().unwrap();
if !inner.config.sessions.iter().any(|meta| meta.id == id) {
bail!("no session {id}");
}
let mut candidate = inner.config.clone();
for meta in candidate.sessions.iter_mut().filter(|meta| meta.id == id) {
meta.params = params.clone();
}
candidate.save(&self.config_path)?;
inner.config = candidate;
// Told to the driver where there is one, and simply saved where there
// is not. Deliberately not `ask`: a session with no process has not
// failed to take these, it has taken them in the only way that matters
// -- its next start reads them -- and adjusting the settings of a
// stopped session so that starting it uses them is the ordinary thing
// to do, not an error to report.
if let Some(driver) = inner.live.get(id).and_then(|session| session.driver()) {
driver.set_params(&params);
}
Ok(())
}
/// Turns this session's notifications on or off, live and persisted --
/// both, or the switch moves back on its own at the next restart.
///
@@ -2394,6 +2451,7 @@ fn launch(
model: Mutex::new(meta.model.clone()),
permission_mode: Mutex::new(meta.permission_mode.clone()),
context_tokens: Mutex::new(transcript.context_tokens()),
context_limit: Mutex::new(transcript.context_limit()),
notify: Mutex::new(meta.notify),
written: Mutex::new(0),
});
@@ -2674,6 +2732,10 @@ async fn pump(
let mut context = shared.context_tokens.lock().unwrap();
*context = context_after(*context, &event);
}
{
let mut limit = shared.context_limit.lock().unwrap();
*limit = context_limit_after(*limit, &event);
}
// Nothing changed, so there is nothing to record. Both of these
// repeat: an imported session reads the turn state off its file's
// newest record on every sync, and the CLI restates its model and