blob: e6d220ea269c58176f70482baf22ee026ed5dfe3 [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 super::*;
use crate::tools::ToolRequest;
use maka_agent_facts::tool_call::ToolRejection;
use maka_agent_facts::tools::*;
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
#[serde(
tag = "mode",
rename_all = "snake_case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
enum Input {
Create {
title: String,
intent_body: String,
schedule: TaskSchedule,
timezone: String,
#[serde(default)]
misfire: TaskMisfire,
effect: Target,
max_fires: Option<u32>,
expires_at: Option<i64>,
},
List {
after: Option<String>,
},
Pause {
task_id: String,
revision: u64,
},
Resume {
task_id: String,
revision: u64,
},
Delete {
task_id: String,
revision: u64,
},
}
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum Target {
Reminder,
Session,
AgentRun,
}
impl Scheduler {
pub fn tools(self: &Arc<Self>) -> Vec<ToolRegistration> {
vec![ToolRegistration {
definition: ToolDefinition { provider: None, name: "ScheduledTask".into(),
description: "Manage this conversation's scheduled tasks. Create saves a paused proposal requiring user approval of its exact execution in Scheduled tasks; model calls cannot grant background access. Session and agent_run use this conversation's workspace and model. Times are Unix milliseconds; timezone is an IANA name. List returns tasks and revisions. Resume requires existing user approval.".into(),
input_schema: serde_json::to_value(schemars::schema_for!(ToolRequest<Input>)).expect("task tool schema"),
output_schema: None, freeform: None },
nesting: ToolNesting::DirectOnly, semantics: ToolSemantics::ExclusiveStep,
handler: ToolHandler::Prepared(self.clone()),
}]
}
}
impl ToolPreparer for Scheduler {
fn names(&self) -> Vec<String> {
vec!["ScheduledTask".into()]
}
fn prepare(
&self,
name: String,
input: Value,
context: ToolCallContext,
cancellation: CancellationToken,
) -> PreparationFuture {
let owner = self.link.get().and_then(Weak::upgrade);
Box::pin(async move {
if name != "ScheduledTask" || owner.is_none() {
return Err(ToolRejection::Unavailable);
}
if cancellation.is_cancelled() {
return Err(ToolRejection::Cancelled);
}
let input = serde_json::from_value::<ToolRequest<Input>>(input)
.map_err(|error| ToolRejection::InvalidInput {
message: error.to_string(),
})?
.request;
let owner = owner.expect("checked owner");
Ok(PreparedEffect::new(move |cancellation| {
Box::pin(async move {
if cancellation.is_cancelled() {
return Err(failed("Scheduled task cancelled"));
}
let runs = owner.runs.lock().await;
if runs.closing || !owner.settings.scheduled_tasks {
return Err(failed("Scheduled tasks unavailable"));
}
let captured = owner.clone();
let job = owner
.workers
.spawn(async move { captured.scheduled_tool(input, context).await });
drop(runs);
let value = job.await.map_err(failed)?.map_err(failed)?;
Ok(value.into())
})
}))
})
}
}
impl Owner {
async fn scheduled_tool(
&self,
input: Input,
context: ToolCallContext,
) -> Result<Value, String> {
let _gate = self.scheduler.gate.lock().await;
self.session(&context.invocation.session_id).await?;
let boundary = self
.store
.invocation_boundary(
&context.invocation.session_id,
&context.invocation.invocation_id,
)
.await
.map_err(reason)?
.ok_or("scheduler_invocation_unavailable")?
.0;
if boundary.invocation != context.invocation
|| matches!(
boundary.state,
maka_event_log::turns::InvocationState::Ended { .. }
)
{
return Err("scheduler_invocation_closed".into());
}
let identity = hex::encode(Sha256::digest(
serde_json::to_vec(&(&context.invocation, &context.operation_id)).map_err(reason)?,
));
let key = format!("tool:{identity}");
let fingerprint = hex::encode(Sha256::digest(serde_json::to_vec(&input).map_err(reason)?));
if let Some(record) = self
.scheduler
.storage
.read(key.clone())
.await
.map_err(reason)?
{
let receipt = record.data.value().ok_or("scheduler_receipt_invalid")?;
if receipt["fingerprint"] != fingerprint {
return Err("scheduler_receipt_conflict".into());
}
return Ok(receipt["result"].clone());
}
let mut catalog = self.scheduler.load().await?;
if let Input::List { after } = &input {
let mut tasks = catalog.plans.values().filter(|saved| after.as_ref().is_none_or(|after| saved.plan.task.id > *after) && matches!(&saved.plan.task.created_by, Creator::Agent { session_id } if session_id == &context.invocation.session_id))
.take(21).map(Saved::summary).collect::<Vec<_>>();
let more = tasks.len() > 20;
tasks.truncate(20);
let next = more.then(|| tasks.last().expect("nonempty task page").id.clone());
return Ok(serde_json::json!({ "tasks": tasks, "nextCursor": next }));
}
let (id, plan) = match input {
Input::Create {
title,
intent_body,
schedule,
timezone,
misfire,
effect,
max_fires,
expires_at,
} => {
let effect = if matches!(effect, Target::Reminder) {
ScheduledEffect::Reminder
} else {
let runs = self.runs.lock().await;
let control = runs
.controls
.get(&context.invocation.session_id)
.filter(|control| control.invocation == context.invocation)
.ok_or("scheduler_invocation_closed")?;
let accepted = control
.basis
.model
.as_ref()
.ok_or("scheduler_model_unavailable")?;
let catalog = self.configuration.catalog().await.map_err(reason)?;
let connection = catalog
.connections
.iter()
.find(|row| row.connection_id == accepted.binding.connection_id)
.ok_or("scheduler_model_unavailable")?;
let model = ModelSelection {
connection_id: connection.connection_id.clone(),
revision: connection.revision,
model_id: accepted.binding.model.clone(),
};
let session = self.session(&context.invocation.session_id).await?;
let target = ScheduledTarget {
session_id: session.id,
workspace: session.configuration.workspace,
permissions: session.configuration.permissions,
boundary_revision: session.configuration.boundary_revision,
};
let thinking_level = accepted.thinking_level;
if matches!(effect, Target::Session) {
ScheduledEffect::Session {
target,
model,
thinking_level,
}
} else {
ScheduledEffect::AgentRun {
target,
model,
thinking_level,
}
}
};
let id = format!("task-{identity}");
let mut plan = Plan::create(
id.clone(),
maka_scheduler::task::Create {
title,
intent_body,
schedule,
effect,
max_fires,
expires_at,
},
Creator::Agent {
session_id: context.invocation.session_id,
},
timezone,
now() as i64,
)
.map_err(reason)?;
plan.misfire = misfire;
plan = plan.pause(now() as i64);
(id, Some(plan))
}
Input::Pause {
ref task_id,
revision,
}
| Input::Resume {
ref task_id,
revision,
}
| Input::Delete {
ref task_id,
revision,
} => {
let saved = catalog.plans.get(task_id).ok_or("scheduler_task_missing")?;
if saved.revision != revision {
return Err("scheduler_revision_changed".into());
}
if !matches!(&saved.plan.task.created_by, Creator::Agent { session_id } if session_id == &context.invocation.session_id)
{
return Err("scheduler_task_outside_conversation".into());
}
let plan = match input {
Input::Pause { .. } => Some(saved.plan.pause(now() as i64)),
Input::Resume { .. } => {
let Some(approval) = &saved.plan.authorization else {
return Err("scheduler_user_approval_required".into());
};
if let Approval::Execution { basis } = approval {
self.validate_background(basis).await?;
}
Some(saved.plan.resume(now() as i64).map_err(reason)?)
}
_ => {
if saved.plan.pending.is_some() {
return Err("scheduler_trigger_pending".into());
}
None
}
};
(task_id.clone(), plan)
}
Input::List { .. } => unreachable!(),
};
let revision = catalog.plans.get(&id).map_or(1, |saved| saved.revision + 1);
let result = serde_json::json!({ "taskId": id, "revision": revision, "approved": plan.as_ref().is_some_and(|p| p.authorization.is_some()) });
let receipt = Mutation {
key,
expected_revision: None,
data: Data::Present(
serde_json::json!({ "fingerprint": fingerprint, "result": result }),
),
};
self.scheduler
.commit(&mut catalog, &id, plan, Some(receipt))
.await?;
self.scheduler.wake.notify_one();
Ok(result)
}
}
fn failed(error: impl std::fmt::Display) -> ToolError {
ToolError::Failed(error.to_string())
}