blob: fba2164ae6063c056a8b34ebbedd43f343cd5d84 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use agent_client_protocol::schema::v1 as acp;
use maka_agent_facts::executor::Output;
use maka_agent_ports::executor::Error;
use maka_agent_ports::executor::OutputSink;
use std::collections::BTreeMap;
pub(crate) mod v2;
// JSON escaping expands each byte at most sixfold; leave room for identities.
const CHUNK_BYTES: usize = 8 * 1024;
#[derive(Default)]
struct Tool {
text: String,
done: bool,
closure_error: Option<&'static str>,
}
/// Projects one ACP prompt turn into committed public executor observations.
pub(crate) struct Projection {
session_id: String,
invocation_id: String,
tools: BTreeMap<String, Tool>,
text: String,
config_options: Option<Vec<acp::SessionConfigOption>>,
finished: bool,
}
impl Projection {
pub(crate) fn session_id(&self) -> &str {
&self.session_id
}
pub(crate) fn new(session_id: String, invocation_id: String) -> Self {
Self {
session_id,
invocation_id,
tools: BTreeMap::new(),
text: String::new(),
config_options: None,
finished: false,
}
}
pub(crate) fn text(&self) -> String {
self.text.clone()
}
pub(crate) fn take_config_options(&mut self) -> Option<Vec<acp::SessionConfigOption>> {
self.config_options.take()
}
pub(crate) async fn update(
&mut self,
notification: acp::SessionNotification,
sink: &dyn OutputSink,
) -> Result<(), Error> {
if notification.session_id.to_string() != self.session_id || self.finished {
return Err(invalid("ACP update belongs to another or finished turn"));
}
match notification.update {
acp::SessionUpdate::AgentMessageChunk(chunk) => {
let text = content_text(chunk.content)?;
emit_text(&text, sink, |text| Output::OutputDelta { text }).await?;
self.text.push_str(&text);
}
acp::SessionUpdate::AgentThoughtChunk(chunk) => {
emit_text(&content_text(chunk.content)?, sink, |text| {
Output::ThinkingDelta { text }
})
.await?;
}
acp::SessionUpdate::ToolCall(call) => {
let fields = acp::ToolCallUpdateFields::new()
.title(call.title)
.name(call.name)
.status(call.status)
.content(call.content)
.raw_input(call.raw_input)
.raw_output(call.raw_output);
self.tool(call.tool_call_id.to_string(), fields, sink)
.await?;
}
acp::SessionUpdate::ToolCallUpdate(call) => {
self.tool(call.tool_call_id.to_string(), call.fields, sink)
.await?;
}
acp::SessionUpdate::Plan(plan) => {
emit_text(&json(&plan)?, sink, |text| Output::ThinkingDelta { text }).await?;
}
acp::SessionUpdate::ConfigOptionUpdate(update) => {
self.config_options = Some(update.config_options);
}
// User echoes and session metadata do not form assistant output.
acp::SessionUpdate::UserMessageChunk(_)
| acp::SessionUpdate::AvailableCommandsUpdate(_)
| acp::SessionUpdate::CurrentModeUpdate(_)
| acp::SessionUpdate::SessionInfoUpdate(_)
| acp::SessionUpdate::UsageUpdate(_) => {}
_ => return Err(invalid("unsupported ACP session update")),
}
Ok(())
}
fn tool_id(&self, id: &str) -> String {
format!("{}:{}:{id}", self.invocation_id.len(), self.invocation_id)
}
async fn tool(
&mut self,
id: String,
fields: acp::ToolCallUpdateFields,
sink: &dyn OutputSink,
) -> Result<(), Error> {
if id.is_empty() {
return Err(invalid("ACP tool call ID is empty"));
}
// Keep terminal tombstones for this turn: late updates cannot reopen tools.
if self.tools.get(&id).is_some_and(|tool| tool.done) {
return Err(invalid("ACP updated a completed tool call"));
}
let status = fields.status.unwrap_or_default();
let done = match status {
acp::ToolCallStatus::Completed | acp::ToolCallStatus::Failed => true,
acp::ToolCallStatus::Pending | acp::ToolCallStatus::InProgress => false,
_ => return Err(invalid("unsupported ACP tool status")),
};
let text = match fields.content {
Some(content) if !content.is_empty() => Some(tool_text(content)?),
Some(_) => Some(
fields
.raw_output
.as_ref()
.map(json)
.transpose()?
.unwrap_or_default(),
),
None => fields.raw_output.as_ref().map(json).transpose()?,
};
let tool_call_id = self.tool_id(&id);
if !self.tools.contains_key(&id) {
emit(
sink,
Output::ToolStart {
tool_call_id: tool_call_id.clone(),
name: fields
.name
.or(fields.title)
.unwrap_or_else(|| "ACP tool".into()),
input: fields.raw_input.unwrap_or(serde_json::Value::Null),
},
)
.await?;
self.tools.insert(id.clone(), Tool::default());
}
let tool = self.tools.get_mut(&id).expect("tool start was committed");
if let Some(text) = text {
// ACP replaces content snapshots; avoid repeating an unchanged prefix.
if !done && text != tool.text {
let delta = text
.strip_prefix(&tool.text)
.map(str::to_owned)
.unwrap_or_else(|| format!("\nUpdated tool output:\n{text}"));
emit_text(&delta, sink, |text| Output::ToolProgress {
tool_call_id: tool_call_id.clone(),
text,
})
.await?;
}
tool.text = text;
}
if done {
if let Err(error) = emit_result(
sink,
tool_call_id,
&tool.text,
status == acp::ToolCallStatus::Failed,
)
.await
{
tool.closure_error = Some(match &error {
Error::Invalid(_) => "ACP tool result contains invalid text",
_ => "ACP tool result could not be recorded",
});
return Err(error);
}
tool.text.clear();
tool.done = true;
}
Ok(())
}
/// Close every unfinished observation when the prompt settles or transport ends.
pub(crate) async fn finish(&mut self, sink: &dyn OutputSink) -> Result<(), Error> {
self.finished = true;
let invocation_id = &self.invocation_id;
for (id, tool) in &mut self.tools {
if !tool.done {
emit_result(
sink,
format!("{}:{invocation_id}:{id}", invocation_id.len()),
tool.closure_error
.unwrap_or("ACP turn ended before the tool reported completion"),
true,
)
.await?;
tool.text.clear();
tool.done = true;
}
}
Ok(())
}
}
fn invalid(message: &str) -> Error {
Error::Invalid(message.into())
}
fn json(value: &impl serde::Serialize) -> Result<String, Error> {
serde_json::to_string(value).map_err(|error| invalid(&error.to_string()))
}
fn content_text(content: acp::ContentBlock) -> Result<String, Error> {
match content {
acp::ContentBlock::Text(text) => Ok(text.text),
_ => Err(invalid("ACP sent unsupported non-text content")),
}
}
fn tool_text(content: Vec<acp::ToolCallContent>) -> Result<String, Error> {
let mut text = String::new();
for part in content {
let part = match part {
acp::ToolCallContent::Content(content) => content_text(content.content)?,
acp::ToolCallContent::Diff(diff) => format!(
"File: {}\nBefore:\n{}\nAfter:\n{}",
diff.path.display(),
diff.old_text.as_deref().unwrap_or(""),
diff.new_text
),
_ => return Err(invalid("ACP sent unsupported tool content")),
};
if !text.is_empty() {
text.push('\n');
}
text.push_str(&part);
}
Ok(text)
}
pub(crate) async fn emit(sink: &dyn OutputSink, output: Output) -> Result<(), Error> {
output.validate().map_err(invalid)?;
sink.emit(output).await
}
pub(crate) async fn emit_text(
mut text: &str,
sink: &dyn OutputSink,
output: impl Fn(String) -> Output,
) -> Result<(), Error> {
if text.contains('\0') {
return Err(invalid("ACP text contains NUL"));
}
while !text.is_empty() {
let mut end = CHUNK_BYTES.min(text.len());
while !text.is_char_boundary(end) {
end -= 1;
}
emit(sink, output(text[..end].into())).await?;
text = &text[end..];
}
Ok(())
}
pub(crate) async fn emit_result(
sink: &dyn OutputSink,
id: String,
text: &str,
is_error: bool,
) -> Result<(), Error> {
// Reloaded tool cards use the complete terminal fact, not transient progress.
emit(
sink,
Output::ToolResult {
tool_call_id: id,
text: text.into(),
is_error,
},
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use std::{future::Future, pin::Pin, sync::Mutex};
#[derive(Default)]
struct Sink(Mutex<Vec<Output>>);
impl OutputSink for Sink {
fn emit(
&self,
output: Output,
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
Box::pin(async move {
self.0.lock().unwrap().push(output);
Ok(())
})
}
}
fn update(value: serde_json::Value) -> acp::SessionNotification {
serde_json::from_value(serde_json::json!({ "sessionId": "s", "update": value })).unwrap()
}
#[tokio::test]
async fn update_before_start_and_eof_close_only_unfinished_tools() {
let sink = Sink::default();
let mut projection = Projection::new("s".into(), "turn".into());
for value in [
serde_json::json!({"sessionUpdate":"tool_call_update","toolCallId":"a","status":"completed"}),
serde_json::json!({"sessionUpdate":"tool_call","toolCallId":"b","title":"running"}),
] {
projection.update(update(value), &sink).await.unwrap();
}
projection.finish(&sink).await.unwrap();
projection.finish(&sink).await.unwrap();
let events = sink.0.lock().unwrap();
assert_eq!(events.len(), 4);
assert!(
matches!(&events[0], Output::ToolStart { tool_call_id, .. } if tool_call_id == "4:turn:a")
);
assert!(matches!(
&events[1],
Output::ToolResult {
is_error: false,
..
}
));
assert!(matches!(&events[2], Output::ToolStart { .. }));
assert!(matches!(
&events[3],
Output::ToolResult { is_error: true, .. }
));
}
#[tokio::test]
async fn unicode_chunks_round_trip_and_foreign_sessions_fail() {
let sink = Sink::default();
let mut projection = Projection::new("s".into(), "turn".into());
let text = "界\n".repeat(400_000);
let notification = update(
serde_json::json!({"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":text}}),
);
projection
.update(notification.clone(), &sink)
.await
.unwrap();
assert_eq!(projection.text(), text);
let mut actual = String::new();
{
let events = sink.0.lock().unwrap();
for event in events.iter() {
assert!(json(event).unwrap().len() <= 64 * 1024);
if let Output::OutputDelta { text } = event {
actual.push_str(text);
}
}
}
assert_eq!(actual, text);
let mut foreign = notification;
foreign.session_id = "other".into();
assert!(projection.update(foreign, &sink).await.is_err());
}
#[tokio::test]
async fn complete_large_output_keeps_late_completion_refusal() {
let sink = Sink::default();
let mut projection = Projection::new("s".into(), "turn".into());
let oversized = update(serde_json::json!({
"sessionUpdate":"agent_message_chunk",
"content":{"type":"text","text":"\n".repeat(1024 * 1024 / 2)}
}));
projection.update(oversized, &sink).await.unwrap();
assert_eq!(projection.text(), "\n".repeat(1024 * 1024 / 2));
let emitted = sink.0.lock().unwrap().len();
let terminal = update(serde_json::json!({
"sessionUpdate":"tool_call_update","toolCallId":"a","status":"completed"
}));
projection.update(terminal.clone(), &sink).await.unwrap();
assert!(projection.update(terminal, &sink).await.is_err());
projection.finish(&sink).await.unwrap();
assert_eq!(sink.0.lock().unwrap().len(), emitted + 2);
}
#[tokio::test]
async fn terminal_text_is_complete_and_durable() {
let sink = Sink::default();
let mut projection = Projection::new("s".into(), "turn".into());
let terminal = |id: &str, text: String| {
update(serde_json::json!({
"sessionUpdate":"tool_call_update","toolCallId":id,"status":"completed",
"content":[{"type":"content","content":{"type":"text","text":text}}]
}))
};
let text = "x".repeat(9 * 1024);
projection
.update(terminal("a", text.clone()), &sink)
.await
.unwrap();
let oversized = terminal("b", "x".repeat(64 * 1024));
projection.update(oversized, &sink).await.unwrap();
projection.finish(&sink).await.unwrap();
let events = sink.0.lock().unwrap();
assert_eq!(events.len(), 4);
assert!(
matches!(&events[1], Output::ToolResult { text: actual, is_error: false, .. } if actual == &text)
);
assert!(
matches!(&events[3], Output::ToolResult { text, is_error: false, .. } if text == &"x".repeat(64 * 1024))
);
}
}