Synced from monorepo
Changes: - Stop hooks for session lifecycle - Add x.ai/session/state and x.ai/session/import ACP methods - Deny-and-continue for auto-mode classifier blocks with denial limits - Drop codebase-upload from dhat soak test - scheduler_create upsert via task_id; retire one-shot tasks - Clipboard: copy file fallback + honest toasts for SSH/Apple Terminal - Polarity-safe syntax colors in minimal mode - Auto mode classifies unvetted env prefixes instead of hard-prompting - Add GROK_CLIPBOARD_NO_OSC52 kill switch to force OSC 52 off
This commit is contained in:
parent
7cfcb20d2b
commit
ba76b0a683
143 changed files with 9465 additions and 3419 deletions
|
|
@ -543,6 +543,31 @@ impl ToolBridge {
|
|||
}
|
||||
}
|
||||
|
||||
/// Snapshot the session's scheduled tasks; empty when no scheduler is
|
||||
/// registered or the actor has stopped.
|
||||
pub async fn list_scheduled_tasks(
|
||||
&self,
|
||||
) -> Vec<crate::implementations::grok_build::scheduler::types::ScheduledTask> {
|
||||
use crate::implementations::grok_build::scheduler::types::{
|
||||
SchedulerCommand, SchedulerHandle,
|
||||
};
|
||||
let sender = {
|
||||
let res = self.registry.resources.lock().await;
|
||||
match res.get::<SchedulerHandle>() {
|
||||
Some(handle) => handle.0.clone(),
|
||||
None => return Vec::new(),
|
||||
}
|
||||
};
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
if sender
|
||||
.send(SchedulerCommand::List { reply: reply_tx })
|
||||
.is_err()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
reply_rx.await.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn delete_scheduled_task(
|
||||
&self,
|
||||
task_id: &str,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,22 +13,31 @@ pub use xai_grok_tools_api::slash_commands::{
|
|||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SchedulerCreateInput {
|
||||
/// Interval string: "5m", "2h", "1d", etc.
|
||||
#[schemars(description = "Interval between executions, e.g. \"5m\", \"2h\", \"1d\"")]
|
||||
pub interval: String,
|
||||
#[serde(default)]
|
||||
#[schemars(
|
||||
description = "Id of an existing task to update in place: provided fields replace old \
|
||||
values, omitted ones are unchanged, the schedule keeps its phase, and an \
|
||||
unknown id errors. Omit to create a task."
|
||||
)]
|
||||
pub task_id: Option<String>,
|
||||
|
||||
/// The prompt to run on each fire.
|
||||
#[schemars(description = "The prompt text to execute on each scheduled fire")]
|
||||
pub prompt: String,
|
||||
#[serde(default)]
|
||||
#[schemars(
|
||||
description = "Interval between executions, e.g. \"5m\", \"2h\", \"1d\". \
|
||||
Required to create; optional with task_id"
|
||||
)]
|
||||
pub interval: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[schemars(description = "The prompt text to execute on each scheduled fire. \
|
||||
Required to create; optional with task_id")]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Whether the task recurs. Default true.
|
||||
#[serde(
|
||||
default = "default_true",
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Whether the task repeats (true) or fires once (false). Default: true"
|
||||
)]
|
||||
#[schemars(skip)]
|
||||
pub recurring: bool,
|
||||
|
||||
/// Whether the task persists across sessions. Default false (session-only).
|
||||
|
|
@ -36,9 +45,23 @@ pub struct SchedulerCreateInput {
|
|||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
|
||||
)]
|
||||
#[schemars(description = "Whether the task persists across sessions. Default: false")]
|
||||
#[schemars(
|
||||
description = "Whether the task persists across sessions. Default: false. \
|
||||
Create-only: ignored with task_id"
|
||||
)]
|
||||
pub durable: Option<bool>,
|
||||
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::types::schema::deserialize_lenient_option_bool"
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Run each fire as a main-conversation turn instead of a background \
|
||||
subagent; set true only when runs need the conversation's context. \
|
||||
Default: false. Create-only: ignored with task_id"
|
||||
)]
|
||||
pub foreground: Option<bool>,
|
||||
|
||||
/// Whether to fire immediately on creation. Default false (wait for the
|
||||
/// first interval — a "scheduled" task should not run on creation unless
|
||||
/// explicitly asked to).
|
||||
|
|
@ -47,7 +70,8 @@ pub struct SchedulerCreateInput {
|
|||
deserialize_with = "crate::types::schema::deserialize_lenient_bool"
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Whether to fire immediately on creation (true) or wait for the first interval (false). Default: false"
|
||||
description = "Whether to fire immediately on creation (true) or wait for the first \
|
||||
interval (false). Default: false. Create-only: ignored with task_id"
|
||||
)]
|
||||
pub fire_immediately: bool,
|
||||
}
|
||||
|
|
@ -61,7 +85,8 @@ fn default_true() -> bool {
|
|||
pub struct SchedulerCreateOutput {
|
||||
pub id: String,
|
||||
pub human_schedule: String,
|
||||
pub recurring: bool,
|
||||
#[serde(default)]
|
||||
pub updated: bool,
|
||||
}
|
||||
|
||||
impl xai_tool_runtime::ToolOutput for SchedulerCreateOutput {}
|
||||
|
|
@ -79,23 +104,23 @@ impl crate::types::tool_metadata::ToolMetadata for SchedulerCreateTool {
|
|||
}
|
||||
|
||||
fn description_template(&self) -> &str {
|
||||
r#"Create a scheduled task that runs a prompt on a recurring interval.
|
||||
r#"Create a scheduled task that runs a prompt on a recurring interval, or update an existing one in place.
|
||||
|
||||
Set fire_immediately: true to also fire once on creation; by default the first run waits for the interval.
|
||||
|
||||
To change an existing task, pass its task_id: provided fields replace old values, omitted ones are unchanged, and the schedule keeps its phase. An unknown id errors.
|
||||
|
||||
Usage notes:
|
||||
- Interval format: "5m" (minutes), "2h" (hours), "1d" (days), "60s" (seconds, min 60)
|
||||
- Maximum 50 scheduled tasks at once
|
||||
- Recurring tasks auto-expire after 7 days"#
|
||||
- Tasks auto-expire after 7 days
|
||||
- For one-time delayed work, run a background terminal command (e.g. `sleep 1800 && <command>`) instead; its completion notifies you"#
|
||||
// TODO: scheduler tools share ToolKind::Other so they can't be template-ized
|
||||
// via ${{ tools.by_kind.* }}. If tool name randomization is needed, add
|
||||
// dedicated ToolKind variants (SchedulerCreate, SchedulerDelete, SchedulerList).
|
||||
}
|
||||
|
||||
fn emitted_notifications(&self) -> &'static [&'static str] {
|
||||
// A create call only registers the task (the actor emits
|
||||
// ScheduledTaskCreated). Fired/Removed come later from the actor timer,
|
||||
// delete, or shutdown — not from this tool's execution.
|
||||
&["ScheduledTaskCreated"]
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +158,7 @@ impl xai_tool_runtime::Tool for SchedulerCreateTool {
|
|||
#[tracing::instrument(
|
||||
name = "tool.scheduler_create",
|
||||
skip_all,
|
||||
fields(interval = %input.interval)
|
||||
fields(interval = input.interval.as_deref().unwrap_or(""), task_id = input.task_id.as_deref().unwrap_or(""))
|
||||
)]
|
||||
async fn run(
|
||||
&self,
|
||||
|
|
@ -143,7 +168,11 @@ impl xai_tool_runtime::Tool for SchedulerCreateTool {
|
|||
use crate::types::tool_metadata::shared_resources;
|
||||
let resources = shared_resources(&ctx)?;
|
||||
|
||||
let interval_secs = parse_interval(&input.interval)
|
||||
let interval_secs = input
|
||||
.interval
|
||||
.as_deref()
|
||||
.map(parse_interval)
|
||||
.transpose()
|
||||
.map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string()))?;
|
||||
|
||||
let sender = {
|
||||
|
|
@ -156,39 +185,91 @@ impl xai_tool_runtime::Tool for SchedulerCreateTool {
|
|||
.clone()
|
||||
};
|
||||
|
||||
let send_and_wait = |cmd: SchedulerCommand,
|
||||
reply_rx: tokio::sync::oneshot::Receiver<
|
||||
Result<ScheduledTask, super::types::SchedulerError>,
|
||||
>| async move {
|
||||
sender.send(cmd).map_err(|_| {
|
||||
xai_tool_runtime::ToolError::custom("process_manager", "Scheduler actor stopped")
|
||||
})?;
|
||||
reply_rx
|
||||
.await
|
||||
.map_err(|_| {
|
||||
xai_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
"Scheduler actor dropped reply",
|
||||
)
|
||||
})?
|
||||
.map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string()))
|
||||
};
|
||||
|
||||
if let Some(task_id) = input.task_id {
|
||||
if input.prompt.is_none() && interval_secs.is_none() {
|
||||
return Err(xai_tool_runtime::ToolError::invalid_arguments(
|
||||
"nothing to update: provide interval and/or prompt alongside task_id",
|
||||
));
|
||||
}
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
let updated = send_and_wait(
|
||||
SchedulerCommand::Update {
|
||||
id: task_id,
|
||||
prompt: input.prompt,
|
||||
interval_secs,
|
||||
reply: reply_tx,
|
||||
},
|
||||
reply_rx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(SchedulerCreateOutput {
|
||||
id: updated.id,
|
||||
human_schedule: interval_to_human(updated.interval_secs),
|
||||
updated: true,
|
||||
});
|
||||
}
|
||||
|
||||
if !input.recurring {
|
||||
return Err(xai_tool_runtime::ToolError::invalid_arguments(
|
||||
"one-shot tasks are not supported; run a background terminal command instead \
|
||||
(`sleep <secs> && <command>`, background: true) or do the work now",
|
||||
));
|
||||
}
|
||||
|
||||
let interval_secs = interval_secs.ok_or_else(|| {
|
||||
xai_tool_runtime::ToolError::invalid_arguments(
|
||||
"interval is required when creating a task",
|
||||
)
|
||||
})?;
|
||||
let prompt = input.prompt.ok_or_else(|| {
|
||||
xai_tool_runtime::ToolError::invalid_arguments(
|
||||
"prompt is required when creating a task",
|
||||
)
|
||||
})?;
|
||||
|
||||
let durable = input.durable.unwrap_or(false);
|
||||
let task = ScheduledTask::with_fire_immediately(
|
||||
let mut task = ScheduledTask::with_fire_immediately(
|
||||
interval_secs,
|
||||
input.prompt,
|
||||
input.recurring,
|
||||
prompt,
|
||||
true,
|
||||
durable,
|
||||
input.fire_immediately,
|
||||
);
|
||||
task.foreground = input.foreground.unwrap_or(false);
|
||||
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
sender
|
||||
.send(SchedulerCommand::Create {
|
||||
task: task.clone(),
|
||||
let created = send_and_wait(
|
||||
SchedulerCommand::Create {
|
||||
task,
|
||||
reply: reply_tx,
|
||||
})
|
||||
.map_err(|_| {
|
||||
xai_tool_runtime::ToolError::custom("process_manager", "Scheduler actor stopped")
|
||||
})?;
|
||||
|
||||
let created = reply_rx
|
||||
.await
|
||||
.map_err(|_| {
|
||||
xai_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
"Scheduler actor dropped reply",
|
||||
)
|
||||
})?
|
||||
.map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string()))?;
|
||||
},
|
||||
reply_rx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(SchedulerCreateOutput {
|
||||
id: created.id,
|
||||
human_schedule: interval_to_human(interval_secs),
|
||||
recurring: input.recurring,
|
||||
updated: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -196,6 +277,184 @@ impl xai_tool_runtime::Tool for SchedulerCreateTool {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::implementations::grok_build::scheduler::actor::SchedulerActor;
|
||||
use crate::notification::types::ToolNotificationHandle;
|
||||
use crate::types::resources::{Resources, SharedResources, State};
|
||||
use crate::types::tool_metadata::test_ctx;
|
||||
use xai_tool_runtime::Tool;
|
||||
|
||||
fn scheduler_resources() -> (SharedResources, tokio_util::sync::CancellationToken) {
|
||||
let mut resources = Resources::new();
|
||||
resources.register_state::<super::super::types::SchedulerState>();
|
||||
let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
resources.insert(SchedulerHandle(cmd_tx));
|
||||
let shared = resources.into_shared();
|
||||
|
||||
let (notif_handle, _notif_rx) = ToolNotificationHandle::channel();
|
||||
let cancel_token = tokio_util::sync::CancellationToken::new();
|
||||
let actor = SchedulerActor {
|
||||
resources: shared.clone(),
|
||||
notification_handle: notif_handle,
|
||||
cmd_rx,
|
||||
cancel_token: cancel_token.clone(),
|
||||
};
|
||||
tokio::spawn(actor.run());
|
||||
(shared, cancel_token)
|
||||
}
|
||||
|
||||
fn input(json: serde_json::Value) -> SchedulerCreateInput {
|
||||
serde_json::from_value(json).expect("valid input json")
|
||||
}
|
||||
|
||||
async fn task_count(resources: &SharedResources) -> usize {
|
||||
let res = resources.lock().await;
|
||||
res.get::<State<super::super::types::SchedulerState>>()
|
||||
.map(|s| s.tasks.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_requires_interval_and_prompt() {
|
||||
let (resources, cancel) = scheduler_resources();
|
||||
|
||||
let err = SchedulerCreateTool
|
||||
.run(test_ctx(resources.clone()), input(serde_json::json!({})))
|
||||
.await
|
||||
.expect_err("create without interval must fail");
|
||||
assert!(err.to_string().contains("interval is required"));
|
||||
|
||||
let err = SchedulerCreateTool
|
||||
.run(
|
||||
test_ctx(resources.clone()),
|
||||
input(serde_json::json!({"interval": "5m"})),
|
||||
)
|
||||
.await
|
||||
.expect_err("create without prompt must fail");
|
||||
assert!(err.to_string().contains("prompt is required"));
|
||||
|
||||
assert_eq!(task_count(&resources).await, 0);
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recurring_false_errors_with_sleep_guidance() {
|
||||
let (resources, cancel) = scheduler_resources();
|
||||
|
||||
let err = SchedulerCreateTool
|
||||
.run(
|
||||
test_ctx(resources.clone()),
|
||||
input(serde_json::json!({
|
||||
"interval": "5m", "prompt": "check", "recurring": false
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect_err("one-shot must be rejected");
|
||||
assert!(err.to_string().contains("sleep"), "steers to sleep: {err}");
|
||||
assert_eq!(task_count(&resources).await, 0);
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_unknown_task_id_errors_and_never_creates() {
|
||||
let (resources, cancel) = scheduler_resources();
|
||||
|
||||
let err = SchedulerCreateTool
|
||||
.run(
|
||||
test_ctx(resources.clone()),
|
||||
input(serde_json::json!({
|
||||
"task_id": "nonexistent", "prompt": "new prompt"
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect_err("unknown id must error");
|
||||
assert!(err.to_string().contains("no scheduled task with id"));
|
||||
assert_eq!(
|
||||
task_count(&resources).await,
|
||||
0,
|
||||
"strict update must not fall back to create"
|
||||
);
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_ignores_legacy_recurring_flag() {
|
||||
let (resources, cancel) = scheduler_resources();
|
||||
|
||||
let created = SchedulerCreateTool
|
||||
.run(
|
||||
test_ctx(resources.clone()),
|
||||
input(serde_json::json!({"interval": "5m", "prompt": "check deploy"})),
|
||||
)
|
||||
.await
|
||||
.expect("create succeeds");
|
||||
|
||||
let updated = SchedulerCreateTool
|
||||
.run(
|
||||
test_ctx(resources.clone()),
|
||||
input(serde_json::json!({
|
||||
"task_id": created.id, "interval": "10m", "recurring": false
|
||||
})),
|
||||
)
|
||||
.await
|
||||
.expect("update succeeds despite legacy flag");
|
||||
assert!(updated.updated);
|
||||
assert_eq!(updated.human_schedule, "every 10 minutes");
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_with_no_patch_fields_errors() {
|
||||
let (resources, cancel) = scheduler_resources();
|
||||
|
||||
let err = SchedulerCreateTool
|
||||
.run(
|
||||
test_ctx(resources.clone()),
|
||||
input(serde_json::json!({"task_id": "abc123"})),
|
||||
)
|
||||
.await
|
||||
.expect_err("empty patch must error");
|
||||
assert!(err.to_string().contains("nothing to update"));
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_then_update_patches_in_place() {
|
||||
let (resources, cancel) = scheduler_resources();
|
||||
|
||||
let created = SchedulerCreateTool
|
||||
.run(
|
||||
test_ctx(resources.clone()),
|
||||
input(serde_json::json!({"interval": "5m", "prompt": "check deploy"})),
|
||||
)
|
||||
.await
|
||||
.expect("create succeeds");
|
||||
assert!(!created.updated);
|
||||
assert_eq!(created.human_schedule, "every 5 minutes");
|
||||
|
||||
let updated = SchedulerCreateTool
|
||||
.run(
|
||||
test_ctx(resources.clone()),
|
||||
input(serde_json::json!({"task_id": created.id, "interval": "10m"})),
|
||||
)
|
||||
.await
|
||||
.expect("update succeeds");
|
||||
assert!(updated.updated);
|
||||
assert_eq!(updated.id, created.id, "identity preserved");
|
||||
assert_eq!(updated.human_schedule, "every 10 minutes");
|
||||
assert_eq!(task_count(&resources).await, 1, "no second task");
|
||||
cancel.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_hides_recurring_and_advertises_task_id() {
|
||||
let schema = schemars::schema_for!(SchedulerCreateInput);
|
||||
let json = serde_json::to_string(&schema).unwrap();
|
||||
assert!(
|
||||
!json.contains("recurring"),
|
||||
"recurring must not be advertised: {json}"
|
||||
);
|
||||
assert!(json.contains("task_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_usage_message_has_no_host_default() {
|
||||
|
|
|
|||
|
|
@ -9,20 +9,43 @@ pub enum SchedulerError {
|
|||
|
||||
#[error("maximum of {0} scheduled tasks reached")]
|
||||
TaskLimitReached(usize),
|
||||
|
||||
#[error("no scheduled task with id {0}; call scheduler_list to see active task ids")]
|
||||
TaskNotFound(String),
|
||||
}
|
||||
|
||||
/// A single scheduled recurring or one-shot task.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScheduledTask {
|
||||
pub id: String,
|
||||
pub interval_secs: u64,
|
||||
pub prompt: String,
|
||||
#[serde(default = "default_recurring")]
|
||||
pub recurring: bool,
|
||||
pub durable: bool,
|
||||
#[serde(default)]
|
||||
pub foreground: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_fired_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub last_subagent_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub iterations_since_fresh: u32,
|
||||
/// Set when the prompt is patched: the next fire starts a fresh
|
||||
/// transcript instead of resuming the old task's. The anchor itself is
|
||||
/// kept until then so the in-flight guard can still see a running
|
||||
/// iteration.
|
||||
#[serde(default)]
|
||||
pub chain_reset_pending: bool,
|
||||
}
|
||||
|
||||
pub const LOOP_FRESH_CHAIN_EVERY: u32 = 10;
|
||||
|
||||
pub const LOOP_COMPLETION_OUTPUT_CAP: usize = 4_000;
|
||||
|
||||
fn default_recurring() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl ScheduledTask {
|
||||
|
|
@ -51,6 +74,7 @@ impl ScheduledTask {
|
|||
prompt,
|
||||
recurring,
|
||||
durable,
|
||||
foreground: false,
|
||||
created_at,
|
||||
last_fired_at: None,
|
||||
expires_at: if recurring {
|
||||
|
|
@ -58,6 +82,9 @@ impl ScheduledTask {
|
|||
} else {
|
||||
None
|
||||
},
|
||||
last_subagent_id: None,
|
||||
iterations_since_fresh: 0,
|
||||
chain_reset_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,11 +98,6 @@ impl ScheduledTask {
|
|||
pub fn is_expired(&self, now: DateTime<Utc>) -> bool {
|
||||
self.expires_at.is_some_and(|exp| now >= exp)
|
||||
}
|
||||
|
||||
/// Whether this task was missed (one-shot: fire time already passed, never fired).
|
||||
pub fn is_missed(&self, now: DateTime<Utc>) -> bool {
|
||||
!self.recurring && self.last_fired_at.is_none() && self.next_fire_at() < now
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted state for the scheduler, stored via Resources + ResourcesPersistence.
|
||||
|
|
@ -97,6 +119,12 @@ pub enum SchedulerCommand {
|
|||
task: ScheduledTask,
|
||||
reply: oneshot::Sender<Result<ScheduledTask, SchedulerError>>,
|
||||
},
|
||||
Update {
|
||||
id: String,
|
||||
prompt: Option<String>,
|
||||
interval_secs: Option<u64>,
|
||||
reply: oneshot::Sender<Result<ScheduledTask, SchedulerError>>,
|
||||
},
|
||||
Delete {
|
||||
id: String,
|
||||
reply: oneshot::Sender<bool>,
|
||||
|
|
@ -161,25 +189,22 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn is_missed_returns_true_for_unfired_one_shot_past_due() {
|
||||
let mut task = ScheduledTask::new(1, "test".into(), false, false);
|
||||
task.created_at = Utc::now() - chrono::Duration::seconds(10);
|
||||
assert!(task.is_missed(Utc::now()));
|
||||
fn legacy_state_without_recurring_field_deserializes_as_recurring() {
|
||||
let json = r#"{"id":"abc123","intervalSecs":300,"prompt":"check",
|
||||
"durable":true,"createdAt":"2026-01-01T00:00:00Z",
|
||||
"lastFiredAt":null,"expiresAt":null}"#;
|
||||
let task: ScheduledTask = serde_json::from_str(json).unwrap();
|
||||
assert!(task.recurring);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_missed_returns_false_for_recurring() {
|
||||
let mut task = ScheduledTask::new(1, "test".into(), true, false);
|
||||
task.created_at = Utc::now() - chrono::Duration::seconds(10);
|
||||
assert!(!task.is_missed(Utc::now()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_missed_returns_false_if_already_fired() {
|
||||
let mut task = ScheduledTask::new(1, "test".into(), false, false);
|
||||
task.created_at = Utc::now() - chrono::Duration::seconds(10);
|
||||
task.last_fired_at = Some(Utc::now());
|
||||
assert!(!task.is_missed(Utc::now()));
|
||||
fn legacy_one_shot_state_still_deserializes() {
|
||||
let json = r#"{"id":"abc123","intervalSecs":300,"prompt":"check",
|
||||
"recurring":false,"durable":true,
|
||||
"createdAt":"2026-01-01T00:00:00Z",
|
||||
"lastFiredAt":null,"expiresAt":null}"#;
|
||||
let task: ScheduledTask = serde_json::from_str(json).unwrap();
|
||||
assert!(!task.recurring);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -313,6 +313,8 @@ impl xai_tool_runtime::Tool for TaskTool {
|
|||
// parent agent decides the flavor (the `/goal` harness override
|
||||
// is set only by the harness-internal role spawners).
|
||||
harness_agent_type: None,
|
||||
completion_output_cap: None,
|
||||
spawn_depth: None,
|
||||
},
|
||||
run_in_background: input.run_in_background,
|
||||
// Model-spawned subagents must still appear in the idle reminder.
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@ pub struct SubagentRuntimeOverrides {
|
|||
/// (implementer vs explorer). `None` for every non-goal spawn ⇒ the parent
|
||||
/// agent decides the flavor (unchanged behavior).
|
||||
pub harness_agent_type: Option<String>,
|
||||
pub completion_output_cap: Option<usize>,
|
||||
pub spawn_depth: Option<u32>,
|
||||
}
|
||||
|
||||
/// Re-export of [`xai_tool_types::is_not_sentinel`] for existing call sites.
|
||||
|
|
|
|||
|
|
@ -311,6 +311,7 @@ pub struct ScheduledTaskFired {
|
|||
pub human_schedule: String,
|
||||
/// RFC3339 timestamp of next fire (for live countdown viz).
|
||||
pub next_fire_at: Option<String>,
|
||||
pub subagent_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Notification that a scheduled task was removed (deleted, expired, or one-shot completed).
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ pub use task_completion::TaskCompletionReminder;
|
|||
pub const DEFAULT_REMINDER_TAG: &str = "system-reminder";
|
||||
|
||||
/// Wrap plain text in `<system-reminder>` tags (default hyphen variant).
|
||||
///
|
||||
/// Input: `"Some reminder text"`
|
||||
/// Output: `"<system-reminder>\nSome reminder text\n</system-reminder>"`
|
||||
pub fn wrap_reminder(text: &str) -> String {
|
||||
|
|
@ -61,6 +60,28 @@ pub fn format_scheduled_task_prompt(prompt: &str, task_id: &str, human_schedule:
|
|||
)
|
||||
}
|
||||
|
||||
pub fn format_loop_iteration_prompt(
|
||||
prompt: &str,
|
||||
task_id: &str,
|
||||
human_schedule: &str,
|
||||
prior_iteration_summary: Option<&str>,
|
||||
) -> String {
|
||||
let prior = prior_iteration_summary
|
||||
.map(|s| format!("\nYour previous iteration ended with:\n{s}\n"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"<system-reminder>\n\
|
||||
Scheduled task {task_id} ({human_schedule}). Earlier iterations, if any, appear \
|
||||
above.\n\
|
||||
Run the task below. End with a short status: what changed or needs attention. \
|
||||
The status is relayed to the main agent.\n\
|
||||
{prior}\
|
||||
</system-reminder>\n\
|
||||
\n\
|
||||
{prompt}"
|
||||
)
|
||||
}
|
||||
|
||||
/// Append wrapped reminders to a tool output string.
|
||||
/// Returns output unchanged if reminders is empty.
|
||||
///
|
||||
|
|
@ -135,6 +156,30 @@ mod tests {
|
|||
assert!(out.ends_with("do stuff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_loop_iteration_prompt_frames_subagent_iteration() {
|
||||
let out = format_loop_iteration_prompt("check ci", "task-9", "every 5 minutes", None);
|
||||
assert!(out.starts_with("<system-reminder>"));
|
||||
assert!(out.contains("task task-9"));
|
||||
assert!(out.contains("every 5 minutes"));
|
||||
assert!(out.contains("short status"));
|
||||
assert!(out.ends_with("check ci"));
|
||||
assert!(
|
||||
!out.contains("previous iteration"),
|
||||
"no prior-output note without a summary"
|
||||
);
|
||||
|
||||
let with_prior = format_loop_iteration_prompt(
|
||||
"check ci",
|
||||
"task-9",
|
||||
"every 5 minutes",
|
||||
Some("ci was green"),
|
||||
);
|
||||
assert!(with_prior.contains("previous iteration"));
|
||||
assert!(with_prior.contains("ci was green"));
|
||||
assert!(with_prior.ends_with("check ci"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_with_reminders_returns_unchanged_when_empty() {
|
||||
let output = "file content here".to_string();
|
||||
|
|
|
|||
|
|
@ -961,9 +961,10 @@ impl ToolOutput {
|
|||
}
|
||||
}
|
||||
ToolOutput::SchedulerCreate(o) => {
|
||||
let verb = if o.updated { "updated" } else { "created" };
|
||||
format!(
|
||||
"Scheduled task created (ID: {}, {}, recurring: {}).",
|
||||
o.id, o.human_schedule, o.recurring
|
||||
"Scheduled task {} (ID: {}, {}).",
|
||||
verb, o.id, o.human_schedule
|
||||
)
|
||||
}
|
||||
ToolOutput::SchedulerDelete(o) => o.message.clone(),
|
||||
|
|
|
|||
|
|
@ -652,6 +652,19 @@ impl Default for RespectGitignore {
|
|||
/// Default `false`. Hosts may enable this via remote config or local settings.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct PathNotFoundHints(pub bool);
|
||||
/// Whether scheduled task fires execute in background loop subagents.
|
||||
///
|
||||
/// `false` forces every fire onto the legacy main-conversation path.
|
||||
/// Configured via `[scheduler] background_loops` in `config.toml`, the
|
||||
/// `GROK_SCHEDULER_BACKGROUND_LOOPS` env var, or the
|
||||
/// `scheduler_background_loops` remote setting.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SchedulerBackgroundLoops(pub bool);
|
||||
impl Default for SchedulerBackgroundLoops {
|
||||
fn default() -> Self {
|
||||
Self(true)
|
||||
}
|
||||
}
|
||||
/// Map of canonical tool names → model-facing tool names.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ToolNameMapping(pub HashMap<String, String>);
|
||||
|
|
|
|||
Loading…
Reference in a new issue