Watch
0
0
Fork
You've already forked hyperhive
0

hive-runtime: record a new ACP session only once its first prompt is answered

The session id was written to the session file right after session/new,
but the system prompt rides on the first prompt only. If that prompt
failed, the next turn (or the next harness start) resumed the recorded
session as not-new and the system prompt never reached it.

The id is now written after the first session/prompt gets its reply.
A failed first prompt leaves nothing recorded, so the next turn starts
a new session and sends the system prompt again. Chosen over a separate
"system prompt delivered" marker: one file, and "recorded" already means
"usable".

Tests drive AcpRuntime against a scripted sh agent that fails the first
prompt: in-process and across a restart, the retry is a new session
carrying the system prompt.

Also: PermissionPolicy now sees a PermissionAsk (kind plus the MCP
server the tool belongs to, matched by name against the servers handed
to the session), so a caller can tell MCP tool calls from other `other`
requests.

Refs #4391
This commit is contained in:
atlas 2026-09-29 21:47:28 +02:00 • committed by mara
commit a2ab40cc69
6 changed files with 240 additions and 61 deletions

1
Cargo.lock generated
View file

@ -1987,6 +1987,7 @@ dependencies = [
"hive-claude",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",

View file

@ -14,3 +14,6 @@ serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -28,10 +28,21 @@ const STDERR_TAIL: usize = 20;
const SETTLE_QUIET: std::time::Duration = std::time::Duration::from_millis(500);
const SETTLE_MAX: std::time::Duration = std::time::Duration::from_secs(5);
/// Decides the agent's `session/request_permission` requests from the tool
/// call's ACP `kind` (`read`, `edit`, `execute`, `fetch`, …; `other` when the
/// agent gives none). `true` allows the call once, `false` rejects it.
pub type PermissionPolicy = Arc<dyn Fn(&str) -> bool + Send + Sync>;
/// One `session/request_permission` request, as a [`PermissionPolicy`] sees it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PermissionAsk<'a> {
/// The tool call's ACP `kind` (`read`, `edit`, `execute`, `fetch`, …;
/// `other` when the agent gives none).
pub kind: &'a str,
/// The MCP server, of those handed to the session, whose tool this is:
/// the tool call's title names it as `<server>_<tool>`,
/// `<server>__<tool>` or `mcp__<server>__<tool>`.
pub mcp_server: Option<&'a str>,
}
/// Decides the agent's `session/request_permission` requests. `true` allows
/// the call once, `false` rejects it.
pub type PermissionPolicy = Arc<dyn Fn(&PermissionAsk<'_>) -> bool + Send + Sync>;
/// Why an ACP operation failed.
#[derive(Debug, thiserror::Error)]
@ -105,8 +116,10 @@ impl AcpRuntime {
}
}
async fn start(&self, cwd: &Path) -> Result<Live> {
let conn = Connection::spawn(&self.command, cwd, self.permit.clone())?;
async fn start(&self, config: &Config) -> Result<Live> {
let (_, servers) = mcp_servers(config)?;
let cwd = session_cwd(config);
let conn = Connection::spawn(&self.command, &cwd, self.permit.clone(), servers)?;
let init = conn
.request(
"initialize",
@ -134,15 +147,22 @@ impl AcpRuntime {
}
/// Attach the durable session: the one already loaded, else the one named
/// by the session file, else a new one. Returns whether it is new.
async fn attach(&self, live: &mut Live, cwd: &Path, servers: &[Value]) -> Result<bool> {
/// by the session file, else a new one. Returns its id and whether it is
/// new. A new session is not recorded here: [`Self::turn`] records it once
/// its first prompt, the one carrying the system prompt, has been answered.
async fn attach(
&self,
live: &mut Live,
cwd: &Path,
servers: &[Value],
) -> Result<(String, bool)> {
let persisted = std::fs::read_to_string(&self.session_file)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty());
if let Some(id) = persisted {
if live.session.as_deref() == Some(id.as_str()) {
return Ok(false);
return Ok((id, false));
}
if live.load_session {
let params = json!({ "sessionId": id, "cwd": cwd, "mcpServers": servers });
@ -150,8 +170,8 @@ impl AcpRuntime {
Ok(response) => {
discard_stale(&mut live.conn)?;
live.model = stream::session_model(&response);
live.session = Some(id);
return Ok(false);
live.session = Some(id.clone());
return Ok((id, false));
}
Err(e @ AcpError::Rpc { .. }) => {
tracing::warn!(error = %e, "ACP session/load failed; starting a new session");
@ -169,13 +189,16 @@ impl AcpRuntime {
field: "sessionId",
})?
.to_owned();
if let Some(dir) = self.session_file.parent() {
std::fs::create_dir_all(dir).map_err(AcpError::Io)?;
}
std::fs::write(&self.session_file, &id).map_err(AcpError::Io)?;
live.model = stream::session_model(&response);
live.session = Some(id);
Ok(true)
Ok((id, true))
}
fn record_session(&self, id: &str) -> std::result::Result<(), AcpError> {
if let Some(dir) = self.session_file.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&self.session_file, id)?;
Ok(())
}
async fn turn(
@ -187,8 +210,7 @@ impl AcpRuntime {
) -> Result<Progress> {
let cwd = session_cwd(config);
let (servers, names) = mcp_servers(config)?;
let created = self.attach(live, &cwd, &servers).await?;
let session = live.session.clone().unwrap_or_default();
let (session, created) = self.attach(live, &cwd, &servers).await?;
let text = match (created, &config.system_prompt_file) {
(true, Some(path)) => match std::fs::read_to_string(path) {
Ok(system) => format!("{system}\n\n{prompt}"),
@ -219,6 +241,10 @@ impl AcpRuntime {
reply = &mut reply => break reply.unwrap_or(Err(AcpError::Closed))?,
}
};
if created {
self.record_session(&session)?;
}
live.session = Some(session.clone());
// Updates may still arrive after the reply: an agent can forward its
// stream asynchronously. Take them until the agent goes quiet.
let settled = tokio::time::Instant::now() + SETTLE_MAX;
@ -261,7 +287,7 @@ impl Runtime for AcpRuntime {
}
let live = match guard.as_mut() {
Some(live) => live,
None => guard.insert(self.start(&session_cwd(config)).await?),
None => guard.insert(self.start(config).await?),
};
let result = self.turn(live, config, prompt, sink).await;
if let Err(Error::Acp(
@ -363,3 +389,114 @@ fn mcp_servers(config: &Config) -> Result<(Vec<Value>, Vec<String>)> {
let parsed: Value = serde_json::from_str(&raw).map_err(|e| fail(e.to_string()))?;
Ok(stream::mcp_servers(&parsed))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use hive_claude::{Config, NoopSink};
use super::{AcpError, AcpRuntime};
use crate::{AcpCommand, Error, Runtime};
/// An ACP agent in plain `sh`: answers `initialize`, numbers its sessions
/// `s1`, `s2`, …, appends every `session/prompt` line to the file named by
/// its argument, and fails the first prompt it is sent.
const AGENT: &str = r#"
n=0
while IFS= read -r line; do
id=${line#*\"id\":}; id=${id%%[,\}]*}
case $line in
*'"method":"initialize"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"mcpCapabilities":{"http":true}}}}\n' "$id" ;;
*'"method":"session/new"'*)
n=$((n+1))
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s%s"}}\n' "$id" "$n" ;;
*'"method":"session/load"'*)
printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
*'"method":"session/prompt"'*)
printf '%s\n' "$line" >> "$1"
if [ -e "$1.failed" ]; then
printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id"
else
: > "$1.failed"
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32603,"message":"provider down"}}\n' "$id"
fi ;;
esac
done
"#;
fn runtime(dir: &Path) -> AcpRuntime {
let script = dir.join("agent.sh");
std::fs::write(&script, AGENT).unwrap();
let command = AcpCommand {
command: "/bin/sh".into(),
args: vec![
script.display().to_string(),
dir.join("prompts").display().to_string(),
],
env: BTreeMap::new(),
};
AcpRuntime::new(command, dir.join("session"), Arc::new(|_: &_| false))
}
fn config(dir: &Path) -> Config {
std::fs::write(dir.join("system.md"), "SYSTEM PROMPT").unwrap();
Config {
cwd: Some(dir.to_path_buf()),
system_prompt_file: Some(dir.join("system.md")),
..Config::default()
}
}
/// Which of the prompts the agent was sent carried the system prompt.
fn carried_system_prompt(dir: &Path) -> Vec<bool> {
std::fs::read_to_string(dir.join("prompts"))
.unwrap()
.lines()
.map(|line| line.contains("SYSTEM PROMPT"))
.collect()
}
#[tokio::test]
async fn a_session_whose_first_prompt_failed_is_not_resumed() {
let dir = tempfile::tempdir().unwrap();
let (runtime, config) = (runtime(dir.path()), config(dir.path()));
let failed = runtime.run(&config, "one", &NoopSink).await;
assert!(
matches!(failed, Err(Error::Acp(AcpError::Rpc { .. }))),
"{failed:?}"
);
assert!(!dir.path().join("session").exists());
let retried = runtime.run(&config, "two", &NoopSink).await.unwrap();
assert!(retried.created);
let next = runtime.run(&config, "three", &NoopSink).await.unwrap();
assert!(!next.created);
assert_eq!(carried_system_prompt(dir.path()), [true, true, false]);
assert_eq!(
std::fs::read_to_string(dir.path().join("session")).unwrap(),
"s2"
);
}
#[tokio::test]
async fn after_a_restart_a_session_whose_first_prompt_failed_is_not_loaded() {
let dir = tempfile::tempdir().unwrap();
let config = config(dir.path());
let before = runtime(dir.path());
assert!(before.run(&config, "one", &NoopSink).await.is_err());
drop(before);
let after = runtime(dir.path());
let retried = after.run(&config, "two", &NoopSink).await.unwrap();
assert!(retried.created);
assert_eq!(carried_system_prompt(dir.path()), [true, true]);
}
}

View file

@ -12,7 +12,8 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::{mpsc, oneshot};
use super::{AcpError, PermissionPolicy};
use super::stream::mcp_server_of;
use super::{AcpError, PermissionAsk, PermissionPolicy};
use crate::spec::AcpCommand;
/// Something the agent sent that is not a response to one of our requests.
@ -42,6 +43,7 @@ impl Connection {
command: &AcpCommand,
cwd: &Path,
permit: PermissionPolicy,
servers: Vec<String>,
) -> Result<Self, AcpError> {
let mut child = Command::new(&command.command)
.args(&command.args)
@ -80,6 +82,7 @@ impl Connection {
pending: pending.clone(),
tx,
permit,
servers,
};
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
@ -151,6 +154,7 @@ struct Reader {
pending: Pending,
tx: mpsc::UnboundedSender<Incoming>,
permit: PermissionPolicy,
servers: Vec<String>,
}
impl Reader {
@ -169,7 +173,7 @@ impl Reader {
let reply = match method {
"session/request_permission" => json!({
"jsonrpc": "2.0", "id": id,
"result": permission_outcome(&message["params"], &self.permit),
"result": permission_outcome(&message["params"], &self.permit, &self.servers),
}),
_ => json!({
"jsonrpc": "2.0", "id": id,
@ -225,15 +229,21 @@ async fn write(stdin: &tokio::sync::Mutex<ChildStdin>, message: &Value) -> Resul
}
/// The `session/request_permission` result: the agent's first option of the
/// matching kind, allow if `permit` accepts the tool call's kind and reject
/// otherwise. With no such option the request is answered `cancelled`.
pub(super) fn permission_outcome(params: &Value, permit: &PermissionPolicy) -> Value {
let kind = params
.get("toolCall")
.and_then(|t| t.get("kind"))
.and_then(Value::as_str)
.unwrap_or("other");
let wanted = if permit(kind) {
/// matching kind, allow if `permit` accepts the request and reject otherwise.
/// With no such option the request is answered `cancelled`. `servers` are the
/// MCP servers handed to the session, matched against the tool call's title.
pub(super) fn permission_outcome(
params: &Value,
permit: &PermissionPolicy,
servers: &[String],
) -> Value {
let tool_call = params.get("toolCall");
let field = |k: &str| tool_call.and_then(|t| t.get(k)).and_then(Value::as_str);
let ask = PermissionAsk {
kind: field("kind").unwrap_or("other"),
mcp_server: field("title").and_then(|title| mcp_server_of(title, servers)),
};
let wanted = if permit(&ask) {
["allow_once", "allow_always"]
} else {
["reject_once", "reject_always"]
@ -257,14 +267,14 @@ pub(super) fn permission_outcome(params: &Value, permit: &PermissionPolicy) -> V
#[cfg(test)]
mod tests {
use super::permission_outcome;
use crate::acp::PermissionPolicy;
use crate::acp::{PermissionAsk, PermissionPolicy};
use serde_json::json;
use std::sync::Arc;
fn request(kind: &str) -> serde_json::Value {
fn request(kind: &str, title: &str) -> serde_json::Value {
json!({
"sessionId": "s",
"toolCall": { "toolCallId": "t", "kind": kind },
"toolCall": { "toolCallId": "t", "kind": kind, "title": title },
"options": [
{ "optionId": "once", "kind": "allow_once", "name": "Allow once" },
{ "optionId": "always", "kind": "allow_always", "name": "Always allow" },
@ -273,34 +283,54 @@ mod tests {
})
}
fn no_execute() -> PermissionPolicy {
Arc::new(|kind: &str| kind != "execute")
fn servers() -> Vec<String> {
vec!["hyperhive".to_owned()]
}
/// Allows `edit` and MCP tools, nothing else.
fn edit_or_mcp() -> PermissionPolicy {
Arc::new(|ask: &PermissionAsk<'_>| ask.kind == "edit" || ask.mcp_server.is_some())
}
#[test]
fn a_permitted_kind_is_allowed_once() {
fn a_permitted_request_is_allowed_once() {
assert_eq!(
permission_outcome(&request("edit"), &no_execute()),
permission_outcome(&request("edit", "edit"), &edit_or_mcp(), &servers()),
json!({ "outcome": { "outcome": "selected", "optionId": "once" } })
);
}
#[test]
fn a_refused_kind_is_rejected() {
fn a_refused_request_is_rejected() {
assert_eq!(
permission_outcome(&request("execute"), &no_execute()),
permission_outcome(&request("execute", "bash"), &edit_or_mcp(), &servers()),
json!({ "outcome": { "outcome": "selected", "optionId": "reject" } })
);
}
#[test]
fn a_missing_kind_is_judged_as_other() {
let policy: PermissionPolicy = Arc::new(|kind: &str| kind == "other");
let mut req = request("x");
fn the_policy_sees_which_mcp_server_a_tool_belongs_to() {
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
let record = seen.clone();
let policy: PermissionPolicy = Arc::new(move |ask: &PermissionAsk<'_>| {
record
.lock()
.unwrap()
.push((ask.kind.to_owned(), ask.mcp_server.map(str::to_owned)));
true
});
permission_outcome(&request("other", "hyperhive_send"), &policy, &servers());
permission_outcome(&request("other", "skill"), &policy, &servers());
let mut req = request("x", "read");
req["toolCall"].as_object_mut().unwrap().remove("kind");
permission_outcome(&req, &policy, &servers());
assert_eq!(
permission_outcome(&req, &policy)["outcome"]["optionId"],
"once"
*seen.lock().unwrap(),
vec![
("other".to_owned(), Some("hyperhive".to_owned())),
("other".to_owned(), None),
("other".to_owned(), None),
]
);
}
@ -309,7 +339,7 @@ mod tests {
let req = json!({ "toolCall": { "kind": "execute" },
"options": [{ "optionId": "once", "kind": "allow_once" }] });
assert_eq!(
permission_outcome(&req, &no_execute()),
permission_outcome(&req, &edit_or_mcp(), &servers()),
json!({ "outcome": { "outcome": "cancelled" } })
);
}

View file

@ -33,10 +33,6 @@ impl StreamMapper {
/// `servers` are the MCP server names handed to the agent, used to give
/// their tools claude's `mcp__<server>__<tool>` names.
pub(super) fn new(servers: Vec<String>) -> Self {
let mut servers = servers;
// Longest first, so a server whose name prefixes another's never
// claims the other's tools.
servers.sort_by_key(|s| std::cmp::Reverse(s.len()));
Self {
servers,
text: String::new(),
@ -226,18 +222,30 @@ pub(super) fn canonical_tool_name(name: &str, servers: &[String]) -> String {
if name.starts_with("mcp__") {
return name.to_owned();
}
for server in servers {
if let Some(rest) = name
.strip_prefix(server.as_str())
.and_then(|r| r.strip_prefix('_'))
{
split_mcp_name(name, servers).map_or_else(
|| name.to_owned(),
|(server, tool)| format!("mcp__{server}__{tool}"),
)
}
/// Which of `servers` a tool named `<server>_<tool>`, `<server>__<tool>` or
/// `mcp__<server>__<tool>` belongs to, if any.
pub(super) fn mcp_server_of<'a>(name: &str, servers: &'a [String]) -> Option<&'a str> {
split_mcp_name(name, servers).map(|(server, _)| server)
}
/// `(server, tool)` for an MCP tool name, matching the longest server name so
/// one that prefixes another's never claims the other's tools.
fn split_mcp_name<'a, 'n>(name: &'n str, servers: &'a [String]) -> Option<(&'a str, &'n str)> {
let bare = name.strip_prefix("mcp__").unwrap_or(name);
servers
.iter()
.filter_map(|server| {
let rest = bare.strip_prefix(server.as_str())?.strip_prefix('_')?;
let tool = rest.strip_prefix('_').unwrap_or(rest);
if !tool.is_empty() {
return format!("mcp__{server}__{tool}");
}
}
}
name.to_owned()
(!tool.is_empty()).then_some((server.as_str(), tool))
})
.max_by_key(|(server, _)| server.len())
}
fn chunk_text(update: &Value) -> &str {

View file

@ -21,7 +21,7 @@ mod acp;
mod claude;
mod spec;
pub use acp::{AcpError, AcpRuntime, PermissionPolicy};
pub use acp::{AcpError, AcpRuntime, PermissionAsk, PermissionPolicy};
pub use claude::ClaudeRuntime;
pub use hive_claude::{
CompactionPolicy, Config, PercentPolicy, Progress, SessionStore, Sink, Telemetry, TokenUsage,