Skip to content
Merged
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
8 changes: 8 additions & 0 deletions crates/openplotva-app/src/agent_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use time::OffsetDateTime;

pub struct AgentContextTools {
pub direct_draw: Arc<dyn crate::dialog_messages::DirectDrawApiEffects>,
/// Global admins; the direct Draw API is theirs alone.
pub admin_ids: Arc<[i64]>,
pub history: PostgresHistoryStore,
pub memory: PostgresMemoryStore,
pub vip: Arc<dyn crate::payments::VipStatusChecker + Send + Sync>,
Expand Down Expand Up @@ -40,6 +42,12 @@ impl DialogToolbox for AgentContextTools {
};
match step.step.as_str() {
"draw_api" => {
if !self.admin_ids.contains(&ctx.user_id) {
return Ok(ToolResult::failed(
"draw_api_owner_only",
"The direct Draw API is reserved for the bot owner. Use draw_image if this user asked for a picture.",
));
}
let result = self
.direct_draw
.send_direct_draw_api(crate::dialog_messages::DirectDrawApiRequest {
Expand Down
34 changes: 31 additions & 3 deletions crates/openplotva-app/src/dialog_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,8 @@ pub struct DialogMessageUpdateConfig {
pub aifarm_default_max_tokens: i32,
pub aifarm_long_max_tokens: i32,
pub processing_timeout_seconds: i32,
/// Global admins; only they get the `%` Draw API shortcut.
pub admin_ids: Vec<i64>,
}

impl DialogMessageUpdateConfig {
Expand Down Expand Up @@ -1282,6 +1284,7 @@ impl DialogMessageUpdateConfig {
.max(300)
.max(config.llm.history_summary.timeout_seconds)
.max(config.llm.dialog.session_hard_cap_secs.saturating_add(120)),
admin_ids: config.admins.admin_ids.clone(),
}
}
}
Expand Down Expand Up @@ -1610,7 +1613,6 @@ where
.map(ToString::to_string)
.unwrap_or_default();
if parsed.is_addressed
|| crate::rates::is_rates_command_message(message, &config.bot_user)
|| is_bang_draw_shortcut(&first_word_lower)
|| is_bang_song_shortcut(&first_word_lower)
|| is_song_command_for_bot(message, &first_word_lower, &bot_username)
Expand Down Expand Up @@ -1759,7 +1761,6 @@ where
{
parsed.is_addressed = true;
}
parsed.is_addressed |= crate::rates::is_rates_command_message(message, &config.bot_user);
parsed.is_addressed |= is_bang_draw_shortcut(&first_word_lower)
|| direct_song_shortcut_topic(
message,
Expand Down Expand Up @@ -1799,7 +1800,9 @@ where
|| reply_context
.as_ref()
.is_some_and(context_has_editable_image);
let intent = if is_direct_draw_api_shortcut(&first_word_lower) {
let intent = if is_direct_draw_api_shortcut(&first_word_lower)
&& config.admin_ids.contains(&context.meta.sender_id)
{
Some((
"draw_api",
format!("Requested tool: draw_api. Prompt: {}", parsed.rest_text),
Expand Down Expand Up @@ -6526,6 +6529,30 @@ mod tests {
Ok(())
}

#[tokio::test]
async fn draw_api_shortcut_is_only_for_admins() -> Result<(), Box<dyn std::error::Error>> {
let scheduler = SchedulerStub::default();
let config = DialogMessageUpdateConfig {
admin_ids: vec![1],
..test_config()
};
let route = handle_dialog_message_update_or_else(
&scheduler,
&config,
group_message_update("@plotva_bot % red fish")?,
|_| async { Err::<(), std::io::Error>(std::io::Error::other("delegated")) },
)
.await?;
assert!(
matches!(route, DialogMessageUpdateRoute::Scheduled { .. }),
"{route:?}"
);
let metas = scheduler.metas();
assert_eq!(metas[0].requested_tool, "");
assert!(!metas[0].annotation.contains("draw_api"), "{metas:?}");
Ok(())
}

#[tokio::test]
async fn attachment_shortcuts_keep_media_in_agent_context()
-> Result<(), Box<dyn std::error::Error>> {
Expand Down Expand Up @@ -7683,6 +7710,7 @@ mod tests {
aifarm_default_max_tokens: 1024,
aifarm_long_max_tokens: 2048,
processing_timeout_seconds: 720,
admin_ids: vec![99],
}
}

Expand Down
39 changes: 25 additions & 14 deletions crates/openplotva-app/src/dialog_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ use crate::{
agent_runtime::HistorySearcher,
permissions::{ChatPermissionPolicy, ChatPermissionStore},
rate_limits::TaskEnqueueRateLimit,
rates::{RatesFetcher, RatesSideEffectDispatcher, currency_rates_tool},
rates::{
RatesFetcher, RatesSideEffectDispatcher, currency_rates_table_tool, currency_rates_tool,
},
task_queue::TaskQueueDurabilityBarrier,
translate::{TextTranslator, TranslationResult, translate_text_tool},
};
Expand Down Expand Up @@ -2257,7 +2259,7 @@ fn usize_to_i64(value: usize) -> i64 {
#[derive(Clone)]
pub struct AppDialogToolbox<RatesFetcherT, RatesDispatcherT, TranslatorT> {
rates_fetcher: Option<Arc<RatesFetcherT>>,
_rates_dispatcher: Option<Arc<RatesDispatcherT>>,
rates_dispatcher: Option<Arc<RatesDispatcherT>>,
translator: Option<Arc<TranslatorT>>,
queue_status: Option<Arc<dyn QueueStatusProvider>>,
drawing_canceller: Option<Arc<dyn DrawingJobCanceller>>,
Expand All @@ -2283,7 +2285,7 @@ impl<RatesFetcherT, RatesDispatcherT, TranslatorT>
) -> Self {
Self {
rates_fetcher,
_rates_dispatcher: rates_dispatcher,
rates_dispatcher,
translator,
queue_status: None,
drawing_canceller: None,
Expand Down Expand Up @@ -2387,7 +2389,13 @@ where
{
fn currency_rates<'a>(&'a self, req: RatesRequest) -> ToolboxFuture<'a> {
Box::pin(async move {
Ok(currency_rates_tool(self.rates_fetcher.as_deref(), &req.context, &req.pairs).await)
let fetcher = self.rates_fetcher.as_deref();
Ok(match self.rates_dispatcher.as_deref() {
Some(dispatcher) => {
currency_rates_table_tool(fetcher, dispatcher, &req.context, &req.pairs).await
}
None => currency_rates_tool(fetcher, &req.context, &req.pairs).await,
})
})
}

Expand Down Expand Up @@ -4035,7 +4043,7 @@ mod tests {
}

#[tokio::test]
async fn app_dialog_toolbox_dispatches_currency_rates_to_llm() -> Result<(), ToolboxError> {
async fn app_dialog_toolbox_sends_the_rates_table_as_the_reply() -> Result<(), ToolboxError> {
let dispatcher = Arc::new(RatesDispatcherStub::default());
let toolbox = AppDialogToolbox::new(
Some(Arc::new(RatesFetcherStub)),
Expand All @@ -4052,17 +4060,20 @@ mod tests {
})
.await?;

assert_eq!(result.status, TOOL_RESULT_STATUS_OK);
assert!(!result.no_reply);
assert!(result.side_effect.is_none());
assert!(result.message.contains("💵 USD/RUB=90.00 ₽"));
assert!(result.message.contains("₿ BTC/USD=100000 $"));
assert!(result.message.contains("🛢 Brent=76.50 $"));
assert!(result.no_reply);
assert_eq!(
result.data.as_ref().expect("data")["rates_text"],
result.message
result
.side_effect
.as_ref()
.map(|effect| effect.kind.as_str()),
Some(crate::rates::RATES_MESSAGE_KIND)
);
assert_eq!(dispatcher.calls().len(), 0);
assert!(result.message.contains("💵 USD/RUB=90.00 ₽"));
let calls = dispatcher.calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0.message_id, 11);
assert!(calls[0].1.contains("Alice"), "{}", calls[0].1);
assert!(calls[0].1.contains("USD/RUB"), "{}", calls[0].1);
Ok(())
}

Expand Down
58 changes: 49 additions & 9 deletions crates/openplotva-app/src/dialog_turn/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1484,15 +1484,15 @@ where
SessionBatchDisposition::ContinueForResults
| SessionBatchDisposition::ContinueWithoutFinal => {}
SessionBatchDisposition::CompleteWithSideEffect => {
let delivered_images = step.tool_calls.iter().zip(&batch_results)
.filter(|(call, result)| direct_draw_delivered(&call.step.step, result))
let delivered_replies = step.tool_calls.iter().zip(&batch_results)
.filter(|(call, result)| tool_delivered_its_own_reply(&call.step.step, result))
.count();
if delivered_images > 0 {
if delivered_replies > 0 {
report.sent_answer = true;
append_session_sent_marker(queue, ctx.item_id, disposition_now).await;
return std::ops::ControlFlow::Break(TurnResolution {
outcome: TurnOutcome::Sent {
parts: sent.total_count + delivered_images,
parts: sent.total_count + delivered_replies,
side_effect_tickets: ticket_ids(&side_effect_tickets),
},
disposition: JobDisposition::Complete,
Expand Down Expand Up @@ -1683,10 +1683,24 @@ fn queued_generation_side_effect(result: &ToolResult) -> Option<QueuedSideEffect
})
}

fn direct_draw_delivered(name: &str, result: &ToolResult) -> bool {
name == "draw_api"
&& result.status == openplotva_dialog::TOOL_RESULT_STATUS_OK
&& result.data.as_ref().and_then(|data| data.get("delivered")) == Some(&Value::Bool(true))
/// A tool that already delivered its own message to the chat: a direct draw or the
/// rates table. The turn needs no further text after it.
fn tool_delivered_its_own_reply(name: &str, result: &ToolResult) -> bool {
match name {
"draw_api" => {
result.status == openplotva_dialog::TOOL_RESULT_STATUS_OK
&& result.data.as_ref().and_then(|data| data.get("delivered"))
== Some(&Value::Bool(true))
}
openplotva_dialog::STEP_CURRENCY_RATES => {
result.status == openplotva_dialog::TOOL_RESULT_STATUS_EXECUTED
&& result.side_effect.as_ref().is_some_and(|effect| {
effect.kind == crate::rates::RATES_MESSAGE_KIND
&& effect.state.eq_ignore_ascii_case("executed")
})
}
_ => false,
}
}

fn session_batch_disposition(
Expand All @@ -1709,7 +1723,7 @@ fn session_batch_disposition(
return SessionBatchDisposition::ContinueForResults;
};
if queued_generation_side_effect(result).is_some()
|| direct_draw_delivered(&call.step.step, result)
|| tool_delivered_its_own_reply(&call.step.step, result)
{
completed_generation = true;
} else {
Expand Down Expand Up @@ -2859,6 +2873,32 @@ mod tests {
),
SessionBatchDisposition::ContinueForResults
);
let rates_table = |state: &str| ToolResult {
status: openplotva_dialog::TOOL_RESULT_STATUS_EXECUTED.to_owned(),
side_effect: Some(openplotva_dialog::ToolSideEffect {
kind: crate::rates::RATES_MESSAGE_KIND.to_owned(),
state: state.to_owned(),
..openplotva_dialog::ToolSideEffect::default()
}),
..ToolResult::default()
};
assert_eq!(
session_batch_disposition(
&[call(openplotva_dialog::STEP_CURRENCY_RATES)],
&[rates_table("executed")],
),
SessionBatchDisposition::CompleteWithSideEffect
);
assert_eq!(
session_batch_disposition(
&[call(openplotva_dialog::STEP_CURRENCY_RATES)],
&[ToolResult::failed(
"currency_rates_failed",
"rates fetch failed"
)],
),
SessionBatchDisposition::ContinueForResults
);
}

struct ParallelMediaToolbox {
Expand Down
Loading
Loading