feat: steering messages with ACP (#9560)

Signed-off-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Michael Neale
2026-06-11 07:05:30 +10:00
committed by GitHub
parent fde407c9d3
commit 40315b5ac2
17 changed files with 1557 additions and 42 deletions
@@ -667,6 +667,11 @@ pub struct MessageMetadata {
pub agent_visible: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub inference: Option<InferenceMetadata>,
/// Whether this message is a steer injected into an active run. UI-only:
/// surfaced as `_meta.goose.steer` so clients can mark the steer boundary
/// without matching user-visible text. Never sent to providers.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub steer: bool,
}
impl Default for MessageMetadata {
@@ -675,6 +680,7 @@ impl Default for MessageMetadata {
user_visible: true,
agent_visible: true,
inference: None,
steer: false,
}
}
}
@@ -743,6 +749,11 @@ impl MessageMetadata {
self.inference = Some(inference);
self
}
pub fn with_steer(mut self) -> Self {
self.steer = true;
self
}
}
#[derive(ToSchema, Clone, PartialEq, Serialize, Deserialize, Debug)]
@@ -1028,6 +1039,11 @@ impl Message {
self
}
pub fn with_steer(mut self) -> Self {
self.metadata.steer = true;
self
}
pub fn with_inference_if_assistant(self, inference: InferenceMetadata) -> Self {
if self.role == Role::Assistant && self.metadata.inference.is_none() {
self.with_inference(inference)
+25 -1
View File
@@ -1,4 +1,4 @@
use agent_client_protocol::schema::McpServer;
use agent_client_protocol::schema::{ContentBlock, McpServer};
use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -140,6 +140,30 @@ pub struct SetSessionSystemPromptRequest {
pub text: String,
}
/// Add user input to the currently active prompt without starting a new prompt.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(
method = "_goose/unstable/session/steer",
response = SteerSessionResponse
)]
#[serde(rename_all = "camelCase")]
pub struct SteerSessionRequest {
pub session_id: String,
#[serde(default)]
pub prompt: Vec<ContentBlock>,
pub expected_run_id: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
pub struct SteerSessionResponse {
pub run_id: String,
/// Stable id of the queued steer message. The same id later appears as
/// `messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`),
/// letting clients correlate a queued steer with its pickup.
pub message_id: String,
}
/// Delete a session.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "session/delete", response = EmptyResponse)]
+5
View File
@@ -35,6 +35,11 @@
"requestType": "SetSessionSystemPromptRequest_unstable",
"responseType": "EmptyResponse"
},
{
"method": "_goose/unstable/session/steer",
"requestType": "SteerSessionRequest_unstable",
"responseType": "SteerSessionResponse_unstable"
},
{
"method": "session/delete",
"requestType": "DeleteSessionRequest",
+445
View File
@@ -210,6 +210,434 @@
],
"description": "How a session system prompt update should be applied."
},
"SteerSessionRequest_unstable": {
"type": "object",
"properties": {
"sessionId": {
"type": "string"
},
"prompt": {
"type": "array",
"items": {
"$ref": "#/$defs/ContentBlock"
},
"default": []
},
"expectedRunId": {
"type": "string"
}
},
"required": [
"sessionId",
"expectedRunId"
],
"description": "Add user input to the currently active prompt without starting a new prompt.",
"x-side": "agent",
"x-method": "_goose/unstable/session/steer"
},
"ContentBlock": {
"oneOf": [
{
"$ref": "#/$defs/TextContent",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "text"
}
},
"required": [
"type"
],
"description": "Text content. May be plain text or formatted with Markdown.\n\nAll agents MUST support text content blocks in prompts.\nClients SHOULD render this text as Markdown."
},
{
"$ref": "#/$defs/ImageContent",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "image"
}
},
"required": [
"type"
],
"description": "Images for visual context or analysis.\n\nRequires the `image` prompt capability when included in prompts."
},
{
"$ref": "#/$defs/AudioContent",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "audio"
}
},
"required": [
"type"
],
"description": "Audio data for transcription or analysis.\n\nRequires the `audio` prompt capability when included in prompts."
},
{
"$ref": "#/$defs/ResourceLink",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "resource_link"
}
},
"required": [
"type"
],
"description": "References to resources that the agent can access.\n\nAll agents MUST support resource links in prompts."
},
{
"$ref": "#/$defs/EmbeddedResource",
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "resource"
}
},
"required": [
"type"
],
"description": "Complete resource contents embedded directly in the message.\n\nPreferred for including context as it avoids extra round-trips.\n\nRequires the `embeddedContext` prompt capability when included in prompts."
}
],
"description": "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)",
"discriminator": {
"propertyName": "type"
}
},
"Annotations": {
"type": "object",
"properties": {
"audience": {
"type": [
"array",
"null"
],
"items": {
"$ref": "#/$defs/Role"
}
},
"lastModified": {
"type": [
"string",
"null"
]
},
"priority": {
"type": [
"number",
"null"
],
"format": "double"
},
"_meta": {
"type": [
"object",
"null"
],
"additionalProperties": {},
"description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)"
}
},
"description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed"
},
"Role": {
"type": "string",
"enum": [
"assistant",
"user"
],
"description": "The sender or recipient of messages and data in a conversation."
},
"TextContent": {
"type": "object",
"properties": {
"annotations": {
"anyOf": [
{
"$ref": "#/$defs/Annotations"
},
{
"type": "null"
}
]
},
"text": {
"type": "string"
},
"_meta": {
"type": [
"object",
"null"
],
"additionalProperties": {},
"description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)"
}
},
"required": [
"text"
],
"description": "Text provided to or from an LLM."
},
"ImageContent": {
"type": "object",
"properties": {
"annotations": {
"anyOf": [
{
"$ref": "#/$defs/Annotations"
},
{
"type": "null"
}
]
},
"data": {
"type": "string"
},
"mimeType": {
"type": "string"
},
"uri": {
"type": [
"string",
"null"
]
},
"_meta": {
"type": [
"object",
"null"
],
"additionalProperties": {},
"description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)"
}
},
"required": [
"data",
"mimeType"
],
"description": "An image provided to or from an LLM."
},
"AudioContent": {
"type": "object",
"properties": {
"annotations": {
"anyOf": [
{
"$ref": "#/$defs/Annotations"
},
{
"type": "null"
}
]
},
"data": {
"type": "string"
},
"mimeType": {
"type": "string"
},
"_meta": {
"type": [
"object",
"null"
],
"additionalProperties": {},
"description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)"
}
},
"required": [
"data",
"mimeType"
],
"description": "Audio provided to or from an LLM."
},
"ResourceLink": {
"type": "object",
"properties": {
"annotations": {
"anyOf": [
{
"$ref": "#/$defs/Annotations"
},
{
"type": "null"
}
]
},
"description": {
"type": [
"string",
"null"
]
},
"mimeType": {
"type": [
"string",
"null"
]
},
"name": {
"type": "string"
},
"size": {
"type": [
"integer",
"null"
]
},
"title": {
"type": [
"string",
"null"
]
},
"uri": {
"type": "string"
},
"_meta": {
"type": [
"object",
"null"
],
"additionalProperties": {},
"description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)"
}
},
"required": [
"name",
"uri"
],
"description": "A resource that the server is capable of reading, included in a prompt or tool call result."
},
"EmbeddedResourceResource": {
"anyOf": [
{
"$ref": "#/$defs/TextResourceContents"
},
{
"$ref": "#/$defs/BlobResourceContents"
}
],
"description": "Resource content that can be embedded in a message."
},
"TextResourceContents": {
"type": "object",
"properties": {
"mimeType": {
"type": [
"string",
"null"
]
},
"text": {
"type": "string"
},
"uri": {
"type": "string"
},
"_meta": {
"type": [
"object",
"null"
],
"additionalProperties": {},
"description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)"
}
},
"required": [
"text",
"uri"
],
"description": "Text-based resource contents."
},
"BlobResourceContents": {
"type": "object",
"properties": {
"blob": {
"type": "string"
},
"mimeType": {
"type": [
"string",
"null"
]
},
"uri": {
"type": "string"
},
"_meta": {
"type": [
"object",
"null"
],
"additionalProperties": {},
"description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)"
}
},
"required": [
"blob",
"uri"
],
"description": "Binary resource contents."
},
"EmbeddedResource": {
"type": "object",
"properties": {
"annotations": {
"anyOf": [
{
"$ref": "#/$defs/Annotations"
},
{
"type": "null"
}
]
},
"resource": {
"$ref": "#/$defs/EmbeddedResourceResource"
},
"_meta": {
"type": [
"object",
"null"
],
"additionalProperties": {},
"description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)"
}
},
"required": [
"resource"
],
"description": "The contents of a resource, embedded into a prompt or tool call result."
},
"SteerSessionResponse_unstable": {
"type": "object",
"properties": {
"runId": {
"type": "string"
},
"messageId": {
"type": "string",
"description": "Stable id of the queued steer message. The same id later appears as\n`messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`),\nletting clients correlate a queued steer with its pickup."
}
},
"required": [
"runId",
"messageId"
],
"x-side": "agent",
"x-method": "_goose/unstable/session/steer"
},
"DeleteSessionRequest": {
"type": "object",
"properties": {
@@ -3325,6 +3753,15 @@
"description": "Params for _goose/unstable/session/system-prompt/set",
"title": "SetSessionSystemPromptRequest_unstable"
},
{
"allOf": [
{
"$ref": "#/$defs/SteerSessionRequest_unstable"
}
],
"description": "Params for _goose/unstable/session/steer",
"title": "SteerSessionRequest_unstable"
},
{
"allOf": [
{
@@ -3855,6 +4292,14 @@
],
"title": "ReadResourceResponse_unstable"
},
{
"allOf": [
{
"$ref": "#/$defs/SteerSessionResponse_unstable"
}
],
"title": "SteerSessionResponse_unstable"
},
{
"allOf": [
{
+290 -37
View File
@@ -79,6 +79,7 @@ use tokio_util::compat::{TokioAsyncReadCompatExt as _, TokioAsyncWriteCompatExt
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use url::Url;
use uuid::Uuid;
mod config;
mod custom_dispatch;
@@ -170,6 +171,7 @@ struct GooseAcpSession {
/// Idempotence guard so we summarize each chain at most once.
summarized_chains: HashSet<String>,
cancel_token: Option<CancellationToken>,
active_run_id: Option<String>,
}
/// A run of consecutive ToolRequest blocks within one assistant message,
@@ -1189,6 +1191,7 @@ impl GooseAcpAgent {
responded_tool_ids: HashSet::new(),
summarized_chains: HashSet::new(),
cancel_token: None,
active_run_id: None,
};
self.sessions.lock().await.insert(session_id, acp_session);
}
@@ -1286,19 +1289,22 @@ impl GooseAcpAgent {
session_id_str: &str,
message_id: Option<&str>,
message_created: i64,
role: &Role,
steer: bool,
agent: &Arc<Agent>,
session: &mut GooseAcpSession,
cx: &ConnectionTo<Client>,
) -> Result<(), agent_client_protocol::Error> {
match content_item {
MessageContent::Text(text) => {
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::AgentMessageChunk(
ContentChunk::new(ContentBlock::Text(TextContent::new(text.text.clone())))
.meta(message_update_meta(message_id, message_created)),
),
))?;
let chunk =
ContentChunk::new(ContentBlock::Text(TextContent::new(text.text.clone())))
.meta(message_update_meta(message_id, message_created, steer));
let update = match role {
Role::User => SessionUpdate::UserMessageChunk(chunk),
Role::Assistant => SessionUpdate::AgentMessageChunk(chunk),
};
cx.send_notification(SessionNotification::new(session_id.clone(), update))?;
}
MessageContent::ToolRequest(tool_request) => {
self.handle_tool_request(
@@ -1329,7 +1335,11 @@ impl GooseAcpAgent {
ContentChunk::new(ContentBlock::Text(TextContent::new(
thinking.thinking.clone(),
)))
.meta(message_update_meta(message_id, message_created)),
.meta(message_update_meta(
message_id,
message_created,
steer,
)),
),
))?;
}
@@ -1995,15 +2005,18 @@ fn send_elicitation_interaction_update(
}
fn interaction_update_meta(message_id: Option<&str>, created: i64) -> serde_json::Value {
serde_json::Value::Object(message_update_meta(message_id, created))
serde_json::Value::Object(message_update_meta(message_id, created, false))
}
fn message_update_meta(message_id: Option<&str>, created: i64) -> Meta {
fn message_update_meta(message_id: Option<&str>, created: i64, steer: bool) -> Meta {
let mut goose = serde_json::Map::new();
goose.insert("created".to_string(), serde_json::json!(created));
if let Some(id) = message_id {
goose.insert("messageId".to_string(), serde_json::json!(id));
}
if steer {
goose.insert("steer".to_string(), serde_json::json!(true));
}
let mut meta = serde_json::Map::new();
meta.insert("goose".to_string(), serde_json::Value::Object(goose));
@@ -2040,6 +2053,9 @@ fn replay_message_goose_meta(message: &Message) -> serde_json::Map<String, serde
if let Some(id) = &message.id {
goose.insert("messageId".to_string(), serde_json::json!(id));
}
if message.metadata.steer {
goose.insert("steer".to_string(), serde_json::json!(true));
}
goose
}
@@ -2208,6 +2224,127 @@ impl GooseAcpAgent {
Ok(agent)
}
async fn start_active_run(
&self,
session_id: &str,
run_id: String,
cancel_token: CancellationToken,
) -> Result<(), agent_client_protocol::Error> {
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(session_id).ok_or_else(|| {
agent_client_protocol::Error::resource_not_found(Some(session_id.to_string()))
.data(format!("Session not found: {}", session_id))
})?;
if let Some(active_run_id) = &session.active_run_id {
return Err(agent_client_protocol::Error::invalid_params().data(format!(
"session already has active run `{active_run_id}`; use _goose/unstable/session/steer"
)));
}
session.cancel_token = Some(cancel_token);
session.active_run_id = Some(run_id);
Ok(())
}
async fn clear_active_run(&self, session_id: &str, run_id: &str) {
let agent = {
let mut sessions = self.sessions.lock().await;
let Some(session) = sessions.get_mut(session_id) else {
return;
};
if session.active_run_id.as_deref() != Some(run_id) {
return;
}
session.cancel_token = None;
session.active_run_id = None;
session.agent.clone()
};
agent.discard_pending_steers(session_id).await;
}
async fn require_active_run(
&self,
session_id: &str,
expected_run_id: &str,
) -> Result<String, agent_client_protocol::Error> {
if expected_run_id.is_empty() {
return Err(agent_client_protocol::Error::invalid_params()
.data("expectedRunId must not be empty"));
}
let sessions = self.sessions.lock().await;
let session = sessions.get(session_id).ok_or_else(|| {
agent_client_protocol::Error::resource_not_found(Some(session_id.to_string()))
.data(format!("Session not found: {}", session_id))
})?;
let active_run_id = session.active_run_id.as_ref().ok_or_else(|| {
agent_client_protocol::Error::invalid_params().data("no active run to steer")
})?;
if active_run_id != expected_run_id {
return Err(
agent_client_protocol::Error::invalid_params().data(serde_json::json!({
"message": format!(
"expected active run id `{expected_run_id}` but found `{active_run_id}`"
),
"expectedRunId": expected_run_id,
"actualRunId": active_run_id,
})),
);
}
Ok(active_run_id.clone())
}
fn active_run_meta(active_run_id: Option<&str>) -> Meta {
let mut goose = serde_json::Map::new();
goose.insert(
"activeRunId".to_string(),
active_run_id
.map(|run_id| serde_json::Value::String(run_id.to_string()))
.unwrap_or(serde_json::Value::Null),
);
let mut meta = serde_json::Map::new();
meta.insert("goose".to_string(), serde_json::Value::Object(goose));
meta
}
fn send_active_run_update(
cx: &ConnectionTo<Client>,
session_id: &SessionId,
active_run_id: Option<&str>,
) -> Result<(), agent_client_protocol::Error> {
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::SessionInfoUpdate(
SessionInfoUpdate::new().meta(Self::active_run_meta(active_run_id)),
),
))
}
fn send_queued_steer_update(
cx: &ConnectionTo<Client>,
session_id: &SessionId,
message_id: &str,
run_id: &str,
) -> Result<(), agent_client_protocol::Error> {
let mut goose = serde_json::Map::new();
goose.insert(
"queuedSteer".to_string(),
serde_json::json!({
"messageId": message_id,
"runId": run_id,
}),
);
let mut meta = serde_json::Map::new();
meta.insert("goose".to_string(), serde_json::Value::Object(goose));
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::SessionInfoUpdate(SessionInfoUpdate::new().meta(meta)),
))
}
#[allow(dead_code)]
async fn add_mcp_extensions(
agent: &Arc<Agent>,
@@ -2263,10 +2400,23 @@ impl GooseAcpAgent {
let sid = sid_short(&session_id);
let t_start = std::time::Instant::now();
let run_id = format!("run_{}", Uuid::new_v4());
let cancel_token = CancellationToken::new();
let agent = self
.get_session_agent(&session_id, Some(cancel_token.clone()))
self.start_active_run(&session_id, run_id.clone(), cancel_token.clone())
.await?;
if let Err(error) = Self::send_active_run_update(cx, &args.session_id, Some(&run_id)) {
self.clear_active_run(&session_id, &run_id).await;
return Err(error);
}
let agent = match self.get_session_agent(&session_id, None).await {
Ok(agent) => agent,
Err(error) => {
self.clear_active_run(&session_id, &run_id).await;
let _ = Self::send_active_run_update(cx, &args.session_id, None);
return Err(error);
}
};
let user_message = Self::convert_acp_prompt_to_message(&args.prompt);
@@ -2281,7 +2431,7 @@ impl GooseAcpAgent {
)
{
if recipe_path.exists() {
cx.send_notification(SessionNotification::new(
if let Err(error) = cx.send_notification(SessionNotification::new(
args.session_id.clone(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(
ContentBlock::Text(TextContent::new(format!(
@@ -2289,7 +2439,11 @@ impl GooseAcpAgent {
full_command
))),
)),
))?;
)) {
self.clear_active_run(&session_id, &run_id).await;
let _ = Self::send_active_run_update(cx, &args.session_id, None);
return Err(error);
}
}
}
}
@@ -2302,10 +2456,18 @@ impl GooseAcpAgent {
retry_config: None,
};
let mut stream = agent
let mut stream = match agent
.reply(user_message, session_config, Some(cancel_token.clone()))
.await
.internal_err_ctx("Error getting agent reply")?;
{
Ok(stream) => stream,
Err(error) => {
self.clear_active_run(&session_id, &run_id).await;
let _ = Self::send_active_run_update(cx, &args.session_id, None);
return Err(agent_client_protocol::Error::internal_error()
.data(format!("Error getting agent reply: {error}")));
}
};
let mut was_cancelled = false;
let mut first_event_logged = false;
@@ -2321,6 +2483,7 @@ impl GooseAcpAgent {
// `handle_tool_response` finds the chain when subsequent responses
// are processed.
let mut chain_buffer: Vec<(String, String)> = Vec::new();
let mut stream_error = None;
while let Some(event) = stream.next().await {
if cancel_token.is_cancelled() {
@@ -2344,15 +2507,18 @@ impl GooseAcpAgent {
let stored_message_id = message.id.clone();
let mut sessions = self.sessions.lock().await;
let session = sessions.get_mut(&session_id).ok_or_else(|| {
agent_client_protocol::Error::invalid_params()
.data(format!("Session not found: {}", session_id))
})?;
let Some(session) = sessions.get_mut(&session_id) else {
stream_error = Some(
agent_client_protocol::Error::invalid_params()
.data(format!("Session not found: {}", session_id)),
);
break;
};
for content_item in &message.content {
if let Some(error) = prompt_error_from_message_content(content_item) {
session.cancel_token = None;
return Err(error);
stream_error = Some(error);
break;
}
match content_item {
@@ -2382,23 +2548,36 @@ impl GooseAcpAgent {
}
}
self.handle_message_content(
content_item,
&args.session_id,
&session_id,
stored_message_id.as_deref(),
message.created,
&agent,
session,
cx,
)
.await?;
if let Err(error) = self
.handle_message_content(
content_item,
&args.session_id,
&session_id,
stored_message_id.as_deref(),
message.created,
&message.role,
message.metadata.steer,
&agent,
session,
cx,
)
.await
{
stream_error = Some(error);
break;
}
}
if stream_error.is_some() {
break;
}
}
Ok(_) => {}
Err(e) => {
return Err(agent_client_protocol::Error::internal_error()
.data(format!("Error in agent response stream: {}", e)));
stream_error = Some(
agent_client_protocol::Error::internal_error()
.data(format!("Error in agent response stream: {}", e)),
);
break;
}
}
}
@@ -2411,9 +2590,13 @@ impl GooseAcpAgent {
// registered. (Eager registration during the loop usually
// covers this.)
extend_chain_membership(&chain_buffer, &mut session.chain_membership);
session.cancel_token = None;
}
}
self.clear_active_run(&session_id, &run_id).await;
Self::send_active_run_update(cx, &args.session_id, None)?;
if let Some(error) = stream_error {
return Err(error);
}
let session = self
.session_manager
@@ -2454,6 +2637,48 @@ impl GooseAcpAgent {
Ok(response)
}
async fn on_steer_session(
&self,
req: SteerSessionRequest,
) -> Result<SteerSessionResponse, agent_client_protocol::Error> {
if req.prompt.is_empty() {
return Err(
agent_client_protocol::Error::invalid_params().data("prompt must not be empty")
);
}
self.require_active_run(&req.session_id, &req.expected_run_id)
.await?;
let agent = self.get_session_agent(&req.session_id, None).await?;
let active_run_id = self
.require_active_run(&req.session_id, &req.expected_run_id)
.await?;
let message = Self::convert_acp_prompt_to_message(&req.prompt);
if message.content.is_empty() {
return Err(agent_client_protocol::Error::invalid_params()
.data("prompt must contain steerable content"));
}
let message_id = format!("steer_{}", Uuid::new_v4());
let message = message.with_id(message_id.clone());
agent.steer(&req.session_id, message).await;
if let Some(cx) = self.client_cx.get() {
let _ = Self::send_queued_steer_update(
cx,
&SessionId::new(req.session_id.clone()),
&message_id,
&active_run_id,
);
}
Ok(SteerSessionResponse {
run_id: active_run_id,
message_id,
})
}
async fn on_cancel(
&self,
args: CancelNotification,
@@ -3382,9 +3607,37 @@ print(\"hello, world\")
);
}
#[test]
fn test_merge_replay_message_meta_includes_steer_marker() {
let message = Message::new(Role::User, 1_700_000_000, vec![])
.with_id("msg_steer")
.with_steer();
let merged = merge_replay_message_meta(None, &message);
assert_eq!(
merged.get("goose"),
Some(&serde_json::json!({
"created": 1_700_000_000,
"messageId": "msg_steer",
"steer": true,
})),
"replay must carry the steer marker so the boundary survives reload"
);
}
#[test]
fn test_merge_replay_message_meta_omits_steer_when_not_set() {
let message = Message::new(Role::Assistant, 1_700_000_000, vec![]).with_id("msg_plain");
let merged = merge_replay_message_meta(None, &message);
assert_eq!(merged.get("goose").and_then(|g| g.get("steer")), None);
}
#[test]
fn test_message_update_meta_includes_created_and_message_id() {
let meta = message_update_meta(Some("msg_live"), 1_700_000_000);
let meta = message_update_meta(Some("msg_live"), 1_700_000_000, false);
assert_eq!(
meta.get("goose"),
@@ -67,6 +67,14 @@ impl GooseAcpAgent {
self.on_set_session_system_prompt(req).await
}
#[custom_method(SteerSessionRequest)]
async fn dispatch_steer_session(
&self,
req: SteerSessionRequest,
) -> Result<SteerSessionResponse, agent_client_protocol::Error> {
self.on_steer_session(req).await
}
#[custom_method(DeleteSessionRequest)]
async fn dispatch_delete_session(
&self,
+82 -1
View File
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::fmt;
use std::future::Future;
use std::pin::Pin;
@@ -247,6 +247,7 @@ pub struct Agent {
container: Mutex<Option<Container>>,
goal: Mutex<Option<String>>,
grind: Mutex<Option<String>>,
pending_steers: Mutex<HashMap<String, VecDeque<Message>>>,
}
#[derive(Clone, Debug)]
@@ -368,6 +369,7 @@ impl Agent {
container: Mutex::new(None),
goal: Mutex::new(None),
grind: Mutex::new(None),
pending_steers: Mutex::new(HashMap::new()),
}
}
@@ -403,6 +405,36 @@ impl Agent {
.await;
}
pub async fn steer(&self, session_id: &str, message: Message) {
self.pending_steers
.lock()
.await
.entry(session_id.to_string())
.or_default()
.push_back(message);
}
pub async fn discard_pending_steers(&self, session_id: &str) {
self.pending_steers.lock().await.remove(session_id);
}
async fn has_pending_steers(&self, session_id: &str) -> bool {
self.pending_steers
.lock()
.await
.get(session_id)
.is_some_and(|messages| !messages.is_empty())
}
async fn drain_pending_steers(&self, session_id: &str) -> Vec<Message> {
self.pending_steers
.lock()
.await
.remove(session_id)
.map(|messages| messages.into_iter().map(Message::with_steer).collect())
.unwrap_or_default()
}
async fn emit_pre_tool_extended_hooks(
&self,
tool_name: &str,
@@ -1703,12 +1735,35 @@ impl Agent {
let mut retrying_after_stop_hook_denial = false;
let mut consecutive_stop_hook_blocks = 0u32;
let stop_hook_block_cap = self.stop_hook_block_cap();
let mut can_drain_pending_steers = false;
loop {
if is_token_cancelled(&cancel_token) {
break;
}
if can_drain_pending_steers {
for message in self.drain_pending_steers(&session_config.id).await {
let message_text = message.as_concat_text();
if self
.hook_manager
.has_hooks(crate::hooks::HookEvent::UserPromptSubmit)
{
let ctx = crate::hooks::HookContext::new(
crate::hooks::HookEvent::UserPromptSubmit,
&session_config.id,
)
.with_message(message_text);
self.hook_manager
.emit(crate::hooks::HookEvent::UserPromptSubmit, ctx)
.await;
}
session_manager.add_message(&session_config.id, &message).await?;
conversation.push(message.clone());
yield AgentEvent::Message(message);
}
}
let final_output = {
let mut guard = self.final_output_tool.lock().await;
guard.as_mut().and_then(|fot| fot.final_output.take())
@@ -2213,6 +2268,8 @@ impl Agent {
}
}
}
can_drain_pending_steers = true;
if tools_updated {
(tools, toolshim_tools, system_prompt) =
self.prepare_tools_and_prompt(&session_config.id, &session.working_dir).await?;
@@ -2252,6 +2309,7 @@ impl Agent {
None if did_recovery_compact_this_iteration => {
// continue from last user message after recovery compact
}
None if self.has_pending_steers(&session_config.id).await => {}
None if self.goal.lock().await.is_some() && !goal_check_pending => {
goal_check_pending = true;
let goal = self.goal.lock().await.clone().unwrap();
@@ -2375,6 +2433,10 @@ impl Agent {
}
conversation.extend(messages_to_add);
if exit_chat && self.has_pending_steers(&session_config.id).await {
exit_chat = false;
}
if exit_chat {
let ctx = crate::hooks::HookContext::new(
crate::hooks::HookEvent::Stop,
@@ -3412,6 +3474,25 @@ exit 0
Ok(())
}
#[tokio::test]
async fn discard_pending_steers_clears_queued_messages() {
let agent = Agent::new();
let session_id = "session-discard";
agent
.steer(session_id, Message::user().with_text("queued steer"))
.await;
assert!(agent.has_pending_steers(session_id).await);
agent.discard_pending_steers(session_id).await;
assert!(
!agent.has_pending_steers(session_id).await,
"discarding must drop steers orphaned by a cancelled run so they cannot leak into a later prompt"
);
assert!(agent.drain_pending_steers(session_id).await.is_empty());
}
#[test]
fn categorize_tool_recognizes_conventional_names() {
assert_eq!(categorize_tool("developer__shell"), ToolCategory::Shell);
@@ -2,6 +2,9 @@
#[path = "acp_common_tests/mod.rs"]
mod common_tests;
use agent_client_protocol::schema::{
ContentBlock, PromptRequest, SessionUpdate, StopReason, TextContent,
};
use common_tests::fixtures::server::AcpServerConnection;
use common_tests::fixtures::{
run_test, send_custom, Connection, PermissionDecision, Session, SessionData,
@@ -15,6 +18,7 @@ use goose_test_support::{EnforceSessionId, IgnoreSessionId};
use serial_test::serial;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use common_tests::fixtures::OpenAiFixture;
@@ -78,6 +82,88 @@ impl Provider for MockProvider {
}
}
fn active_run_id_from_update(update: &SessionUpdate) -> Option<String> {
let SessionUpdate::SessionInfoUpdate(info) = update else {
return None;
};
info.meta
.as_ref()?
.get("goose")?
.get("activeRunId")?
.as_str()
.map(ToString::to_string)
}
fn queued_steer_message_ids(updates: &[SessionUpdate]) -> Vec<String> {
updates
.iter()
.filter_map(|update| {
let SessionUpdate::SessionInfoUpdate(info) = update else {
return None;
};
info.meta
.as_ref()?
.get("goose")?
.get("queuedSteer")?
.get("messageId")?
.as_str()
.map(ToString::to_string)
})
.collect()
}
fn steer_chunk_message_ids(updates: &[SessionUpdate]) -> Vec<String> {
updates
.iter()
.filter_map(|update| {
let SessionUpdate::UserMessageChunk(chunk) = update else {
return None;
};
let goose = chunk.meta.as_ref()?.get("goose")?;
goose.get("steer")?.as_bool().filter(|b| *b)?;
goose.get("messageId")?.as_str().map(ToString::to_string)
})
.collect()
}
fn steer_chunk_texts(updates: &[SessionUpdate]) -> Vec<String> {
updates
.iter()
.filter_map(|update| {
// A steered message is a user message injected mid-run, so it must
// arrive as a UserMessageChunk (matching the replay path), never an
// AgentMessageChunk.
let SessionUpdate::UserMessageChunk(chunk) = update else {
return None;
};
let ContentBlock::Text(text) = &chunk.content else {
return None;
};
let is_steer = chunk
.meta
.as_ref()
.and_then(|m| m.get("goose"))
.and_then(|g| g.get("steer"))
.and_then(|s| s.as_bool())
.unwrap_or(false);
is_steer.then(|| text.text.clone())
})
.collect()
}
fn collect_agent_text(updates: &[SessionUpdate]) -> String {
updates
.iter()
.filter_map(|update| match update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => Some(text.text.as_str()),
_ => None,
},
_ => None,
})
.collect()
}
#[test]
#[serial]
fn test_custom_get_tools() {
@@ -273,6 +359,121 @@ fn test_custom_get_available_extensions() {
});
}
#[test]
#[serial]
fn test_steer_session_adds_input_to_active_prompt() {
write_acp_global_config(DEFAULT_ACP_TEST_CONFIG);
run_test(async move {
// Two-turn exchange: the first turn ends the turn with plain text. A
// steer queued before the turn ends keeps the loop alive (it flips
// `exit_chat` back to false), so a second provider request fires whose
// body must now contain the steered text.
let openai = OpenAiFixture::new(
vec![
(
"start work".to_string(),
include_str!("acp_test_data/openai_steer_first.txt"),
),
(
"steer while active".to_string(),
include_str!("acp_test_data/openai_steer_second.txt"),
),
],
Arc::new(IgnoreSessionId),
)
.await;
let mut conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let SessionData { session, .. } = conn.new_session().await.unwrap();
let session_id = session.session_id().0.to_string();
let acp_session_id = session.session_id().clone();
let mut prompt = Box::pin(
conn.cx()
.send_request(PromptRequest::new(
acp_session_id,
vec![ContentBlock::Text(TextContent::new("start work"))],
))
.block_task(),
);
let mut steer_sent = false;
let mut steer_message_id: Option<String> = None;
let mut final_response = None;
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
tokio::select! {
response = &mut prompt => {
final_response = Some(response.unwrap());
break;
}
_ = tokio::time::sleep(Duration::from_millis(10)), if !steer_sent => {
let updates = session.session_updates();
if let Some(run_id) = updates.iter().find_map(active_run_id_from_update) {
let response = send_custom(
conn.cx(),
"_goose/unstable/session/steer",
serde_json::json!({
"sessionId": session_id,
"expectedRunId": run_id,
"prompt": [
{ "type": "text", "text": "steer while active" }
]
}),
)
.await
.unwrap();
assert_eq!(response["runId"], run_id);
let mid = response["messageId"].as_str();
assert!(
mid.is_some_and(|id| !id.is_empty()),
"steer response must return a messageId for correlation, got: {response:?}"
);
steer_message_id = mid.map(ToString::to_string);
steer_sent = true;
}
}
}
}
let response = final_response.expect("prompt did not complete");
assert_eq!(response.stop_reason, StopReason::EndTurn);
assert!(steer_sent, "test never observed an active run id");
let updates = session.session_updates();
let agent_text = collect_agent_text(&updates);
assert!(
agent_text.contains("saw steer"),
"expected provider to receive steered input, got: {agent_text:?}"
);
// The echoed steer prompt must be marked structurally so the client
// can locate the boundary without matching user-visible text.
let steer_chunks = steer_chunk_texts(&updates);
assert!(
steer_chunks
.iter()
.any(|t| t.contains("steer while active")),
"expected a chunk marked _meta.goose.steer with the steer text, got: {steer_chunks:?}"
);
// The queued steer must be announced (so a UI can show it as pending)
// and carry the same messageId returned by the steer response and later
// stamped on the picked-up UserMessageChunk.
let steer_message_id = steer_message_id.expect("steer response had no messageId");
let queued_ids = queued_steer_message_ids(&updates);
assert!(
queued_ids.contains(&steer_message_id),
"expected a queuedSteer SessionInfoUpdate with messageId {steer_message_id:?}, got: {queued_ids:?}"
);
let picked_up_ids = steer_chunk_message_ids(&updates);
assert!(
picked_up_ids.contains(&steer_message_id),
"picked-up steer chunk must carry the queued messageId {steer_message_id:?} for correlation, got: {picked_up_ids:?}"
);
});
}
#[test]
#[serial]
fn test_custom_list_builtin_skill_sources() {
+7
View File
@@ -302,6 +302,13 @@ pub fn to_notifications(updates: &[SessionUpdate]) -> Vec<Notification> {
SessionUpdate::ConfigOptionUpdate(_) => out.push(Notification::ConfigOption),
SessionUpdate::SessionInfoUpdate(update) => {
let meta = update.meta.as_ref();
let is_active_run_update = meta
.and_then(|m| m.get("goose"))
.and_then(|g| g.get("activeRunId"))
.is_some();
if is_active_run_update {
continue;
}
out.push(Notification::SessionInfoUpdate {
title: update.title.value().cloned(),
updated_at: update.updated_at.value().cloned(),
@@ -0,0 +1,9 @@
data: {"id":"chatcmpl-steer1","object":"chat.completion.chunk","created":1766229303,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-steer1","object":"chat.completion.chunk","created":1766229303,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"first response"},"finish_reason":null}]}
data: {"id":"chatcmpl-steer1","object":"chat.completion.chunk","created":1766229303,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-steer1","object":"chat.completion.chunk","created":1766229303,"model":"gpt-4o","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":10,"total_tokens":110}}
data: [DONE]
@@ -0,0 +1,9 @@
data: {"id":"chatcmpl-steer2","object":"chat.completion.chunk","created":1766229304,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-steer2","object":"chat.completion.chunk","created":1766229304,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"saw steer"},"finish_reason":null}]}
data: {"id":"chatcmpl-steer2","object":"chat.completion.chunk","created":1766229304,"model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-steer2","object":"chat.completion.chunk","created":1766229304,"model":"gpt-4o","choices":[],"usage":{"prompt_tokens":120,"completion_tokens":10,"total_tokens":130}}
data: [DONE]
+4
View File
@@ -6571,6 +6571,10 @@
],
"nullable": true
},
"steer": {
"type": "boolean",
"description": "Whether this message is a steer injected into an active run. UI-only:\nsurfaced as `_meta.goose.steer` so clients can mark the steer boundary\nwithout matching user-visible text. Never sent to providers."
},
"userVisible": {
"type": "boolean",
"description": "Whether the message should be visible to the user in the UI"
+6
View File
@@ -770,6 +770,12 @@ export type MessageMetadata = {
*/
agentVisible: boolean;
inference?: InferenceMetadata | null;
/**
* Whether this message is a steer injected into an active run. UI-only:
* surfaced as `_meta.goose.steer` so clients can mark the steer boundary
* without matching user-visible text. Never sent to providers.
*/
steer?: boolean;
/**
* Whether the message should be visible to the user in the UI
*/
+15
View File
@@ -98,6 +98,8 @@ import type {
RenameSessionRequest_unstable,
SetConfigExtensionEnabledRequest_unstable,
SetSessionSystemPromptRequest_unstable,
SteerSessionRequest_unstable,
SteerSessionResponse_unstable,
UnarchiveSessionRequest_unstable,
UpdateSessionProjectRequest_unstable,
UpdateSourceRequest_unstable,
@@ -139,6 +141,7 @@ import {
zProviderSupportedModelsListResponse_unstable,
zReadResourceResponse_unstable,
zRefreshProviderInventoryResponse_unstable,
zSteerSessionResponse_unstable,
zUpdateSourceResponse_unstable,
} from './zod.gen.js';
@@ -206,6 +209,18 @@ export class GooseExtClient {
);
}
async sessionSteer_unstable(
params: SteerSessionRequest_unstable,
): Promise<SteerSessionResponse_unstable> {
const raw = await this.conn.extMethod(
"_goose/unstable/session/steer",
params,
);
return zSteerSessionResponse_unstable.parse(
raw,
) as SteerSessionResponse_unstable;
}
async sessionDelete(params: DeleteSessionRequest): Promise<void> {
await this.conn.extMethod("session/delete", params);
}
+6 -1
View File
@@ -1,6 +1,6 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, ArchiveSessionRequest_unstable, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, ElicitationRespondRequest_unstable, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js';
export type { AddConfigExtensionRequest_unstable, AddExtensionRequest_unstable, Annotations, ArchiveSessionRequest_unstable, AudioContent, BlobResourceContents, ContentBlock, CreateSourceRequest_unstable, CreateSourceResponse_unstable, CustomProviderConfigDto, CustomProviderCreateRequest_unstable, CustomProviderCreateResponse_unstable, CustomProviderDeleteRequest_unstable, CustomProviderDeleteResponse_unstable, CustomProviderReadRequest_unstable, CustomProviderReadResponse_unstable, CustomProviderUpdateRequest_unstable, CustomProviderUpdateResponse_unstable, DefaultsReadRequest_unstable, DefaultsReadResponse_unstable, DefaultsSaveRequest_unstable, DeleteSessionRequest, DeleteSourceRequest_unstable, DictationConfigRequest_unstable, DictationConfigResponse_unstable, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest_unstable, DictationModelDeleteRequest_unstable, DictationModelDownloadProgressRequest_unstable, DictationModelDownloadProgressResponse_unstable, DictationModelDownloadRequest_unstable, DictationModelOption, DictationModelSelectRequest_unstable, DictationModelsListRequest_unstable, DictationModelsListResponse_unstable, DictationProviderStatusEntry, DictationSecretDeleteRequest_unstable, DictationSecretSaveRequest_unstable, DictationTranscribeRequest_unstable, DictationTranscribeResponse_unstable, ElicitationRespondRequest_unstable, EmbeddedResource, EmbeddedResourceResource, EmptyResponse, EnvVariable, ExportSessionRequest_unstable, ExportSessionResponse_unstable, ExportSourceRequest_unstable, ExportSourceResponse_unstable, ExtNotification, ExtRequest, ExtResponse, GetAvailableExtensionsRequest_unstable, GetAvailableExtensionsResponse_unstable, GetConfigExtensionsRequest_unstable, GetConfigExtensionsResponse_unstable, GetSessionExtensionsRequest_unstable, GetSessionExtensionsResponse_unstable, GetToolsRequest_unstable, GetToolsResponse_unstable, GooseExtension, GooseExtensionEntry, GooseSessionNotification_unstable, GooseSessionUpdate, GooseToolCallRequest_unstable, GooseToolCallResponse_unstable, HttpHeader, ImageContent, ImportSessionRequest_unstable, ImportSessionResponse_unstable, ImportSourcesRequest_unstable, ImportSourcesResponse_unstable, Interaction, InteractionState, InteractionUpdate, ListProvidersRequest_unstable, ListProvidersResponse_unstable, ListSourcesRequest_unstable, ListSourcesResponse_unstable, McpServer, McpServerHttp, McpServerSse, McpServerStdio, OnboardingImportApplyRequest_unstable, OnboardingImportApplyResponse_unstable, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest_unstable, OnboardingImportScanResponse_unstable, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest_unstable, PreferencesReadResponse_unstable, PreferencesRemoveRequest_unstable, PreferencesSaveRequest_unstable, PreferenceValue, ProviderCatalogListRequest_unstable, ProviderCatalogListResponse_unstable, ProviderCatalogTemplateRequest_unstable, ProviderCatalogTemplateResponse_unstable, ProviderConfigAuthenticateRequest_unstable, ProviderConfigChangeResponse_unstable, ProviderConfigDeleteRequest_unstable, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest_unstable, ProviderConfigReadResponse_unstable, ProviderConfigSaveRequest_unstable, ProviderConfigStatusDto, ProviderConfigStatusRequest_unstable, ProviderConfigStatusResponse_unstable, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest_unstable, ProviderSetupCatalogListResponse_unstable, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderSupportedModelsListRequest_unstable, ProviderSupportedModelsListResponse_unstable, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest_unstable, ReadResourceResponse_unstable, RefreshProviderInventoryRequest_unstable, RefreshProviderInventoryResponse_unstable, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest_unstable, RemoveExtensionRequest_unstable, RenameSessionRequest_unstable, ResourceLink, Role, SessionSystemPromptMode, SessionUsageUpdate, SetConfigExtensionEnabledRequest_unstable, SetSessionSystemPromptRequest_unstable, SourceEntry, SourceScope, SourceType, StatusMessage, StatusMessageUpdate, SteerSessionRequest_unstable, SteerSessionResponse_unstable, TextContent, TextResourceContents, UnarchiveSessionRequest_unstable, UpdateSessionProjectRequest_unstable, UpdateSourceRequest_unstable, UpdateSourceResponse_unstable, UpdateWorkingDirRequest_unstable } from './types.gen.js';
export const GOOSE_EXT_METHODS = [
{
@@ -38,6 +38,11 @@ export const GOOSE_EXT_METHODS = [
requestType: "SetSessionSystemPromptRequest_unstable",
responseType: "EmptyResponse",
},
{
method: "_goose/unstable/session/steer",
requestType: "SteerSessionRequest_unstable",
responseType: "SteerSessionResponse_unstable",
},
{
method: "session/delete",
requestType: "DeleteSessionRequest",
+214 -2
View File
@@ -109,6 +109,218 @@ export type SetSessionSystemPromptRequest_unstable = {
*/
export type SessionSystemPromptMode = 'set' | 'append';
/**
* Add user input to the currently active prompt without starting a new prompt.
*/
export type SteerSessionRequest_unstable = {
sessionId: string;
prompt?: Array<ContentBlock>;
expectedRunId: string;
};
/**
* Content blocks represent displayable information in the Agent Client Protocol.
*
* They provide a structured way to handle various types of user-facing content—whether
* it's text from language models, images for analysis, or embedded resources for context.
*
* Content blocks appear in:
* - User prompts sent via `session/prompt`
* - Language model output streamed through `session/update` notifications
* - Progress updates and results from tool calls
*
* This structure is compatible with the Model Context Protocol (MCP), enabling
* agents to seamlessly forward content from MCP tool outputs without transformation.
*
* See protocol docs: [Content](https://agentclientprotocol.com/protocol/content)
*/
export type ContentBlock = ({
type: 'TextContent';
} & TextContent) | ({
type: 'ImageContent';
} & ImageContent) | ({
type: 'AudioContent';
} & AudioContent) | ({
type: 'ResourceLink';
} & ResourceLink) | ({
type: 'EmbeddedResource';
} & EmbeddedResource);
/**
* Optional annotations for the client. The client can use annotations to inform how objects are used or displayed
*/
export type Annotations = {
audience?: Array<Role> | null;
lastModified?: string | null;
priority?: number | null;
/**
* The _meta property is reserved by ACP to allow clients and agents to attach additional
* metadata to their interactions. Implementations MUST NOT make assumptions about values at
* these keys.
*
* See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
*/
_meta?: {
[key: string]: unknown;
} | null;
};
/**
* The sender or recipient of messages and data in a conversation.
*/
export type Role = 'assistant' | 'user';
/**
* Text provided to or from an LLM.
*/
export type TextContent = {
annotations?: Annotations | null;
text: string;
/**
* The _meta property is reserved by ACP to allow clients and agents to attach additional
* metadata to their interactions. Implementations MUST NOT make assumptions about values at
* these keys.
*
* See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
*/
_meta?: {
[key: string]: unknown;
} | null;
};
/**
* An image provided to or from an LLM.
*/
export type ImageContent = {
annotations?: Annotations | null;
data: string;
mimeType: string;
uri?: string | null;
/**
* The _meta property is reserved by ACP to allow clients and agents to attach additional
* metadata to their interactions. Implementations MUST NOT make assumptions about values at
* these keys.
*
* See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
*/
_meta?: {
[key: string]: unknown;
} | null;
};
/**
* Audio provided to or from an LLM.
*/
export type AudioContent = {
annotations?: Annotations | null;
data: string;
mimeType: string;
/**
* The _meta property is reserved by ACP to allow clients and agents to attach additional
* metadata to their interactions. Implementations MUST NOT make assumptions about values at
* these keys.
*
* See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
*/
_meta?: {
[key: string]: unknown;
} | null;
};
/**
* A resource that the server is capable of reading, included in a prompt or tool call result.
*/
export type ResourceLink = {
annotations?: Annotations | null;
description?: string | null;
mimeType?: string | null;
name: string;
size?: number | null;
title?: string | null;
uri: string;
/**
* The _meta property is reserved by ACP to allow clients and agents to attach additional
* metadata to their interactions. Implementations MUST NOT make assumptions about values at
* these keys.
*
* See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
*/
_meta?: {
[key: string]: unknown;
} | null;
};
/**
* Resource content that can be embedded in a message.
*/
export type EmbeddedResourceResource = TextResourceContents | BlobResourceContents;
/**
* Text-based resource contents.
*/
export type TextResourceContents = {
mimeType?: string | null;
text: string;
uri: string;
/**
* The _meta property is reserved by ACP to allow clients and agents to attach additional
* metadata to their interactions. Implementations MUST NOT make assumptions about values at
* these keys.
*
* See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
*/
_meta?: {
[key: string]: unknown;
} | null;
};
/**
* Binary resource contents.
*/
export type BlobResourceContents = {
blob: string;
mimeType?: string | null;
uri: string;
/**
* The _meta property is reserved by ACP to allow clients and agents to attach additional
* metadata to their interactions. Implementations MUST NOT make assumptions about values at
* these keys.
*
* See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
*/
_meta?: {
[key: string]: unknown;
} | null;
};
/**
* The contents of a resource, embedded into a prompt or tool call result.
*/
export type EmbeddedResource = {
annotations?: Annotations | null;
resource: EmbeddedResourceResource;
/**
* The _meta property is reserved by ACP to allow clients and agents to attach additional
* metadata to their interactions. Implementations MUST NOT make assumptions about values at
* these keys.
*
* See protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)
*/
_meta?: {
[key: string]: unknown;
} | null;
};
export type SteerSessionResponse_unstable = {
runId: string;
/**
* Stable id of the queued steer message. The same id later appears as
* `messageId` on the streamed `UserMessageChunk` (with `_meta.goose.steer`),
* letting clients correlate a queued steer with its pickup.
*/
messageId: string;
};
/**
* Delete a session.
*/
@@ -1345,14 +1557,14 @@ export type InteractionUpdate = {
export type ExtRequest = {
id: string;
method: string;
params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ElicitationRespondRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | {
params?: AddExtensionRequest_unstable | RemoveExtensionRequest_unstable | GetToolsRequest_unstable | GooseToolCallRequest_unstable | ReadResourceRequest_unstable | UpdateWorkingDirRequest_unstable | SetSessionSystemPromptRequest_unstable | SteerSessionRequest_unstable | DeleteSessionRequest | GetConfigExtensionsRequest_unstable | GetAvailableExtensionsRequest_unstable | AddConfigExtensionRequest_unstable | RemoveConfigExtensionRequest_unstable | SetConfigExtensionEnabledRequest_unstable | GetSessionExtensionsRequest_unstable | ListProvidersRequest_unstable | ProviderSupportedModelsListRequest_unstable | ProviderCatalogListRequest_unstable | ProviderSetupCatalogListRequest_unstable | ProviderCatalogTemplateRequest_unstable | CustomProviderCreateRequest_unstable | CustomProviderReadRequest_unstable | CustomProviderUpdateRequest_unstable | CustomProviderDeleteRequest_unstable | RefreshProviderInventoryRequest_unstable | ProviderConfigReadRequest_unstable | ProviderConfigStatusRequest_unstable | ProviderConfigSaveRequest_unstable | ProviderConfigDeleteRequest_unstable | ProviderConfigAuthenticateRequest_unstable | PreferencesReadRequest_unstable | PreferencesSaveRequest_unstable | PreferencesRemoveRequest_unstable | DefaultsReadRequest_unstable | DefaultsSaveRequest_unstable | OnboardingImportScanRequest_unstable | OnboardingImportApplyRequest_unstable | ExportSessionRequest_unstable | ImportSessionRequest_unstable | ElicitationRespondRequest_unstable | UpdateSessionProjectRequest_unstable | RenameSessionRequest_unstable | ArchiveSessionRequest_unstable | UnarchiveSessionRequest_unstable | CreateSourceRequest_unstable | ListSourcesRequest_unstable | UpdateSourceRequest_unstable | DeleteSourceRequest_unstable | ExportSourceRequest_unstable | ImportSourcesRequest_unstable | DictationTranscribeRequest_unstable | DictationConfigRequest_unstable | DictationSecretSaveRequest_unstable | DictationSecretDeleteRequest_unstable | DictationModelsListRequest_unstable | DictationModelDownloadRequest_unstable | DictationModelDownloadProgressRequest_unstable | DictationModelCancelRequest_unstable | DictationModelDeleteRequest_unstable | DictationModelSelectRequest_unstable | {
[key: string]: unknown;
} | null;
};
export type ExtResponse = {
id: string;
result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown;
result?: EmptyResponse | GetToolsResponse_unstable | GooseToolCallResponse_unstable | ReadResourceResponse_unstable | SteerSessionResponse_unstable | GetConfigExtensionsResponse_unstable | GetAvailableExtensionsResponse_unstable | GetSessionExtensionsResponse_unstable | ListProvidersResponse_unstable | ProviderSupportedModelsListResponse_unstable | ProviderCatalogListResponse_unstable | ProviderSetupCatalogListResponse_unstable | ProviderCatalogTemplateResponse_unstable | CustomProviderCreateResponse_unstable | CustomProviderReadResponse_unstable | CustomProviderUpdateResponse_unstable | CustomProviderDeleteResponse_unstable | RefreshProviderInventoryResponse_unstable | ProviderConfigReadResponse_unstable | ProviderConfigStatusResponse_unstable | ProviderConfigChangeResponse_unstable | PreferencesReadResponse_unstable | DefaultsReadResponse_unstable | OnboardingImportScanResponse_unstable | OnboardingImportApplyResponse_unstable | ExportSessionResponse_unstable | ImportSessionResponse_unstable | CreateSourceResponse_unstable | ListSourcesResponse_unstable | UpdateSourceResponse_unstable | ExportSourceResponse_unstable | ImportSourcesResponse_unstable | DictationTranscribeResponse_unstable | DictationConfigResponse_unstable | DictationModelsListResponse_unstable | DictationModelDownloadProgressResponse_unstable | unknown;
} | {
error: {
code: number;
+215
View File
@@ -105,6 +105,219 @@ export const zSetSessionSystemPromptRequest_unstable = z.object({
text: z.string()
});
/**
* The sender or recipient of messages and data in a conversation.
*/
export const zRole = z.enum(['assistant', 'user']);
/**
* Optional annotations for the client. The client can use annotations to inform how objects are used or displayed
*/
export const zAnnotations = z.object({
audience: z.union([
z.array(zRole),
z.null()
]).optional(),
lastModified: z.union([
z.string(),
z.null()
]).optional(),
priority: z.union([
z.number(),
z.null()
]).optional(),
_meta: z.union([
z.record(z.unknown()),
z.null()
]).optional()
});
/**
* Text provided to or from an LLM.
*/
export const zTextContent = z.object({
annotations: z.union([
zAnnotations,
z.null()
]).optional(),
text: z.string(),
_meta: z.union([
z.record(z.unknown()),
z.null()
]).optional()
});
/**
* An image provided to or from an LLM.
*/
export const zImageContent = z.object({
annotations: z.union([
zAnnotations,
z.null()
]).optional(),
data: z.string(),
mimeType: z.string(),
uri: z.union([
z.string(),
z.null()
]).optional(),
_meta: z.union([
z.record(z.unknown()),
z.null()
]).optional()
});
/**
* Audio provided to or from an LLM.
*/
export const zAudioContent = z.object({
annotations: z.union([
zAnnotations,
z.null()
]).optional(),
data: z.string(),
mimeType: z.string(),
_meta: z.union([
z.record(z.unknown()),
z.null()
]).optional()
});
/**
* A resource that the server is capable of reading, included in a prompt or tool call result.
*/
export const zResourceLink = z.object({
annotations: z.union([
zAnnotations,
z.null()
]).optional(),
description: z.union([
z.string(),
z.null()
]).optional(),
mimeType: z.union([
z.string(),
z.null()
]).optional(),
name: z.string(),
size: z.union([
z.number().int(),
z.null()
]).optional(),
title: z.union([
z.string(),
z.null()
]).optional(),
uri: z.string(),
_meta: z.union([
z.record(z.unknown()),
z.null()
]).optional()
});
/**
* Text-based resource contents.
*/
export const zTextResourceContents = z.object({
mimeType: z.union([
z.string(),
z.null()
]).optional(),
text: z.string(),
uri: z.string(),
_meta: z.union([
z.record(z.unknown()),
z.null()
]).optional()
});
/**
* Binary resource contents.
*/
export const zBlobResourceContents = z.object({
blob: z.string(),
mimeType: z.union([
z.string(),
z.null()
]).optional(),
uri: z.string(),
_meta: z.union([
z.record(z.unknown()),
z.null()
]).optional()
});
/**
* Resource content that can be embedded in a message.
*/
export const zEmbeddedResourceResource = z.union([
zTextResourceContents,
zBlobResourceContents
]);
/**
* The contents of a resource, embedded into a prompt or tool call result.
*/
export const zEmbeddedResource = z.object({
annotations: z.union([
zAnnotations,
z.null()
]).optional(),
resource: zEmbeddedResourceResource,
_meta: z.union([
z.record(z.unknown()),
z.null()
]).optional()
});
/**
* Content blocks represent displayable information in the Agent Client Protocol.
*
* They provide a structured way to handle various types of user-facing content—whether
* it's text from language models, images for analysis, or embedded resources for context.
*
* Content blocks appear in:
* - User prompts sent via `session/prompt`
* - Language model output streamed through `session/update` notifications
* - Progress updates and results from tool calls
*
* This structure is compatible with the Model Context Protocol (MCP), enabling
* agents to seamlessly forward content from MCP tool outputs without transformation.
*
* See protocol docs: [Content](https://agentclientprotocol.com/protocol/content)
*/
export const zContentBlock = z.union([
z.object({
type: z.literal('TextContent')
}).and(zTextContent),
z.object({
type: z.literal('ImageContent')
}).and(zImageContent),
z.object({
type: z.literal('AudioContent')
}).and(zAudioContent),
z.object({
type: z.literal('ResourceLink')
}).and(zResourceLink),
z.object({
type: z.literal('EmbeddedResource')
}).and(zEmbeddedResource)
]);
/**
* Add user input to the currently active prompt without starting a new prompt.
*/
export const zSteerSessionRequest_unstable = z.object({
sessionId: z.string(),
prompt: z.array(zContentBlock).optional().default([]),
expectedRunId: z.string()
});
export const zSteerSessionResponse_unstable = z.object({
runId: z.string(),
messageId: z.string()
});
/**
* Delete a session.
*/
@@ -1347,6 +1560,7 @@ export const zExtRequest = z.object({
zReadResourceRequest_unstable,
zUpdateWorkingDirRequest_unstable,
zSetSessionSystemPromptRequest_unstable,
zSteerSessionRequest_unstable,
zDeleteSessionRequest,
zGetConfigExtensionsRequest_unstable,
zGetAvailableExtensionsRequest_unstable,
@@ -1416,6 +1630,7 @@ export const zExtResponse = z.union([
zGetToolsResponse_unstable,
zGooseToolCallResponse_unstable,
zReadResourceResponse_unstable,
zSteerSessionResponse_unstable,
zGetConfigExtensionsResponse_unstable,
zGetAvailableExtensionsResponse_unstable,
zGetSessionExtensionsResponse_unstable,