Skip to content
Closed
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
9 changes: 8 additions & 1 deletion Native/OptChat/optchat-chief/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1171,7 +1171,14 @@ In a turn that asks, the turn session runs with acpmux policy `ask` instead of
card's buttons (`question.answer` on the card in the conversation it was
posted in, a side conversation included; only the owner's answer counts, and
another participant's answer closes that card, so the owner gets a fresh
one) or with a message `allow` or `deny`. The line shows a line break as
one) or with a typed answer, read loosely (case, punctuation and emoji
ignored): `allow`, `ALLOW...`, `yes`, `ok`, πŸ‘ or βœ… answer the oldest card;
`deny`, `no`, πŸ‘Ž or ❌ deny it; `allow all` / `deny all` answer every pending
card; `allow for this turn` also lets the rest of the turn run; `allow for
1h` (or `30m`, `2 hours`, at most a day) also lets the owner's own gated
turns (a paired device, Home) run until then, never another participant's.
A message with anything else in it ("yes, and check CI") is a message. One
line confirms what was answered ("Allowed: run `ls`"). The line shows a line break as
`⏎` and a cut as "… (cut)". A client that draws no card shows its line and
options, and an owner that refuses the part gets the same two lines and the
arguments as text.
Expand Down
130 changes: 123 additions & 7 deletions Native/OptChat/optchat-chief/src/approval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,22 +89,120 @@ pub fn is_memory_tool(request: &Value) -> bool {
MEMORY_TOOLS.contains(&tool_name(request).as_str())
}

/// A person's answer in a chat message: `allow` or `deny` (any case, alone).
/// A person's answer: allow or deny.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
Allow,
Deny,
}

impl Answer {
pub fn parse(text: &str) -> Option<Answer> {
match text.trim().to_ascii_lowercase().as_str() {
"allow" => Some(Answer::Allow),
"deny" => Some(Answer::Deny),
_ => None,
/// How far a typed answer reaches.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scope {
/// The conversation's oldest pending request.
Oldest,
/// Every pending request of the conversation.
Pending,
/// Every pending request, and the running turn's later ones.
Turn,
/// Every pending request, and for this many ms the later requests of
/// the owner's own gated turns (a paired device, Home).
For(u64),
}

/// A chat message that answers approvals: "allow", "ALLOW...", "yes",
/// "πŸ‘", "allow all", "allow for this turn", "allow for 1h". Anything
/// else in the message makes it a message, not an answer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Reply {
pub answer: Answer,
pub scope: Scope,
}

/// The longest window `allow for ...` opens.
const MAX_WINDOW_MS: u64 = 24 * 60 * 60 * 1000;

impl Reply {
pub fn parse(text: &str) -> Option<Reply> {
let mut normal = String::new();
for c in text.chars() {
match c {
'\u{1f44d}' | '\u{2705}' | '\u{2714}' => normal.push_str(" allow "),
'\u{1f44e}' | '\u{274c}' | '\u{1f6ab}' => normal.push_str(" deny "),
c if c.is_alphanumeric() => normal.extend(c.to_lowercase()),
_ => normal.push(' '),
}
}
let words: Vec<&str> = normal.split_whitespace().collect();
let (first, rest) = words.split_first()?;
let answer = match *first {
"allow" | "allowed" | "approve" | "approved" | "yes" | "y" | "yep" | "yeah" | "ok"
| "okay" | "sure" => Answer::Allow,
"deny" | "denied" | "reject" | "no" | "n" | "nope" => Answer::Deny,
_ => return None,
};
let mut scope = Scope::Oldest;
let mut dangling_for = false;
let mut k = 0;
while k < rest.len() {
let word = rest[k];
k += 1;
match word {
"please" | "pls" | "thanks" | "it" | "to" | "of" | "the" | "this" | "rest" => {}
"all" | "them" | "everything" => {
if scope == Scope::Oldest {
scope = Scope::Pending;
}
}
"for" => dangling_for = true,
"turn" => {
scope = Scope::Turn;
dangling_for = false;
}
_ => {
// A duration: "1h", "30m", "2 hours", "an hour".
let digits = word
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(word.len());
let (count, unit) = if matches!(word, "a" | "an" | "one") {
k += 1;
(1, rest.get(k - 1).copied())
} else if digits == 0 {
return None;
} else if digits == word.len() {
k += 1;
(word.parse::<u64>().ok()?, rest.get(k - 1).copied())
} else {
(word[..digits].parse::<u64>().ok()?, Some(&word[digits..]))
};
let unit_ms: u64 = match unit? {
"h" | "hr" | "hrs" | "hour" | "hours" => 60 * 60 * 1000,
"m" | "min" | "mins" | "minute" | "minutes" => 60 * 1000,
_ => return None,
};
// "1h 30m" adds up.
let before = match scope {
Scope::For(ms) => ms,
_ => 0,
};
let ms = before.saturating_add(count.saturating_mul(unit_ms));
scope = Scope::For(ms.min(MAX_WINDOW_MS));
dangling_for = false;
}
}
}
if dangling_for || scope == Scope::For(0) {
return None;
}
// Deny reaches only what is pending.
if answer == Answer::Deny && scope != Scope::Oldest {
scope = Scope::Pending;
}
Some(Reply { answer, scope })
}
}

impl Answer {
pub fn as_str(self) -> &'static str {
match self {
Answer::Allow => "allow",
Expand Down Expand Up @@ -164,6 +262,24 @@ pub fn describe(tool: &str, input: Option<&Value>) -> String {
(Some(id), None) => format!("tell subagent {id}"),
_ => "tell a subagent".to_owned(),
},
"mcp__optchat__send" => {
let files = input
.and_then(|i| i.get("attachments"))
.and_then(Value::as_array)
.map_or(0, Vec::len);
let text =
field("text").map(|t| format!("send you \u{201c}{}\u{201d}", one_line(&t, 80)));
match (text, files) {
(Some(t), 0) => t,
(Some(t), 1) => format!("{t} with a file from this Mac"),
(Some(t), n) => format!("{t} with {n} files from this Mac"),
(None, 0) => "send you a message".to_owned(),
(None, _) => "send you files from this Mac".to_owned(),
}
}
"mcp__optchat__control" if field("action").is_none_or(|a| a == "status") => {
"check on its subagents".to_owned()
}
"mcp__optchat__control" => match (field("action"), field("id")) {
(Some(a), Some(id)) => format!("{a} subagent {id}"),
(Some(a), None) => format!("change subagents ({a})"),
Expand Down
110 changes: 98 additions & 12 deletions Native/OptChat/optchat-chief/src/brain/approvals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ use cmux_conversation::{Message, Origin};
use serde_json::{Value, json};

use super::{Brain, reply_entry};
use crate::approval::{Answer, Pending, is_memory_tool, option_for, question, tool_name};
use crate::approval::{
Answer, Pending, Reply, Scope, describe, is_memory_tool, option_for, question, tool_name,
};

/// A turn that asks again for a request it was denied this many times is
/// stopped: the model is not left looping on a refused tool.
Expand Down Expand Up @@ -88,6 +90,11 @@ impl Brain {
}
return;
}
// The owner allowed the rest of this turn, or for a while.
if let Some(standing) = self.standing_allow() {
self.respond(&pending, Answer::Allow, standing, None);
return;
}
let key = format!(
"approval:{}:{}",
self.state.turn.as_ref().map_or("", |t| t.key.as_str()),
Expand All @@ -96,6 +103,22 @@ impl Brain {
self.ask_person(pending, &key);
}

/// The trace's approver when an earlier answer of the owner covers the
/// running turn's requests: "allow for this turn", or an "allow for 1h"
/// window, which covers only the owner's own gated turns (a paired
/// device, Home), never another participant's.
fn standing_allow(&self) -> Option<&'static str> {
if self.turn_allowed {
return Some("the owner (allowed for this turn)");
}
let owners = matches!(
self.turn_why,
Some(crate::approval::Why::Device(_) | crate::approval::Why::Home)
);
let open = self.allow_until.is_some_and(|until| (self.clock)() < until);
(owners && open).then_some("the owner (allowed for a while)")
}

/// The name of the paired device that sent one of `items` (its
/// participant's display name), else "another device".
pub(super) fn device_name(&self, items: &[super::Queued]) -> String {
Expand Down Expand Up @@ -332,30 +355,83 @@ impl Brain {
.any(|p| p.conversation.as_deref() == conversation)
}

/// A person in `conversation` (None: main) answered that conversation's
/// oldest pending approval with `message`; another conversation's
/// approvals are never answered from here.
/// The owner in `conversation` (None: main) answered with `reply`: the
/// conversation's oldest pending approval, or all of them (and, for
/// "allow for this turn" or "allow for 1h", the later requests too);
/// another conversation's approvals are never answered from here. One
/// short line says what was answered.
pub(super) fn answer_approval(
&mut self,
answer: Answer,
reply: Reply,
message: &Message,
conversation: Option<&str>,
) {
let Some(k) = self
let mut answered = Vec::new();
while let Some(k) = self
.approvals
.iter()
.position(|p| p.conversation.as_deref() == conversation)
else {
return;
};
let Some(pending) = self.approvals.remove(k) else {
{
let Some(pending) = self.approvals.remove(k) else {
break;
};
answered.push(pending);
if reply.scope == Scope::Oldest {
break;
}
}
if answered.is_empty() {
return;
};
}
if reply.answer == Answer::Allow {
match reply.scope {
// Only an answer to the running turn's own request covers the turn,
// never one to a subagent's card left over from earlier.
Scope::Turn => {
if answered.iter().any(|pending| pending.child.is_none()) {
self.turn_allowed = true;
}
}
Scope::For(ms) => self.allow_until = Some((self.clock)().saturating_add(ms)),
Scope::Oldest | Scope::Pending => {}
}
}
let install = message
.origin
.as_ref()
.map(|Origin::Remote { install }| install.clone());
self.respond(&pending, answer, &message.author, install.as_deref());
for pending in &answered {
self.respond(pending, reply.answer, &message.author, install.as_deref());
}
let label = match (reply.answer, reply.scope) {
(Answer::Deny, _) => "Denied".to_owned(),
(Answer::Allow, Scope::Turn) => "Allowed for this turn".to_owned(),
(Answer::Allow, Scope::For(ms)) => format!("Allowed for {}", window(ms)),
(Answer::Allow, _) => "Allowed".to_owned(),
};
let what = answered
.iter()
.map(|p| {
let action = describe(&p.tool, p.request.pointer("/toolCall/rawInput"));
match &p.child {
Some(child) => format!("{child}: {action}"),
None => action,
}
})
.collect::<Vec<_>>()
.join("; ");
let Some(target) = conversation
.map(str::to_owned)
.or_else(|| self.state.conversation.clone())
else {
return;
};
let key = format!("approval-answered:{}", message.id);
self.state
.outbox
.push(reply_entry(target, &key, &format!("{label}: {what}")));
self.save();
self.flush_outbox();
}

/// Denies the running turn's pending approvals (`why` is the trace's
Expand Down Expand Up @@ -417,3 +493,13 @@ impl Brain {
}
}
}

/// A window's length the way a person writes it: "1h", "30m", "1h 30m".
fn window(ms: u64) -> String {
let minutes = ms / 60_000;
match (minutes / 60, minutes % 60) {
(0, m) => format!("{m}m"),
(h, 0) => format!("{h}h"),
(h, m) => format!("{h}h {m}m"),
}
}
6 changes: 3 additions & 3 deletions Native/OptChat/optchat-chief/src/brain/inbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,12 @@ impl Brain {
// An answer to a pending approval of the running turn: it
// answers, is logged, and is not a new message (it neither
// queues nor interrupts the turn).
match crate::approval::Answer::parse(&text) {
Some(answer)
match crate::approval::Reply::parse(&text) {
Some(reply)
if self.settings.membership.is_owner_message(summary, &message)
&& self.has_approval(None) =>
{
self.answer_approval(answer, &message, None);
self.answer_approval(reply, &message, None);
if let Err(e) = self.chat.append(optchat_core::Kind::User, &text) {
(self.log)(&format!("logging an approval failed: {e}"));
}
Expand Down
9 changes: 9 additions & 0 deletions Native/OptChat/optchat-chief/src/brain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,13 @@ pub struct Brain {
/// Requests of the running turn denied again because they repeat one
/// already denied (`approvals::REPEATS_BEFORE_STOP` stops the turn).
repeat_denials: u32,
/// The owner answered "allow for this turn": the running turn's later
/// requests run without asking (until it ends or another participant
/// joins it).
turn_allowed: bool,
/// The owner answered "allow for 1h": until this wall clock (ms), the
/// owner's own gated turns (a paired device, Home) run without asking.
allow_until: Option<u64>,
/// A remote-origin turn was stopped for a newer message: the turn that
/// answers both keeps its origin.
remote_taint: bool,
Expand Down Expand Up @@ -804,6 +811,8 @@ impl Brain {
turn_why: None,
turn_denied: Vec::new(),
repeat_denials: 0,
turn_allowed: false,
allow_until: None,
remote_taint: false,
approvals: VecDeque::new(),
after_turn: None,
Expand Down
6 changes: 3 additions & 3 deletions Native/OptChat/optchat-chief/src/brain/side.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,11 @@ impl Brain {
labelled(summary, &text)
};
let by_owner = self.settings.membership.is_owner_message(summary, &message);
match crate::approval::Answer::parse(&text) {
match crate::approval::Reply::parse(&text) {
// An answer to this conversation's pending approval (only a
// person answers one, never another Chief).
Some(answer) if by_owner && self.has_approval(Some(conversation)) => {
self.answer_approval(answer, &message, Some(conversation));
Some(reply) if by_owner && self.has_approval(Some(conversation)) => {
self.answer_approval(reply, &message, Some(conversation));
if let Err(e) = self.chat.append(optchat_core::Kind::User, &labelled) {
(self.log)(&format!("logging an approval failed: {e}"));
}
Expand Down
4 changes: 4 additions & 0 deletions Native/OptChat/optchat-chief/src/brain/steer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ impl Brain {
{
self.turn_why = Some(why);
}
// "Allow for this turn" was the owner's word on the owner's turn.
if non_owner {
self.turn_allowed = false;
}
self.turn_ask = self.turn_why.is_some();
self.interrupt.set_gate(self.turn_ask);
}
Expand Down
Loading