review classifier: model, effort and review concurrency as swarm-wide settings
The classifier ran every classification on a hard-coded `haiku` with no
`--effort`, and the controller ran at most two requested reviews at once,
also hard-coded, so changing any of them took a code change and a
redeploy.
swarm-controller now keeps one settings document,
`{model, effort, max_concurrent_reviews}`, in its own
`review-classifier-settings` KV bucket under `all`, the way it keeps the
schedule definitions. No document means today's values: haiku, no
`--effort`, two reviews. `GET /api/review-classifier/settings` reads it;
`PUT` replaces it whole, needs the gateway's `Remote-User` like the
try-out POST, and refuses a bad model (the inference route's check), an
effort outside the five levels or a limit outside 1..=8 with
problem+json 400.
Every run request (try-out and requested review) now carries the model
and effort, and the classifier passes them to claude instead of its
constant, which is gone. A try-out reads the settings when it is posted;
a review reads them for the limit before it waits for a place and again
for the model and effort once it has one. The review semaphore is
replaced by a running count under a resizable limit, so a PUT, or the
next review's read, changes how many run without a restart, and a lower
limit lets the runs already going finish. MAX_WAITING_REVIEWS is
unchanged.
Closes #5151, part of #5096.
This commit is contained in:
parent
78412b3d3b
commit
2c5eef73cd
10 changed files with 868 additions and 69 deletions
|
|
@ -215,6 +215,8 @@ diff --git a/src/main.rs b/src/main.rs
|
|||
"
|
||||
.to_owned(),
|
||||
classifications: Vec::new(),
|
||||
model: "haiku".to_owned(),
|
||||
effort: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,14 +14,12 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||
use std::time::Duration;
|
||||
|
||||
use hive_claude::{Attach, Config, Sink};
|
||||
use swarm_queue_client::agent_inference::Effort;
|
||||
use swarm_queue_client::review_classifier::{CLASSIFICATION_DEADLINE, Reply, Row, TryOut};
|
||||
use swarm_secret_client::review_classifier::{ApiKey, api_key_path};
|
||||
|
||||
use crate::{aggregate, answer, prompt};
|
||||
|
||||
/// The model every classification runs on.
|
||||
const MODEL: &str = "haiku";
|
||||
|
||||
/// How long `claude` may print nothing before it is stopped.
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_mins(2);
|
||||
|
||||
|
|
@ -35,12 +33,15 @@ pub trait KeySource {
|
|||
/// Asks the model one classification: `claude` in production, a fake in the
|
||||
/// tests.
|
||||
pub trait Model {
|
||||
/// The model's final message for `prompt`, or why there is none.
|
||||
/// The final message of `model` at `effort` for `prompt`, or why there
|
||||
/// is none.
|
||||
fn ask(
|
||||
&self,
|
||||
prompt: &str,
|
||||
api_key: &str,
|
||||
resource_attributes: &str,
|
||||
model: &str,
|
||||
effort: Option<Effort>,
|
||||
) -> impl Future<Output = Result<String, String>> + Send;
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +62,8 @@ pub async fn answer(keys: &impl KeySource, model: &impl Model, run: &TryOut) ->
|
|||
&prompt::classification_prompt(&shared, c),
|
||||
&api_key,
|
||||
&attributes,
|
||||
&run.model,
|
||||
run.effort,
|
||||
)
|
||||
.await
|
||||
.and_then(|text| answer::parse(&text));
|
||||
|
|
@ -124,9 +127,18 @@ pub struct Claude {
|
|||
}
|
||||
|
||||
impl Model for Claude {
|
||||
async fn ask(&self, prompt: &str, api_key: &str, attributes: &str) -> Result<String, String> {
|
||||
async fn ask(
|
||||
&self,
|
||||
prompt: &str,
|
||||
api_key: &str,
|
||||
attributes: &str,
|
||||
model: &str,
|
||||
effort: Option<Effort>,
|
||||
) -> Result<String, String> {
|
||||
let dir = scratch_dir();
|
||||
let outcome = self.ask_in(&dir, prompt, api_key, attributes).await;
|
||||
let outcome = self
|
||||
.ask_in(&dir, prompt, api_key, attributes, model, effort)
|
||||
.await;
|
||||
if let Err(e) = tokio::fs::remove_dir_all(&dir).await {
|
||||
tracing::warn!(dir = %dir.display(), error = %e, "removing a run's directory failed");
|
||||
}
|
||||
|
|
@ -141,13 +153,15 @@ impl Claude {
|
|||
prompt: &str,
|
||||
api_key: &str,
|
||||
attributes: &str,
|
||||
model: &str,
|
||||
effort: Option<Effort>,
|
||||
) -> Result<String, String> {
|
||||
let system = dir.join("system.md");
|
||||
tokio::fs::create_dir_all(dir.join("config"))
|
||||
.await
|
||||
.and(tokio::fs::write(&system, prompt::SYSTEM).await)
|
||||
.map_err(|e| format!("preparing the run's directory: {e}"))?;
|
||||
let config = self.config(dir, system, api_key, attributes);
|
||||
let config = self.config(dir, system, api_key, attributes, model, effort);
|
||||
let running =
|
||||
hive_claude::Claude::spawn(&config, &Attach::OneOff).map_err(|e| e.to_string())?;
|
||||
let cancel = running.cancel_handle();
|
||||
|
|
@ -170,9 +184,18 @@ impl Claude {
|
|||
|
||||
/// One classification's invocation: the trampoline as the program, with
|
||||
/// the real `claude` and the run's own variables in its environment.
|
||||
fn config(&self, dir: &Path, system: PathBuf, api_key: &str, attributes: &str) -> Config {
|
||||
fn config(
|
||||
&self,
|
||||
dir: &Path,
|
||||
system: PathBuf,
|
||||
api_key: &str,
|
||||
attributes: &str,
|
||||
model: &str,
|
||||
effort: Option<Effort>,
|
||||
) -> Config {
|
||||
Config {
|
||||
model: Some(MODEL.to_owned()),
|
||||
model: Some(model.to_owned()),
|
||||
effort: effort.map(|e| e.as_str().to_owned()),
|
||||
cwd: Some(dir.to_owned()),
|
||||
system_prompt_file: Some(system),
|
||||
strict_mcp_config: true,
|
||||
|
|
@ -257,8 +280,16 @@ mod tests {
|
|||
struct Scripted;
|
||||
|
||||
impl Model for Scripted {
|
||||
async fn ask(&self, prompt: &str, api_key: &str, attrs: &str) -> Result<String, String> {
|
||||
async fn ask(
|
||||
&self,
|
||||
prompt: &str,
|
||||
api_key: &str,
|
||||
attrs: &str,
|
||||
model: &str,
|
||||
effort: Option<Effort>,
|
||||
) -> Result<String, String> {
|
||||
assert_eq!(api_key, "k");
|
||||
assert_eq!((model, effort), ("sonnet", Some(Effort::Low)));
|
||||
assert!(attrs.ends_with("repo=o/r,pr=7"), "{attrs}");
|
||||
if prompt.contains("ANSWER-FAIL") {
|
||||
Err("claude failed: exit 1".to_owned())
|
||||
|
|
@ -288,6 +319,8 @@ mod tests {
|
|||
threshold: 50,
|
||||
})
|
||||
.collect(),
|
||||
model: "sonnet".to_owned(),
|
||||
effort: Some(Effort::Low),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -357,6 +390,8 @@ mod tests {
|
|||
PathBuf::from("/tmp/run/system.md"),
|
||||
"k",
|
||||
"a=b",
|
||||
"sonnet",
|
||||
None,
|
||||
);
|
||||
assert_eq!(config.program, Some(claude.trampoline.clone()));
|
||||
assert!(config.env.contains(&(
|
||||
|
|
@ -365,6 +400,35 @@ mod tests {
|
|||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_runs_model_and_effort_are_claudes_flags() {
|
||||
let claude = Claude {
|
||||
program: PathBuf::from("claude"),
|
||||
trampoline: PathBuf::from("trampoline"),
|
||||
};
|
||||
let config = |model, effort| {
|
||||
claude.config(
|
||||
Path::new("/tmp/run"),
|
||||
PathBuf::from("/tmp/run/system.md"),
|
||||
"k",
|
||||
"a=b",
|
||||
model,
|
||||
effort,
|
||||
)
|
||||
};
|
||||
let set = config("opus", Some(Effort::Xhigh));
|
||||
assert_eq!(
|
||||
(set.model.as_deref(), set.effort.as_deref()),
|
||||
(Some("opus"), Some("xhigh"))
|
||||
);
|
||||
let unset = config("haiku", None);
|
||||
assert_eq!(
|
||||
(unset.model.as_deref(), unset.effort.as_deref()),
|
||||
(Some("haiku"), None),
|
||||
"no effort passes no --effort"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_repo_and_pr_extend_the_containers_own_attributes() {
|
||||
let r = run(&[]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue