review classifier: classifications of one PR share a cached prefix
On the API key runtime the classifier now hands claude its prompt as one
stream-json user message (`--input-format stream-json`) with two text
blocks: the PR's shared context, carrying `cache_control: ephemeral`, and
the classification's question.
As plain stdin text the whole prompt was one block, and claude puts its
only message breakpoint on the last block, after the question. The cache
writes an entry only where a breakpoint is, so no entry ended at the
shared-context/question boundary. Every classification wrote the whole
prompt and none read it (production: cacheRead ~0, cacheCreation ~input).
The system-prompt prefix (~270 tokens) is under the minimum cacheable
length. Captures against a local listener with a dummy key (claude
2.1.223) show the client breakpoint survives on the shared block, and that
claude adds none there on its own.
The PR stays in the user role: moving it into the system prompt would give
untrusted PR text the system prompt's authority.
`Model::ask` takes a `Prompt { shared, question }`. The ACP runtime joins
it back into the same text it sent before.
Refs #5245
This commit is contained in:
parent
95b89970ee
commit
6ed88ad70a
5 changed files with 223 additions and 34 deletions
|
|
@ -78,12 +78,17 @@ pub struct Acp<S> {
|
|||
}
|
||||
|
||||
impl<S: CostSink> crate::run::Model for Acp<S> {
|
||||
async fn ask(&self, prompt: &str, key: &str, run: &TryOut) -> Result<String, String> {
|
||||
async fn ask(
|
||||
&self,
|
||||
prompt: &crate::prompt::Prompt<'_>,
|
||||
key: &str,
|
||||
run: &TryOut,
|
||||
) -> Result<String, String> {
|
||||
let Runtime::Acp(provider) = &run.runtime else {
|
||||
return Err("not an ACP run".to_owned());
|
||||
};
|
||||
let dir = crate::run::scratch_dir();
|
||||
let outcome = self.ask_in(&dir, prompt, key, run, provider).await;
|
||||
let outcome = self.ask_in(&dir, &prompt.text(), key, run, provider).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");
|
||||
}
|
||||
|
|
@ -275,6 +280,7 @@ mod tests {
|
|||
use swarm_queue_client::review_classifier::Prices;
|
||||
|
||||
use super::*;
|
||||
use crate::prompt::Prompt;
|
||||
use crate::run::Model as _;
|
||||
|
||||
/// An ACP agent in plain `sh`, logging to `<log>.*`. It writes its
|
||||
|
|
@ -388,7 +394,7 @@ done
|
|||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let (acp, costs) = acp(dir.path(), Duration::from_mins(1));
|
||||
|
||||
let answer = acp.ask("classify", "p", &run()).await;
|
||||
let answer = acp.ask(&Prompt::of("classify"), "p", &run()).await;
|
||||
assert_eq!(
|
||||
answer.as_deref(),
|
||||
Ok(r#"{"verdict":"match","certainty":90,"reason":"ok"}"#)
|
||||
|
|
@ -412,7 +418,9 @@ done
|
|||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let (acp, _) = acp(dir.path(), Duration::from_mins(1));
|
||||
|
||||
acp.ask("classify", "p", &run()).await.expect("an answer");
|
||||
acp.ask(&Prompt::of("classify"), "p", &run())
|
||||
.await
|
||||
.expect("an answer");
|
||||
let env = read(dir.path(), "env");
|
||||
for line in [
|
||||
"ACP_PROVIDER_API_KEY=p",
|
||||
|
|
@ -442,7 +450,9 @@ done
|
|||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let (acp, _) = acp(dir.path(), Duration::from_mins(1));
|
||||
|
||||
acp.ask("classify", "p", &run()).await.expect("an answer");
|
||||
acp.ask(&Prompt::of("classify"), "p", &run())
|
||||
.await
|
||||
.expect("an answer");
|
||||
let config: Value = serde_json::from_str(&read(dir.path(), "config")).expect("JSON");
|
||||
assert_eq!(config["default_agent"], AGENT_ID);
|
||||
// Only a prompt: a `permission` or `tools` key here would be merged
|
||||
|
|
@ -462,7 +472,9 @@ done
|
|||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let (acp, _) = acp(dir.path(), Duration::from_mins(1));
|
||||
|
||||
acp.ask("ASK-READ", "p", &run()).await.expect("an answer");
|
||||
acp.ask(&Prompt::of("ASK-READ"), "p", &run())
|
||||
.await
|
||||
.expect("an answer");
|
||||
let reply: Value =
|
||||
serde_json::from_str(read(dir.path(), "permission").trim()).expect("a JSON reply");
|
||||
assert_eq!(reply["result"]["outcome"]["optionId"], "no");
|
||||
|
|
@ -473,7 +485,7 @@ done
|
|||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let (acp, costs) = acp(dir.path(), Duration::from_millis(500));
|
||||
|
||||
let answer = acp.ask("STALL", "p", &run()).await;
|
||||
let answer = acp.ask(&Prompt::of("STALL"), "p", &run()).await;
|
||||
assert_eq!(answer, Err("no answer within 0 seconds".to_owned()));
|
||||
assert!(
|
||||
read(dir.path(), "methods")
|
||||
|
|
@ -495,9 +507,12 @@ done
|
|||
);
|
||||
std::fs::write(&script, unanswered).expect("written");
|
||||
|
||||
let answer = tokio::time::timeout(Duration::from_secs(10), acp.ask("one", "p", &run()))
|
||||
.await
|
||||
.expect("the deadline ends the ask");
|
||||
let answer = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
acp.ask(&Prompt::of("one"), "p", &run()),
|
||||
)
|
||||
.await
|
||||
.expect("the deadline ends the ask");
|
||||
assert_eq!(answer, Err("no answer within 0 seconds".to_owned()));
|
||||
}
|
||||
|
||||
|
|
@ -506,7 +521,9 @@ done
|
|||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let (acp, costs) = acp(dir.path(), Duration::from_mins(1));
|
||||
|
||||
acp.ask("MUTE", "p", &run()).await.expect("an answer");
|
||||
acp.ask(&Prompt::of("MUTE"), "p", &run())
|
||||
.await
|
||||
.expect("an answer");
|
||||
assert!(costs.0.lock().expect("unpoisoned").is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -517,7 +534,11 @@ done
|
|||
let mut claude = run();
|
||||
claude.runtime = Runtime::ApiKey;
|
||||
|
||||
assert!(acp.ask("classify", "p", &claude).await.is_err());
|
||||
assert!(
|
||||
acp.ask(&Prompt::of("classify"), "p", &claude)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(read(dir.path(), "methods"), "", "no agent started");
|
||||
}
|
||||
|
||||
|
|
@ -538,10 +559,16 @@ done
|
|||
let (acp, _) = acp(dir.path(), Duration::from_mins(1));
|
||||
|
||||
let (run_one, run_two) = (run(), run());
|
||||
let (one, two) = tokio::join!(acp.ask("one", "p", &run_one), acp.ask("two", "p", &run_two));
|
||||
let (prompt_one, prompt_two) = (Prompt::of("one"), Prompt::of("two"));
|
||||
let (one, two) = tokio::join!(
|
||||
acp.ask(&prompt_one, "p", &run_one),
|
||||
acp.ask(&prompt_two, "p", &run_two)
|
||||
);
|
||||
one.expect("an answer");
|
||||
two.expect("an answer");
|
||||
acp.ask("three", "p", &run()).await.expect("an answer");
|
||||
acp.ask(&Prompt::of("three"), "p", &run())
|
||||
.await
|
||||
.expect("an answer");
|
||||
let cwd = dir.path().join("cwd").display().to_string();
|
||||
assert_eq!(cwds(dir.path()), [cwd.clone(), cwd.clone(), cwd]);
|
||||
}
|
||||
|
|
@ -551,8 +578,12 @@ done
|
|||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let (acp, _) = acp(dir.path(), Duration::from_mins(1));
|
||||
|
||||
acp.ask("one", "p", &run()).await.expect("an answer");
|
||||
acp.ask("two", "p", &run()).await.expect("an answer");
|
||||
acp.ask(&Prompt::of("one"), "p", &run())
|
||||
.await
|
||||
.expect("an answer");
|
||||
acp.ask(&Prompt::of("two"), "p", &run())
|
||||
.await
|
||||
.expect("an answer");
|
||||
let homes = read(dir.path(), "homes");
|
||||
let homes: Vec<&str> = homes.lines().collect();
|
||||
assert_eq!(homes.len(), 2, "{homes:?}");
|
||||
|
|
|
|||
Loading…
Reference in a new issue