use crate::pxb;
use std::collections::HashMap;
use std::future::Future;
use std::io;
use std::pin::Pin;
pub use crate::pxb::Error;
mod schema;
pub use schema::Schema;
type Rd = io::StdinLock<'static>;
type Wr = io::StdoutLock<'static>;
type EventHandlers = HashMap<u16, Box<dyn FnMut(pxb::EventNotify)>>;
#[derive(Debug, Clone, Default)]
pub struct HostInfo {
pub cwd: String,
pub session_id: String,
pub extension_dir: String,
pub phi_version: String,
}
#[allow(clippy::type_complexity)] pub struct Tool {
pub name: String,
pub description: String,
pub schema: Schema,
pub readable: bool,
pub timeout_sec: u32,
pub detail_from_args: Option<Box<dyn FnMut(&[u8]) -> String>>,
pub execute: Box<dyn FnMut(&[u8]) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>>>>>,
}
impl Tool {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
schema: impl Into<Schema>,
mut execute: impl FnMut(&[u8]) -> Result<ToolResult, String> + 'static,
) -> Self {
Self {
name: name.into(),
description: description.into(),
schema: schema.into(),
timeout_sec: 0,
detail_from_args: None,
readable: false,
execute: Box::new(move |args| {
let result = execute(args);
Box::pin(async move { result })
}),
}
}
pub fn new_async<F, Fut>(
name: impl Into<String>,
description: impl Into<String>,
schema: impl Into<Schema>,
mut execute: F,
) -> Self
where
F: FnMut(Vec<u8>) -> Fut + 'static,
Fut: Future<Output = Result<ToolResult, String>> + 'static,
{
Self {
name: name.into(),
description: description.into(),
schema: schema.into(),
timeout_sec: 0,
readable: false,
detail_from_args: None,
execute: Box::new(move |args| Box::pin(execute(args.to_vec()))),
}
}
pub fn timeout_sec(mut self, secs: u32) -> Self {
self.timeout_sec = secs;
self
}
pub fn detail_from_args(mut self, f: impl FnMut(&[u8]) -> String + 'static) -> Self {
self.detail_from_args = Some(Box::new(f));
self
}
pub fn readable(mut self) -> Self {
self.readable = true;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ToolResult {
pub content: String,
pub detail: String,
pub output: String,
}
#[allow(clippy::type_complexity)] pub struct Command {
pub description: String,
pub needs_args: bool,
pub handler: Box<dyn FnMut(&str, &mut Context<'_>) -> Result<(), String>>,
}
impl Command {
pub fn new(
description: impl Into<String>,
handler: impl FnMut(&str, &mut Context<'_>) -> Result<(), String> + 'static,
) -> Self {
Self {
description: description.into(),
needs_args: false,
handler: Box::new(handler),
}
}
pub fn needs_args(mut self) -> Self {
self.needs_args = true;
self
}
}
#[derive(Debug, Clone, Default, serde::Serialize)]
#[serde(rename_all = "PascalCase")] pub struct ConfirmRequest {
pub title: String,
pub message: String,
pub yes: String, pub no: String, pub danger: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ConfirmReply {
pub ok: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ToolCallEvent {
pub tool_name: String,
pub tool_call_id: String,
pub input: Vec<u8>,
}
#[derive(Debug, Clone, Default)]
pub struct ToolCallResult {
pub block: bool,
pub reason: String,
pub input: Option<Vec<u8>>,
pub context: String,
}
#[derive(Debug, Clone, Default)]
pub struct ToolResultEvent {
pub tool_name: String,
pub tool_call_id: String,
pub input: Vec<u8>,
pub content: String,
pub is_error: bool,
pub err: String,
}
#[derive(Debug, Clone, Default)]
pub struct ToolResultResult {
pub content: Option<String>,
pub context: String,
pub stop: bool,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub struct BeforeAgentStartEvent {
pub prompt: String,
}
#[derive(Debug, Clone, Default)]
pub struct BeforeAgentStartResult {
pub prompt: Option<String>,
pub system_prompt_append: String,
}
#[derive(Debug, Clone, Default)]
pub struct SessionBeforeSwitchEvent {
pub reason: String,
pub target_session_id: String,
}
#[derive(Debug, Clone, Default)]
pub struct SessionBeforeSwitchResult {
pub cancel: bool,
pub reason: String,
pub toast: String,
}
#[derive(Debug, Clone, Default)]
pub struct UserInputEvent {
pub text: String,
}
#[derive(Debug, Clone, Default)]
pub struct UserInputResult {
pub handled: bool,
pub text: Option<String>,
pub reason: String,
}
#[derive(Debug, Clone, Default)]
pub struct TurnStoppingEvent {
pub turn_index: u32,
}
#[derive(Debug, Clone, Default)]
pub struct TurnStoppingResult {
pub continue_: bool,
pub message: String,
pub reason: String,
}
#[derive(Default)]
struct Handlers {
tool_call: Option<Box<dyn FnMut(ToolCallEvent) -> Option<ToolCallResult>>>,
tool_result: Option<Box<dyn FnMut(ToolResultEvent) -> Option<ToolResultResult>>>,
before_agent_start:
Option<Box<dyn FnMut(BeforeAgentStartEvent) -> Option<BeforeAgentStartResult>>>,
session_before_switch:
Option<Box<dyn FnMut(SessionBeforeSwitchEvent) -> Option<SessionBeforeSwitchResult>>>,
user_input: Option<Box<dyn FnMut(UserInputEvent) -> Option<UserInputResult>>>,
turn_stopping: Option<Box<dyn FnMut(TurnStoppingEvent) -> Option<TurnStoppingResult>>>,
events: EventHandlers,
}
pub struct Extension {
name: String,
version: String,
tools: Vec<Tool>,
commands: Vec<(String, Command)>,
events: Vec<pxb::Event>,
intercept: Vec<pxb::Event>,
handlers: Handlers,
}
impl Extension {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
tools: Vec::new(),
commands: Vec::new(),
events: Vec::new(),
intercept: Vec::new(),
handlers: Handlers::default(),
}
}
pub fn register_tool(&mut self, tool: Tool) {
if !tool.name.is_empty() {
self.tools.push(tool);
}
}
pub fn register_command(&mut self, name: impl Into<String>, cmd: Command) {
let name = name.into();
if !name.is_empty() && !self.commands.iter().any(|(n, _)| *n == name) {
self.commands.push((name, cmd));
}
}
pub fn on_tool_call(
&mut self,
f: impl FnMut(ToolCallEvent) -> Option<ToolCallResult> + 'static,
) {
self.handlers.tool_call = Some(Box::new(f));
push_unique(&mut self.intercept, pxb::Event::ToolCall);
}
pub fn on_tool_result(
&mut self,
f: impl FnMut(ToolResultEvent) -> Option<ToolResultResult> + 'static,
) {
self.handlers.tool_result = Some(Box::new(f));
push_unique(&mut self.intercept, pxb::Event::ToolResult);
}
pub fn on_before_agent_start(
&mut self,
f: impl FnMut(BeforeAgentStartEvent) -> Option<BeforeAgentStartResult> + 'static,
) {
self.handlers.before_agent_start = Some(Box::new(f));
push_unique(&mut self.intercept, pxb::Event::BeforeAgentStart);
}
pub fn on_session_before_switch(
&mut self,
f: impl FnMut(SessionBeforeSwitchEvent) -> Option<SessionBeforeSwitchResult> + 'static,
) {
self.handlers.session_before_switch = Some(Box::new(f));
push_unique(&mut self.intercept, pxb::Event::SessionBeforeSwitch);
}
pub fn on_user_input(
&mut self,
f: impl FnMut(UserInputEvent) -> Option<UserInputResult> + 'static,
) {
self.handlers.user_input = Some(Box::new(f));
push_unique(&mut self.intercept, pxb::Event::UserInput);
}
pub fn on_turn_stopping(
&mut self,
f: impl FnMut(TurnStoppingEvent) -> Option<TurnStoppingResult> + 'static,
) {
self.handlers.turn_stopping = Some(Box::new(f));
push_unique(&mut self.intercept, pxb::Event::TurnStopping);
}
pub fn subscribe(&mut self, event: pxb::Event, f: impl FnMut(pxb::EventNotify) + 'static) {
let code = event.code();
if code == 0 {
return;
}
push_unique(&mut self.events, event);
self.handlers.events.insert(code, Box::new(f));
}
pub fn run(self) -> Result<(), Error> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut rd = stdin.lock();
let mut wr = stdout.lock();
let rt = tokio::runtime::Builder::new_current_thread().build()?;
let host = handshake(&mut rd, &mut wr, &self)?;
register(&mut wr, &self)?;
let Extension {
tools,
commands,
handlers,
..
} = self;
serve(&mut rd, &mut wr, host, tools, commands, handlers, &rt)
}
}
fn handshake(rd: &mut Rd, wr: &mut Wr, ext: &Extension) -> Result<HostInfo, Error> {
let mut caps = 0u32;
if !ext.commands.is_empty() {
caps |= pxb::CAP_COMMANDS;
}
if !ext.tools.is_empty() {
caps |= pxb::CAP_TOOLS;
}
if !ext.events.is_empty() {
caps |= pxb::CAP_EVENTS;
}
if !ext.intercept.is_empty() {
caps |= pxb::CAP_INTERCEPT;
}
let hello = pxb::encode_hello(&pxb::Hello {
name: ext.name.clone(),
version: ext.version.clone(),
caps,
protocol: pxb::PROTOCOL_VERSION,
});
pxb::write_frame(wr, pxb::TYPE_HELLO, 0, 0, &hello)?;
let f = pxb::read_frame(rd)?;
if f.header.typ != pxb::TYPE_HELLO_ACK {
return Err(Error::UnexpectedFrame {
want: "hello_ack",
got: f.header.typ,
});
}
let ack = pxb::decode_hello_ack(&f.body)?;
Ok(HostInfo {
cwd: ack.cwd,
session_id: ack.session_id,
extension_dir: ack.extension_dir,
phi_version: ack.phi_version,
})
}
fn register(wr: &mut Wr, ext: &Extension) -> Result<(), Error> {
for tool in &ext.tools {
let body = pxb::encode_register_tool(&pxb::RegisterTool {
name: tool.name.clone(),
description: tool.description.clone(),
schema_json: tool.schema.to_json_bytes(),
timeout_sec: tool.timeout_sec,
has_detail: tool.detail_from_args.is_some(),
readable: tool.readable,
});
pxb::write_frame(wr, pxb::TYPE_REGISTER_TOOL, 0, 0, &body)?;
}
for (name, cmd) in &ext.commands {
let body = pxb::encode_register_command(&pxb::RegisterCommand {
name: name.clone(),
description: cmd.description.clone(),
needs_args: cmd.needs_args,
});
pxb::write_frame(wr, pxb::TYPE_REGISTER_COMMAND, 0, 0, &body)?;
}
if !ext.events.is_empty() || !ext.intercept.is_empty() {
let body = pxb::encode_subscribe(&pxb::Subscribe {
events: ext.events.iter().map(|e| e.code()).collect(),
intercept: ext.intercept.iter().map(|e| e.code()).collect(),
});
pxb::write_frame(wr, pxb::TYPE_SUBSCRIBE, 0, 0, &body)?;
}
pxb::write_frame(wr, pxb::TYPE_READY, 0, 0, &[])
}
fn serve(
rd: &mut Rd,
wr: &mut Wr,
mut host: HostInfo,
mut tools: Vec<Tool>,
mut commands: Vec<(String, Command)>,
mut handlers: Handlers,
rt: &tokio::runtime::Runtime,
) -> Result<(), Error> {
let mut pending_submit: Option<String> = None;
let mut next_host_id: u32 = 0;
loop {
let f = pxb::read_frame(rd)?;
match pxb::FrameType::from_u16(f.header.typ) {
pxb::FrameType::Shutdown => {
pxb::write_frame(wr, pxb::TYPE_SHUTDOWN_ACK, 0, 0, &[])?;
return Ok(());
}
pxb::FrameType::CommandInvoked => serve_command(
rd,
wr,
&f,
&mut host,
&mut commands,
&mut handlers.events,
&mut pending_submit,
&mut next_host_id,
)?,
pxb::FrameType::ToolInvoke => serve_tool(wr, &f, &mut tools, rt)?,
pxb::FrameType::ToolDetailInvoke => serve_tool_detail(wr, &f, &mut tools)?,
pxb::FrameType::Intercept => serve_intercept(wr, &f, &mut handlers)?,
pxb::FrameType::Event => {
if let Ok(ev) = pxb::decode_event_notify(&f.body) {
dispatch_event(&mut handlers.events, ev);
}
}
pxb::FrameType::SessionMeta => {
if let Ok(meta) = pxb::decode_session_meta(&f.body) {
apply_session_meta(&mut host, meta);
}
}
_ => {}
}
}
}
#[allow(clippy::too_many_arguments)] fn serve_command(
rd: &mut Rd,
wr: &mut Wr,
frame: &pxb::Frame,
host: &mut HostInfo,
commands: &mut [(String, Command)],
events: &mut EventHandlers,
pending_submit: &mut Option<String>,
next_host_id: &mut u32,
) -> Result<(), Error> {
let inv = pxb::decode_command_invoked(&frame.body)?;
let mut resp = pxb::CommandResponse {
ok: true,
..Default::default()
};
if let Some((_, cmd)) = commands.iter_mut().find(|(n, _)| *n == inv.name) {
let mut ctx = Context {
cwd: host.cwd.clone(),
session_id: host.session_id.clone(),
has_ui: true,
rd,
wr,
host,
pending_submit,
next_host_id,
events,
};
if let Err(e) = (cmd.handler)(&inv.args, &mut ctx) {
resp.ok = false;
resp.error = e;
}
} else {
resp.ok = false;
resp.error = "unknown command".into();
}
resp.submit = pending_submit.take().unwrap_or_default();
let body = pxb::encode_command_response(&resp);
pxb::write_frame(
wr,
pxb::TYPE_COMMAND_RESPONSE,
frame.header.flags,
frame.header.id,
&body,
)?;
Ok(())
}
fn serve_tool(
wr: &mut Wr,
frame: &pxb::Frame,
tools: &mut [Tool],
rt: &tokio::runtime::Runtime,
) -> Result<(), Error> {
let inv = pxb::decode_tool_invoke(&frame.body)?;
let tr = match tools.iter_mut().find(|t| t.name == inv.name) {
Some(tool) => match rt.block_on((tool.execute)(&inv.args)) {
Ok(res) => pxb::ToolResultMsg {
content: res.content,
detail: res.detail,
output: res.output,
..Default::default()
},
Err(e) => tool_error(e),
},
None => tool_error("unknown tool"),
};
let body = pxb::encode_tool_result(&tr);
pxb::write_frame(
wr,
pxb::TYPE_TOOL_RESULT,
frame.header.flags,
frame.header.id,
&body,
)?;
Ok(())
}
fn serve_tool_detail(wr: &mut Wr, frame: &pxb::Frame, tools: &mut [Tool]) -> Result<(), Error> {
let inv = pxb::decode_tool_invoke(&frame.body)?;
let detail = tools
.iter_mut()
.find(|t| t.name == inv.name)
.and_then(|t| t.detail_from_args.as_mut())
.map(|f| f(&inv.args))
.unwrap_or_default();
let body = pxb::encode_tool_detail_result(&pxb::ToolDetailResult { detail });
pxb::write_frame(
wr,
pxb::TYPE_TOOL_DETAIL_RESULT,
frame.header.flags,
frame.header.id,
&body,
)?;
Ok(())
}
fn serve_intercept(wr: &mut Wr, frame: &pxb::Frame, handlers: &mut Handlers) -> Result<(), Error> {
let req = pxb::decode_intercept_req(&frame.body)?;
let resp = handle_intercept(req, handlers);
let body = pxb::encode_intercept_resp(&resp);
pxb::write_frame(
wr,
pxb::TYPE_INTERCEPT_RESPONSE,
frame.header.flags,
frame.header.id,
&body,
)?;
Ok(())
}
fn handle_intercept(req: pxb::InterceptReq, handlers: &mut Handlers) -> pxb::InterceptResp {
let mut resp = pxb::InterceptResp::default();
match pxb::Event::from_code(req.event) {
pxb::Event::ToolCall => {
let Some(f) = handlers.tool_call.as_mut() else {
return resp;
};
let Some(r) = f(ToolCallEvent {
tool_name: req.tool_name,
tool_call_id: req.tool_call_id,
input: req.input,
}) else {
return resp;
};
resp.block = r.block;
resp.reason = r.reason;
resp.context = r.context;
if let Some(v) = r.input {
resp.input = v;
}
}
pxb::Event::ToolResult => {
let Some(f) = handlers.tool_result.as_mut() else {
return resp;
};
let Some(r) = f(ToolResultEvent {
tool_name: req.tool_name,
tool_call_id: req.tool_call_id,
input: req.input,
content: req.content,
is_error: req.is_error,
err: req.err_text,
}) else {
return resp;
};
resp.context = r.context;
resp.stop = r.stop;
resp.reason = r.reason;
if let Some(v) = r.content {
resp.content = v;
}
}
pxb::Event::BeforeAgentStart => {
let Some(f) = handlers.before_agent_start.as_mut() else {
return resp;
};
let Some(r) = f(BeforeAgentStartEvent { prompt: req.prompt }) else {
return resp;
};
resp.system_prompt_append = r.system_prompt_append;
if let Some(v) = r.prompt {
resp.prompt = v;
}
}
pxb::Event::SessionBeforeSwitch => {
let Some(f) = handlers.session_before_switch.as_mut() else {
return resp;
};
let Some(r) = f(SessionBeforeSwitchEvent {
reason: req.reason,
target_session_id: req.target_id,
}) else {
return resp;
};
resp.cancel = r.cancel;
resp.reason = r.reason;
resp.toast = r.toast;
}
pxb::Event::UserInput => {
let Some(f) = handlers.user_input.as_mut() else {
return resp;
};
let Some(r) = f(UserInputEvent { text: req.prompt }) else {
return resp;
};
resp.handled = r.handled;
resp.reason = r.reason;
if let Some(v) = r.text {
resp.prompt = v;
}
}
pxb::Event::TurnStopping => {
let Some(f) = handlers.turn_stopping.as_mut() else {
return resp;
};
let Some(r) = f(TurnStoppingEvent {
turn_index: req.turn_index,
}) else {
return resp;
};
resp.continue_ = r.continue_;
resp.prompt = r.message;
resp.reason = r.reason;
}
_ => {}
}
resp
}
fn apply_session_meta(host: &mut HostInfo, meta: pxb::SessionMeta) {
if !meta.session_id.is_empty() {
host.session_id = meta.session_id;
}
if !meta.cwd.is_empty() {
host.cwd = meta.cwd;
}
}
fn dispatch_event(handlers: &mut EventHandlers, ev: pxb::EventNotify) {
if let Some(handler) = handlers.get_mut(&ev.event) {
handler(ev);
}
}
fn tool_error(message: impl Into<String>) -> pxb::ToolResultMsg {
let message = message.into();
pxb::ToolResultMsg {
is_error: true,
error: message.clone(),
content: message,
..Default::default()
}
}
pub struct Context<'a> {
pub cwd: String,
pub session_id: String,
pub has_ui: bool,
rd: &'a mut Rd,
wr: &'a mut Wr,
host: &'a mut HostInfo,
pending_submit: &'a mut Option<String>,
next_host_id: &'a mut u32,
events: &'a mut EventHandlers,
}
impl Context<'_> {
pub fn notify(&mut self, level: &str, message: &str) {
let body = pxb::encode_notify(&pxb::NotifyMsg {
level: level.into(),
message: message.into(),
..Default::default()
});
let _ = pxb::write_frame(self.wr, pxb::TYPE_NOTIFY, 0, 0, &body);
}
pub fn set_status(&mut self, text: &str) {
let body = pxb::encode_notify(&pxb::NotifyMsg {
status: text.into(),
status_set: true,
..Default::default()
});
let _ = pxb::write_frame(self.wr, pxb::TYPE_NOTIFY, 0, 0, &body);
}
pub fn submit(&mut self, text: &str) {
*self.pending_submit = Some(text.to_string());
}
pub fn send_user_message(&mut self, text: &str) {
if text.is_empty() {
return;
}
let body = pxb::encode_host_request(&pxb::HostRequest {
method: "send_user_message".into(),
arg: text.into(),
});
let _ = pxb::write_frame(self.wr, pxb::TYPE_HOST_REQUEST, 0, 0, &body);
}
pub fn confirm(&mut self, title: &str, message: &str) -> ConfirmReply {
self.confirm_opts(ConfirmRequest {
title: title.into(),
message: message.into(),
..Default::default()
})
}
pub fn confirm_opts(&mut self, req: ConfirmRequest) -> ConfirmReply {
let Some(id) = self.send_host_request("confirm", &confirm_request_json(&req)) else {
return ConfirmReply::default();
};
loop {
let Ok(f) = pxb::read_frame(self.rd) else {
return ConfirmReply::default();
};
if let Some(reply) = self.nested_reply(f, id) {
return reply;
}
}
}
fn send_host_request(&mut self, method: &str, arg: &str) -> Option<u32> {
*self.next_host_id = self.next_host_id.wrapping_add(1);
let id = *self.next_host_id;
let body = pxb::encode_host_request(&pxb::HostRequest {
method: method.into(),
arg: arg.into(),
});
if pxb::write_frame(self.wr, pxb::TYPE_HOST_REQUEST, pxb::FLAG_HAS_ID, id, &body).is_err() {
return None;
}
Some(id)
}
fn nested_reply(&mut self, f: pxb::Frame, want_id: u32) -> Option<ConfirmReply> {
match pxb::FrameType::from_u16(f.header.typ) {
pxb::FrameType::HostResult => {
if f.header.flags & pxb::FLAG_HAS_ID == 0 || f.header.id != want_id {
return None;
}
let Ok(res) = pxb::decode_host_result(&f.body) else {
return Some(ConfirmReply::default());
};
Some(ConfirmReply { ok: res.ok })
}
pxb::FrameType::SessionMeta => {
if let Ok(meta) = pxb::decode_session_meta(&f.body) {
apply_session_meta(self.host, meta);
}
None
}
pxb::FrameType::Event => {
if let Ok(ev) = pxb::decode_event_notify(&f.body) {
dispatch_event(self.events, ev);
}
None
}
pxb::FrameType::Shutdown => {
let _ = pxb::write_frame(self.wr, pxb::TYPE_SHUTDOWN_ACK, 0, 0, &[]);
Some(ConfirmReply::default())
}
_ => None,
}
}
}
fn confirm_request_json(req: &ConfirmRequest) -> String {
serde_json::to_string(req)
.expect("ConfirmRequest holds only strings/bool; serialization cannot fail")
}
fn push_unique(xs: &mut Vec<pxb::Event>, v: pxb::Event) {
if !xs.contains(&v) {
xs.push(v);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confirm_json_matches_go_field_names() {
let req = ConfirmRequest {
title: "Delete?".into(),
message: "Remove /tmp/x".into(),
yes: "Delete".into(),
no: "Cancel".into(),
danger: true,
};
assert_eq!(
confirm_request_json(&req),
r#"{"Title":"Delete?","Message":"Remove /tmp/x","Yes":"Delete","No":"Cancel","Danger":true}"#
);
}
#[test]
fn confirm_json_escapes_quotes_and_controls() {
let req = ConfirmRequest {
title: "say \"hi\"\n".into(),
..Default::default()
};
assert_eq!(
confirm_request_json(&req),
r#"{"Title":"say \"hi\"\n","Message":"","Yes":"","No":"","Danger":false}"#
);
}
#[test]
fn push_unique_keeps_first() {
let mut xs = Vec::new();
push_unique(&mut xs, pxb::Event::ToolCall);
push_unique(&mut xs, pxb::Event::ToolCall);
push_unique(&mut xs, pxb::Event::AgentEnd);
assert_eq!(xs, vec![pxb::Event::ToolCall, pxb::Event::AgentEnd]);
}
}