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:
grokkybara[bot] 2026-07-19 18:40:33 +01:00
commit ba76b0a683
143 changed files with 9465 additions and 3419 deletions

View file

@ -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,

View file

@ -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() {

View file

@ -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]

View file

@ -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.

View file

@ -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.

View file

@ -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).

View file

@ -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();

View file

@ -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(),

View file

@ -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>);