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
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ pub(crate) fn resolve_builtin_input_schema_ref(reference: &str) -> Option<Value>
},
"prompt": {
"type": "string",
"description": "Prompt submitted when the trigger fires. Runtime validation caps UTF-8 content at 32768 bytes."
"description": "Prompt submitted when the trigger fires. Runtime validation caps UTF-8 content at 32768 bytes. Do not embed delivery routing here; when the user asks to send routine or trigger results through an outbound product/channel, first select the target through the visible outbound delivery target capabilities, then create the trigger."
},
"cron": { "type": "string", "description": "Five-, six-, or seven-field cron expression; fire cadence must be at least one minute" },
"timezone": { "type": "string", "description": "IANA timezone name for cron evaluation (e.g. 'America/New_York', 'Europe/London', 'UTC'). The cron expression is evaluated in this timezone; fire times are stored and compared in UTC. If the user's timezone is already known from the conversation or their settings, use it without asking; if unknown, ask the user before creating the trigger. Never silently assume UTC — a trigger that fires at the wrong local time is worse than no trigger." }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ pub const TRIGGER_CREATE_CAPABILITY_ID: &str = "builtin.trigger_create";
pub const TRIGGER_LIST_CAPABILITY_ID: &str = "builtin.trigger_list";
pub const TRIGGER_REMOVE_CAPABILITY_ID: &str = "builtin.trigger_remove";

const TRIGGER_CREATE_DESCRIPTION: &str = "Create a caller-scoped scheduled trigger. If the user asks for routine or trigger results to be sent through an outbound product or channel, use the visible outbound delivery target capabilities to select that delivery target before creating the trigger; delivery routing is not encoded in this input.";

pub(super) fn manifests() -> Result<Vec<CapabilityManifest>, ExtensionError> {
Ok(vec![
first_party_capability_manifest(
TRIGGER_CREATE_CAPABILITY_ID,
"Create a caller-scoped scheduled trigger",
TRIGGER_CREATE_DESCRIPTION,
vec![EffectKind::DispatchCapability, EffectKind::ExternalWrite],
PermissionMode::Ask,
resource_profile(),
Expand Down
25 changes: 25 additions & 0 deletions crates/ironclaw_host_runtime/tests/tool_surface_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,31 @@ async fn visible_surface_resolves_builtin_first_party_input_schema_refs() {
assert_schema_has_property(&surface, "builtin.skill_install", "url");
assert_schema_has_property(&surface, "builtin.skill_install", "name");

let trigger_create = surface
.capabilities
.iter()
.find(|capability| capability.descriptor.id == capability_id("builtin.trigger_create"))
.expect("builtin.trigger_create should be visible");
assert!(
trigger_create
.descriptor
.description
.contains("outbound delivery target capabilities"),
"trigger_create description should point the model at delivery target selection"
);
let trigger_prompt_description = trigger_create
.descriptor
.parameters_schema
.get("properties")
.and_then(|properties| properties.get("prompt"))
.and_then(|property| property.get("description"))
.and_then(serde_json::Value::as_str)
.expect("trigger prompt description should be present");
assert!(
trigger_prompt_description.contains("first select the target"),
"trigger_create prompt schema should steer delivery requests before trigger creation"
);

let http_schema = &surface
.capabilities
.iter()
Expand Down
1 change: 1 addition & 0 deletions crates/ironclaw_reborn_composition/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ mod oauth_provider_client;
#[cfg(feature = "openai-compat-beta")]
mod openai_compat_serve;
mod operator_logs;
mod outbound_delivery_capability_surface;
mod outbound_preferences;
mod product_auth_durable;
mod product_auth_providers;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
use ironclaw_product_workflow::{
OutboundPreferencesProductFacade, RebornOutboundDeliveryTargetId,
RebornOutboundDeliveryTargetListResponse, RebornOutboundPreferencesResponse,
RebornServicesError, RebornSetOutboundPreferencesRequest, WebUiAuthenticatedCaller,
};
use thiserror::Error;

pub(crate) const OUTBOUND_DELIVERY_TARGETS_LIST_CAPABILITY_ID: &str =
"builtin.outbound_delivery_targets_list";
pub(crate) const OUTBOUND_DELIVERY_TARGETS_LIST_PROVIDER_TOOL_NAME: &str =
"builtin__outbound_delivery_targets_list";
pub(crate) const OUTBOUND_DELIVERY_TARGETS_LIST_DESCRIPTION: &str = "List available outbound delivery targets for final replies and routine/trigger results, such as Slack DMs or Slack channels. When the user asks to send routine or trigger results through Slack or another product/channel, call this before builtin__trigger_create and before saying a delivery product is unavailable or asking the user to reconnect it.";

pub(crate) const OUTBOUND_DELIVERY_TARGET_SET_CAPABILITY_ID: &str =
"builtin.outbound_delivery_target_set";
pub(crate) const OUTBOUND_DELIVERY_TARGET_SET_PROVIDER_TOOL_NAME: &str =
"builtin__outbound_delivery_target_set";
pub(crate) const OUTBOUND_DELIVERY_TARGET_SET_DESCRIPTION: &str = "Set the current user's final-reply outbound delivery target, such as a Slack DM or Slack channel, to an id returned by builtin__outbound_delivery_targets_list. Use after the user asks to send replies or routine/trigger results through that product or channel, and before creating the routine or trigger. Approval may be required before the preference is changed.";

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OutboundDeliveryTargetsListInput {
channel: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OutboundDeliveryTargetSetInput {
target_id: RebornOutboundDeliveryTargetId,
}

impl OutboundDeliveryTargetSetInput {
pub(crate) fn target_id(&self) -> &RebornOutboundDeliveryTargetId {
&self.target_id
}
}

#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("{reason}")]
pub(crate) struct OutboundDeliveryCapabilityInputError {
reason: String,
}

impl OutboundDeliveryCapabilityInputError {
fn new(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
}
}
}

pub(crate) async fn list_outbound_delivery_targets_for_model(
facade: &dyn OutboundPreferencesProductFacade,
caller: WebUiAuthenticatedCaller,
input: OutboundDeliveryTargetsListInput,
) -> Result<RebornOutboundDeliveryTargetListResponse, RebornServicesError> {
let mut response = facade.list_outbound_delivery_targets(caller).await?;
if let Some(channel_filter) = input.channel {
response.targets.retain(|option| {
option
.target
.channel
.as_str()
.eq_ignore_ascii_case(channel_filter.as_str())
});
}
Ok(response)
}

pub(crate) async fn set_outbound_delivery_target_for_model(
facade: &dyn OutboundPreferencesProductFacade,
caller: WebUiAuthenticatedCaller,
input: OutboundDeliveryTargetSetInput,
) -> Result<RebornOutboundPreferencesResponse, RebornServicesError> {
facade
.set_outbound_preferences(
caller,
RebornSetOutboundPreferencesRequest {
final_reply_target_id: Some(input.target_id),
},
)
.await
}

pub(crate) fn outbound_delivery_targets_list_input_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"channel": {
"type": "string",
"description": "Optional product/channel filter such as slack."
}
},
"additionalProperties": false
})
}

pub(crate) fn outbound_delivery_target_set_input_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"target_id": {
"type": "string",
"description": "Target id returned by builtin__outbound_delivery_targets_list."
}
},
"required": ["target_id"],
"additionalProperties": false
})
}

pub(crate) fn parse_outbound_delivery_targets_list_input(
input: &serde_json::Value,
) -> Result<OutboundDeliveryTargetsListInput, OutboundDeliveryCapabilityInputError> {
let input = input_object(input, "outbound delivery target list", &["channel"])?;
let channel = match input.get("channel") {
None => None,
Some(value) => Some(
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| {
OutboundDeliveryCapabilityInputError::new(
"outbound delivery target list channel must be a non-empty string",
)
})?,
),
};
Ok(OutboundDeliveryTargetsListInput { channel })
}

pub(crate) fn parse_outbound_delivery_target_set_input(
input: &serde_json::Value,
) -> Result<OutboundDeliveryTargetSetInput, OutboundDeliveryCapabilityInputError> {
let input = input_object(input, "outbound delivery target set", &["target_id"])?;
let value = input
.get("target_id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
OutboundDeliveryCapabilityInputError::new(
"outbound delivery target set target_id must be a string",
)
})?;
let target_id = RebornOutboundDeliveryTargetId::new(value).map_err(|reason| {
OutboundDeliveryCapabilityInputError::new(format!(
"outbound delivery target set target_id is invalid: {reason}"
))
})?;
Ok(OutboundDeliveryTargetSetInput { target_id })
}

fn input_object<'a>(
input: &'a serde_json::Value,
capability_name: &'static str,
allowed_fields: &[&str],
) -> Result<&'a serde_json::Map<String, serde_json::Value>, OutboundDeliveryCapabilityInputError> {
let object = input.as_object().ok_or_else(|| {
OutboundDeliveryCapabilityInputError::new(format!(
"{capability_name} input must be an object"
))
})?;
if let Some(field) = object
.keys()
.find(|field| !allowed_fields.contains(&field.as_str()))
{
return Err(OutboundDeliveryCapabilityInputError::new(format!(
"{capability_name} input contains unsupported field `{field}`"
)));
}
Ok(object)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn list_input_rejects_unknown_fields() {
let err = parse_outbound_delivery_targets_list_input(&serde_json::json!({
"channel": "slack",
"extra": true
}))
.expect_err("unknown field should fail");

assert!(err.to_string().contains("unsupported field `extra`"));
}

#[test]
fn set_input_validates_target_id_shape() {
let err = parse_outbound_delivery_target_set_input(&serde_json::json!({
"target_id": "bad\nid"
}))
.expect_err("invalid target id should fail");

assert!(err.to_string().contains("target_id is invalid"));
}
}
Loading
Loading