Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ function LiveUserTool(props: {
});
const sink = <T,>() =>
createElicitationReviewSink<T>({
encodedEmailBody:
props.request.tool === 'SendEmail' &&
props.request.schema.properties.some(
(property) => property.name === 'bodyFormat'
),
canAnswer: elicitation.canAnswer,
ownerName: elicitation.ownerName,
answering: elicitation.answering,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { createElicitationReviewSink } from './elicitation-review-sink';

describe('MCP composer answers', () => {
it('declares encoded email bodies only when the form supports that field', async () => {
for (const encodedEmailBody of [false, true]) {
const respond = vi.fn().mockResolvedValue(true);
const sink = createElicitationReviewSink({
encodedEmailBody,
canAnswer: () => true,
ownerName: () => 'Alice',
answering: () => false,
respond,
});
await sink.onExecute({ body: 'PHA-SGVsbG88L3A-' });
expect(respond).toHaveBeenCalledWith({
action: 'accept',
content: {
draft: JSON.stringify({ body: 'PHA-SGVsbG88L3A-' }),
...(encodedEmailBody ? { bodyFormat: 'base64url_html' } : {}),
},
});
}
});
});
13 changes: 10 additions & 3 deletions apps/web/src/features/block-agent/state/elicitation-review-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* The agent session's half of a user tool's composer: answering the review
* elicitation the agent is blocked on.
*
* Accept sends the whole edited draft under the `draft` field (Macro's
* `_macro/json` extension); the agent's finisher runs the tool with it.
* Accept sends the whole edited draft as a standard string `draft` field.
* The MCP server validates and executes the reviewed tool arguments.
* Reject declines. There is nothing to persist between edits - the draft
* lives in the form until the user decides - so `onEdit` is left out.
*/
Expand All @@ -16,6 +16,8 @@ import type { Accessor } from 'solid-js';
export const DRAFT_FIELD = 'draft';

export function createElicitationReviewSink<T>(options: {
/** New MCP email forms declare the composer body encoding explicitly. */
encodedEmailBody?: boolean;
canAnswer: Accessor<boolean>;
ownerName: Accessor<string>;
answering: Accessor<boolean>;
Expand All @@ -32,7 +34,12 @@ export function createElicitationReviewSink<T>(options: {
canAct()
? options.respond({
action: 'accept',
content: { [DRAFT_FIELD]: JSON.stringify(args) },
content: {
[DRAFT_FIELD]: JSON.stringify(args),
...(options.encodedEmailBody
? { bodyFormat: 'base64url_html' }
: {}),
},
})
: Promise.resolve(false),
onReject: () =>
Expand Down
21 changes: 2 additions & 19 deletions crates/agent/src/agent_loop.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// The main entry point: [`AgentLoop`] and [`Session`].
use crate::error::AgentError;
use crate::hook::{BridgeInputs, RegisterFn, ToolRouter, UserToolFinisher};
use crate::hook::{BridgeInputs, RegisterFn, ToolRouter};
use crate::model::PredefinedModel;
use crate::model::router::{ModelRouter, ProviderAgent};
use crate::stream::ChatCompletionStream;
Expand Down Expand Up @@ -30,7 +30,6 @@ pub struct AgentLoop {
max_turns: usize,
max_tokens: u64,
recorder: Arc<dyn UsageRecorder>,
user_tool_finisher: Option<UserToolFinisher>,
}

impl AgentLoop {
Expand All @@ -47,24 +46,9 @@ impl AgentLoop {
max_turns: DEFAULT_MAX_TURNS,
max_tokens: DEFAULT_MAX_TOKENS,
recorder,
user_tool_finisher: None,
}
}

/// Finish user tools inside the turn.
///
/// A user tool (`ai_toolset::UserTool`) answers `"PendingUserExecution"`
/// and leaves the call for the host to finish. Without a finisher that
/// answer reaches the model as-is and the host finishes the call later,
/// as chat does over HTTP. With one, the bridge hands each pending call
/// to `finisher` before the model reads it, and the model sees what the
/// user decided instead - the shape a host that can reach its user
/// mid-turn wants.
pub fn with_user_tool_finisher(mut self, finisher: UserToolFinisher) -> Self {
self.user_tool_finisher = Some(finisher);
self
}

/// Override the model.
///
/// Accepts any stringifiable id — an [`AgentModel`] (backend) or a raw
Expand Down Expand Up @@ -248,7 +232,6 @@ impl AgentLoop {
routing,
loaded_buffer,
register_loaded,
user_tool_finisher: self.user_tool_finisher.clone(),
},
recorder: self.recorder.clone(),
usage_ctx,
Expand Down Expand Up @@ -293,7 +276,7 @@ pub struct Session {
history: Vec<Message>,
max_turns: usize,
/// What every turn's stream bridge is built from: tool routing, the
/// on-demand tool loading pair, and the user-tool finisher if any.
/// on-demand tool loading pair.
bridge_inputs: BridgeInputs,
recorder: Arc<dyn UsageRecorder>,
usage_ctx: UsageContext,
Expand Down
89 changes: 1 addition & 88 deletions crates/agent/src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,44 +30,6 @@ pub type ToolRouter = Arc<dyn Fn(&str) -> Option<ToolInfo> + Send + Sync>;
pub type RegisterFn =
Arc<dyn Fn(Vec<SearchableTool>) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;

/// A user tool the model called, as the host's [`UserToolFinisher`] sees it.
///
/// A user tool (`ai_toolset::UserTool`) answers `"PendingUserExecution"` and
/// does nothing: the host is meant to finish it - let the user review the
/// call, then execute or reject it. Chat does that after the turn, over HTTP;
/// a host that can reach its user mid-turn does it here, before the model
/// reads the result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingUserTool {
/// The tool's name as the toolset knows it.
pub tool_name: String,
/// The call's id as the stream reported it ([`ToolCall::id`]): the
/// provider's, or rig's correlation id when the provider gave none. The
/// id the host's transcript shows the call under.
pub tool_call_id: String,
/// The arguments the model called the tool with.
pub args: serde_json::Value,
}

/// What a finished user tool comes back as, in place of the pending answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FinishedUserTool {
/// The tool's own result (`UserToolResponse<T>` as JSON: the user's
/// action, or their rejection).
Result(serde_json::Value),
/// Finishing failed; the description is what the model reads.
Error(String),
}

/// Finishes a user tool inside the turn. Returns `None` to leave the pending
/// answer as it is - the host will finish the call some other way, or not at
/// all. Context-erased for the same reason as [`RegisterFn`].
pub type UserToolFinisher = Arc<
dyn Fn(PendingUserTool) -> Pin<Box<dyn Future<Output = Option<FinishedUserTool>> + Send>>
+ Send
+ Sync,
>;

/// Everything the session hands the stream bridge besides the request context.
#[derive(Clone)]
pub struct BridgeInputs {
Expand All @@ -77,13 +39,8 @@ pub struct BridgeInputs {
pub loaded_buffer: Arc<Mutex<Vec<SearchableTool>>>,
/// Registers loaded tools with the live tool server.
pub register_loaded: RegisterFn,
/// Finishes user tools mid-turn, when the host can.
pub user_tool_finisher: Option<UserToolFinisher>,
}

/// The answer a user tool gives when it has not been finished.
const PENDING_USER_EXECUTION: &str = "PendingUserExecution";

static CANCELLED_REASON: &str = "user cancelled";

/// Retry budget for invalid tool calls recovered via
Expand All @@ -110,9 +67,6 @@ pub struct StreamBridge {
/// [`Self::on_invalid_tool_call`] to recover calls to tools the model
/// discovered but never loaded.
searchable_catalog: Arc<Vec<SearchableTool>>,
/// Finishes a user tool's pending answer before the model reads it, on
/// hosts that can reach the user mid-turn (see [`UserToolFinisher`]).
user_tool_finisher: Option<UserToolFinisher>,
/// the user has requested the stream stop
cancel: CancellationToken,
}
Expand All @@ -137,7 +91,6 @@ impl StreamBridge {
routing,
loaded_buffer,
register_loaded,
user_tool_finisher,
} = inputs;
let (tx, rx) = mpsc::unbounded_channel();
(
Expand All @@ -147,7 +100,6 @@ impl StreamBridge {
loaded_buffer,
register_loaded,
searchable_catalog,
user_tool_finisher,
cancel,
},
rx,
Expand Down Expand Up @@ -177,11 +129,6 @@ fn presentation_json(presentation: &ToolOutput) -> Option<serde_json::Value> {
.and_then(|text| serde_json::from_str(text).ok())
}

/// Whether a tool's JSON result is a user tool's unfinished answer.
fn is_pending_user_execution(json: &serde_json::Value) -> bool {
json.as_str() == Some(PENDING_USER_EXECUTION)
}

/// The hook bodies, as inherent methods so tests can exercise them directly:
/// rig's [`HookContext`] has no public constructor, so the [`AgentHook`] impl
/// below is a thin delegation layer over these.
Expand Down Expand Up @@ -274,7 +221,7 @@ impl StreamBridge {
tool_name: &str,
tool_call_id: Option<&str>,
internal_call_id: &str,
args: &str,
_args: &str,
presentation: &ToolOutput,
is_success: bool,
) -> ToolResultAction {
Expand All @@ -297,40 +244,6 @@ impl StreamBridge {
None
};

// A user tool's pending answer is finished here when the host can:
// the user reviews the call while the turn waits, and the model reads
// what they decided instead of a "pending" it would take for success.
if let Some(finisher) = &self.user_tool_finisher
&& json.as_ref().is_some_and(is_pending_user_execution)
{
let call = PendingUserTool {
tool_name: tool_name.to_owned(),
tool_call_id: id.clone(),
args: serde_json::from_str(args).unwrap_or(serde_json::Value::Null),
};
match finisher(call).await {
Some(FinishedUserTool::Result(result)) => {
let _ = self
.tx
.send(Ok(StreamPart::ToolResponse(ToolResponse::Json {
id,
json: result.clone(),
name: tool_name.to_owned(),
})));
return ToolResultAction::Rewrite(ToolOutput::json(result));
}
Some(FinishedUserTool::Error(description)) => {
let _ = self.tx.send(Ok(StreamPart::ToolResponse(ToolResponse::Err {
id,
name: tool_name.to_owned(),
description: description.clone(),
})));
return ToolResultAction::rewrite(description);
}
None => {}
}
}

let response = if let Some(json) = json {
ToolResponse::Json {
id,
Expand Down
1 change: 0 additions & 1 deletion crates/agent/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ pub use agent_loop::{AgentLoop, Session};
pub use completion::{complete, complete_with_history};
pub use convert::{merge_consecutive_parts, to_rig_messages};
pub use error::AgentError;
pub use hook::{FinishedUserTool, PendingUserTool, UserToolFinisher};
pub use model::PredefinedModel;
pub use stream::{ChatCompletionStream, McpInfo, StreamPart, ToolCall, ToolResponse, Usage};
pub use tool_adapter::{DynToolSetAdapter, ToolsetToolAdapter, normalize_request_schema};
Expand Down
Loading
Loading