feat(buzz-acp): route goose systemPrompt via ACP+ _meta extension

For goose agents, systemPrompt must be passed via _meta["systemPrompt"]
rather than as a first-class session/new field, per the ACP extensibility
convention (ACP+ / _meta approach). Buzz-agent native agents continue to
use the top-level systemPrompt field unchanged.

Add session_new_full_goose to AcpClient, which puts systemPrompt inside
_meta instead of at the top level. Add agent_command to PromptContext so
create_session_and_apply_model can detect goose (via
normalize_agent_command_identity) and dispatch to the correct variant.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-25 15:00:43 -04:00
co-authored by Will Pfleger
parent 116a445aab
commit 79eabd4a15
4 changed files with 116 additions and 10 deletions
+91
View File
@@ -321,6 +321,34 @@ impl AcpClient {
})
}
/// Send `session/new` for a goose agent, passing `systemPrompt` via `_meta`
/// per the ACP+ extensibility convention (goose reads it from `_meta["systemPrompt"]`
/// rather than as a first-class field).
pub async fn session_new_full_goose(
&mut self,
cwd: &str,
mcp_servers: Vec<McpServer>,
system_prompt: Option<&str>,
) -> Result<SessionNewResponse, AcpError> {
let mut params = serde_json::json!({
"cwd": cwd,
"mcpServers": mcp_servers,
});
if let Some(sp) = system_prompt {
params["_meta"] = serde_json::json!({ "systemPrompt": sp });
}
let result = self.send_request("session/new", params).await?;
let session_id = result["sessionId"]
.as_str()
.ok_or_else(|| AcpError::Protocol("session/new response missing sessionId".into()))?
.to_owned();
tracing::info!(target: "acp::session", "session created: {session_id}");
Ok(SessionNewResponse {
session_id,
raw: result,
})
}
/// Send `session/new` and return only the `sessionId` string.
///
/// Convenience wrapper around [`session_new_full`].
@@ -2068,4 +2096,67 @@ mod tests {
"systemPrompt should NOT be in params when value is None"
);
}
#[tokio::test]
async fn session_new_full_goose_includes_system_prompt_in_meta_when_some() {
// Goose path: systemPrompt must appear in _meta, NOT as a top-level field.
let script = r#"
read -t 2 _init
echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
read -t 2 REQ
echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_goose","_receivedRequest":'"$REQ"'}}'
sleep 1
"#;
let mut client = spawn_script(script).await;
client
.initialize()
.await
.expect("initialize should succeed");
let resp = client
.session_new_full_goose("/tmp", vec![], Some("Be concise."))
.await
.expect("session_new_full_goose should succeed");
assert_eq!(resp.session_id, "ses_goose");
let received = &resp.raw["_receivedRequest"];
assert_eq!(
received["params"]["_meta"]["systemPrompt"].as_str(),
Some("Be concise."),
"systemPrompt should be in _meta when Some"
);
assert!(
received["params"]["systemPrompt"].is_null(),
"systemPrompt should NOT appear as a top-level field"
);
}
#[tokio::test]
async fn session_new_full_goose_omits_meta_when_none() {
// Goose path: when system_prompt is None, _meta should not appear in params.
let script = r#"
read -t 2 _init
echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
read -t 2 REQ
echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_goose","_receivedRequest":'"$REQ"'}}'
sleep 1
"#;
let mut client = spawn_script(script).await;
client
.initialize()
.await
.expect("initialize should succeed");
let resp = client
.session_new_full_goose("/tmp", vec![], None)
.await
.expect("session_new_full_goose should succeed");
assert_eq!(resp.session_id, "ses_goose");
let received = &resp.raw["_receivedRequest"];
assert!(
received["params"]["_meta"].is_null(),
"_meta should NOT be in params when system_prompt is None"
);
}
}
+1 -1
View File
@@ -492,7 +492,7 @@ fn validate_allowlist(entries: &[String]) -> Result<HashSet<String>, ConfigError
Ok(validated)
}
fn normalize_agent_command_identity(command: &str) -> String {
pub(crate) fn normalize_agent_command_identity(command: &str) -> String {
let normalized = command.trim().replace('\\', "/");
let trimmed = normalized.trim_end_matches('/');
let basename = trimmed
+1
View File
@@ -1277,6 +1277,7 @@ async fn tokio_main() -> Result<()> {
.as_deref()
.and_then(|hex| nostr::PublicKey::from_hex(hex).ok()),
memory_enabled: config.memory_enabled,
agent_command: config.agent_command.clone(),
});
if !config.memory_enabled {
+23 -9
View File
@@ -32,7 +32,7 @@ use crate::acp::{
extract_model_config_options, extract_model_state, resolve_model_switch_method, AcpClient,
AcpError, McpServer, ModelSwitchMethod, StopReason,
};
use crate::config::{DedupMode, PermissionMode};
use crate::config::{normalize_agent_command_identity, DedupMode, PermissionMode};
use crate::observer;
use crate::queue::{
ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile,
@@ -250,6 +250,9 @@ pub struct PromptContext {
/// `[Agent Memory — core]` section. On by default; disabled via
/// `--no-memory` / `BUZZ_ACP_NO_MEMORY`.
pub memory_enabled: bool,
/// Agent command (binary name or path). Used to detect goose agents and
/// route `systemPrompt` via `_meta` rather than as a first-class field.
pub agent_command: String,
}
impl AgentPool {
@@ -438,14 +441,25 @@ async fn create_session_and_apply_model(
None
};
let resp = agent
.acp
.session_new_full(
&ctx.cwd,
ctx.mcp_servers.clone(),
combined_system_prompt.as_deref(),
)
.await?;
let resp = if normalize_agent_command_identity(&ctx.agent_command) == "goose" {
agent
.acp
.session_new_full_goose(
&ctx.cwd,
ctx.mcp_servers.clone(),
combined_system_prompt.as_deref(),
)
.await?
} else {
agent
.acp
.session_new_full(
&ctx.cwd,
ctx.mcp_servers.clone(),
combined_system_prompt.as_deref(),
)
.await?
};
// Populate model capabilities on first session creation.
if agent.model_capabilities.is_none() {