Synced from monorepo
Synced from monorepo Changes: - Shell: accept target response id on rewind execute - Shell: stamp response id on chat user message chunks - Worktree: optional rebuild and stale git registration cleanup in auto-GC - Worktree: kind-aware auto-GC TTLs and config knobs - Worktree: macOS process CWD scan and Unix PID liveness for GC guards - Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only) - Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups - Shell: stop overwriting user skills - Tools: read markdown in `skills/` directories untruncated - `/usage` shows per-session token and dollar usage in the TUI - Security: prompt on environment-dumping `ps` variants - Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission - Tools: make scheduler deletion durable - Shell: add relocation storage primitives - Shell: give side model calls their own conversation ids - Fix five workflow-runtime bugs (budget, pause, cancel, reconnect) - Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask) - Pager: expose doctor in the TUI - Security: block unauthorized RCE via abused safe commands - Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent" - Security: block `rg --pre` arbitrary code execution in auto-mode - Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section - App builder deployer: `allow_forking` and `show_built_with_grok` - Pager: stop stacking duplicate "Worked for" markers on parked turns - Shell: support `max` as a distinct reasoning effort tier - Tools: serialize background `/loop` fires on the whole work unit - Shell: add working-directory relocation state primitives - Proto: `ClientToolResult` and `ChatConfig` client-side tools - Shell: model providers - Chat: select App Builder product on the Build path - Shell: attach author identity to feedback when the deployment opts in - Doctor: fix for SSH wrap setup - Workflow authoring skills: create-workflow and import-claude-workflow docs - Add read-only grok doctor - Sandbox: apply Landlock without a controlling TTY - Pager: recover image paste over grok wrap on headless remotes - Pager: make actions screen-mode aware - Shell: resume sessions when the working directory moves - Pager: centralize terminal diagnostics - Workspace: gate inline shell file access - Pager: centralize terminal probes - Pager: edit minimal prompts in an external editor - Pager: standardize backgrounding on Ctrl+B - Shell: recap rides the parent turn's prompt cache - Tools: add scheduler lifecycle version clock Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899
This commit is contained in:
parent
a881e6703f
commit
3af4d5d398
556 changed files with 56609 additions and 21892 deletions
|
|
@ -565,7 +565,10 @@ impl ToolBridge {
|
|||
{
|
||||
return Vec::new();
|
||||
}
|
||||
reply_rx.await.unwrap_or_default()
|
||||
reply_rx
|
||||
.await
|
||||
.map(|snapshot| snapshot.tasks)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn delete_scheduled_task(
|
||||
|
|
@ -593,9 +596,15 @@ impl ToolBridge {
|
|||
.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")
|
||||
})
|
||||
reply_rx
|
||||
.await
|
||||
.map_err(|_| {
|
||||
xai_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
"Scheduler actor dropped reply",
|
||||
)
|
||||
})?
|
||||
.map_err(crate::implementations::grok_build::scheduler::types::scheduler_tool_error)
|
||||
}
|
||||
|
||||
/// Move a foreground command to background by tool_call_id.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ pub mod update_goal;
|
|||
pub mod video_gen;
|
||||
pub mod web_fetch;
|
||||
pub mod web_search;
|
||||
pub mod workflow;
|
||||
pub use ask_user_question::AskUserQuestionTool;
|
||||
pub use bash::BashTool;
|
||||
pub use deploy_app::{AppBuilderDeployerConfig, DEPLOY_APP_TOOL_NAME};
|
||||
|
|
@ -65,3 +66,4 @@ pub use video_gen::{
|
|||
};
|
||||
pub use web_fetch::{WebFetchClient, WebFetchConfig, WebFetchParams, WebFetchTool};
|
||||
pub use web_search::WebSearchTool;
|
||||
pub use workflow::{WORKFLOW_TOOL_NAME, WorkflowTool};
|
||||
|
|
|
|||
|
|
@ -174,6 +174,37 @@ fn resolve_read_start_line(file_content: &str, offset: Option<i64>) -> usize {
|
|||
fn stored_read_offset(offset: Option<i64>) -> Option<usize> {
|
||||
offset.filter(|&o| o >= 0).map(|o| o as usize)
|
||||
}
|
||||
/// Files read in full (no line/token cap): any file named exactly `SKILL.md`,
|
||||
/// plus any Markdown file with a `skills` path component so docs a `SKILL.md`
|
||||
/// references are never silently truncated. `.`/`..` are folded lexically
|
||||
/// (symlinks are not resolved). Intentionally broader than
|
||||
/// skill discovery's dir check — matches any `skills` segment
|
||||
/// (plugin/bundled/user roots), and matches it exactly (not case-folded) so
|
||||
/// near-misses like `skills-cursor` do not qualify.
|
||||
fn is_skill_markdown(path: &std::path::Path) -> bool {
|
||||
if path.file_name().is_some_and(|n| n == "SKILL.md") {
|
||||
return true;
|
||||
}
|
||||
let is_md = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
|
||||
if !is_md {
|
||||
return false;
|
||||
}
|
||||
use std::path::Component;
|
||||
let mut stack: Vec<&std::ffi::OsStr> = Vec::new();
|
||||
for comp in path.components() {
|
||||
match comp {
|
||||
Component::CurDir | Component::RootDir | Component::Prefix(_) => {}
|
||||
Component::ParentDir => {
|
||||
stack.pop();
|
||||
}
|
||||
Component::Normal(c) => stack.push(c),
|
||||
}
|
||||
}
|
||||
stack.into_iter().any(|c| c == "skills")
|
||||
}
|
||||
/// Result of extracting file content lines with both default and concise formats
|
||||
pub struct ExtractedContent {
|
||||
/// Default format: line numbers with → separator (no padding)
|
||||
|
|
@ -313,6 +344,7 @@ pub(crate) async fn run_read_file(
|
|||
hints_enabled = res.get::<PathNotFoundHints>().is_some_and(|h| h.0);
|
||||
}
|
||||
let joined_path = resolve_model_path(&cwd, display_cwd.as_deref(), &input.path);
|
||||
let is_skill_markdown = is_skill_markdown(&joined_path);
|
||||
let (path, _unicode_note) = match crate::util::fs::try_canonicalize(&joined_path).await {
|
||||
Ok(p) => (p, None),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
|
|
@ -325,10 +357,6 @@ pub(crate) async fn run_read_file(
|
|||
};
|
||||
let version = ReadFileVersion::from_contract(contract_version);
|
||||
let is_legacy = version.is_legacy();
|
||||
let is_skill_file = path
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.is_some_and(|name| name == "SKILL.md");
|
||||
let skip_gitignore = is_legacy && versions::legacy_0_4_10::allows_gitignored_reads();
|
||||
if !skip_gitignore {
|
||||
let res = resources.lock().await;
|
||||
|
|
@ -447,7 +475,7 @@ pub(crate) async fn run_read_file(
|
|||
.map(|t| t.0.max_lines_read())
|
||||
.unwrap_or_else(|| TruncationConfig::default().max_lines_read())
|
||||
};
|
||||
let (effective_offset, effective_limit) = if is_skill_file {
|
||||
let (effective_offset, effective_limit) = if is_skill_markdown {
|
||||
(None, None)
|
||||
} else {
|
||||
(
|
||||
|
|
@ -462,7 +490,7 @@ pub(crate) async fn run_read_file(
|
|||
total_lines,
|
||||
);
|
||||
let token_count = crate::util::truncate::estimate_tokens(&extracted.content);
|
||||
if !is_skill_file && token_count > MAX_NUM_TOKENS {
|
||||
if !is_skill_markdown && token_count > MAX_NUM_TOKENS {
|
||||
let (grep_name, execute_name);
|
||||
{
|
||||
let res = resources.lock().await;
|
||||
|
|
@ -510,7 +538,7 @@ pub(crate) async fn run_read_file(
|
|||
};
|
||||
return Ok(ReadFileOutput::FileTooLarge(msg));
|
||||
}
|
||||
let (stored_offset, stored_limit) = if is_skill_file {
|
||||
let (stored_offset, stored_limit) = if is_skill_markdown {
|
||||
(None, None)
|
||||
} else {
|
||||
(stored_read_offset(input.offset), input.limit)
|
||||
|
|
@ -602,22 +630,22 @@ impl xai_tool_runtime::Tool for ReadFileTool {
|
|||
});
|
||||
};
|
||||
Box::pin(async_stream::stream! {
|
||||
match ReadFileTool::read_with_streamability(& ctx, input). await {
|
||||
Ok((output, streamable)) => { if streamable && let
|
||||
ReadFileOutput::FileContent(fc) = & output && ! fc.content.is_empty() {
|
||||
let content = fc.content.as_bytes(); let mut last_total : u64 = 0; let
|
||||
mut window_start = 0usize; while window_start < content.len() { let mut
|
||||
window_end = (window_start + STREAM_DELTA_TARGET_BYTES).min(content
|
||||
.len()); while window_end > window_start && ! fc.content
|
||||
.is_char_boundary(window_end) { window_end -= 1; }
|
||||
if let Some(p) =
|
||||
xai_tool_runtime::stream_chunk(spec, & content[..window_end], window_end
|
||||
as u64, & mut last_total, false,) { yield
|
||||
xai_tool_runtime::ToolStreamItem::Progress(p); } window_start =
|
||||
window_end; } } yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Ok(output)); } Err(e) => yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)), }
|
||||
})
|
||||
match ReadFileTool::read_with_streamability(& ctx, input). await {
|
||||
Ok((output, streamable)) => { if streamable && let
|
||||
ReadFileOutput::FileContent(fc) = & output && ! fc.content.is_empty() {
|
||||
let content = fc.content.as_bytes(); let mut last_total : u64 = 0; let
|
||||
mut window_start = 0usize; while window_start < content.len() { let mut
|
||||
window_end = (window_start + STREAM_DELTA_TARGET_BYTES).min(content
|
||||
.len()); while window_end > window_start && ! fc.content
|
||||
.is_char_boundary(window_end) { window_end -= 1; }
|
||||
if let Some(p) =
|
||||
xai_tool_runtime::stream_chunk(spec, & content[..window_end], window_end
|
||||
as u64, & mut last_total, false,) { yield
|
||||
xai_tool_runtime::ToolStreamItem::Progress(p); } window_start =
|
||||
window_end; } } yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Ok(output)); } Err(e) => yield
|
||||
xai_tool_runtime::ToolStreamItem::Terminal(Err(e)), }
|
||||
})
|
||||
}
|
||||
#[tracing::instrument(name = "tool.read_file", skip_all, fields(path = %input.path))]
|
||||
async fn run(
|
||||
|
|
@ -1775,6 +1803,46 @@ pub fn verify(req: &HttpRequest) -> Result<Claims, Error> {
|
|||
std::mem::discriminant(&result),
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn md_in_skills_dir_ignores_model_offset_and_limit() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path().join(".grok/skills/my-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
let content = (1..=1200)
|
||||
.map(|n| format!("line{n}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
std::fs::write(skill_dir.join("reference.md"), &content).unwrap();
|
||||
let tool = ReadFileTool;
|
||||
let resources = test_resources(tmp.path());
|
||||
let input = ReadFileInput {
|
||||
path: ".grok/skills/my-skill/reference.md".to_string(),
|
||||
offset: Some(3),
|
||||
limit: Some(1),
|
||||
pages: None,
|
||||
format: None,
|
||||
};
|
||||
let result = xai_tool_runtime::Tool::run(&tool, test_ctx(resources.into_shared()), input)
|
||||
.await
|
||||
.unwrap();
|
||||
match result {
|
||||
ReadFileOutput::FileContent(fc) => {
|
||||
assert!(
|
||||
fc.content.contains("line1"),
|
||||
"missing line1: {}",
|
||||
fc.content
|
||||
);
|
||||
assert!(
|
||||
fc.content.contains("line1200"),
|
||||
"missing line1200: {}",
|
||||
fc.content
|
||||
);
|
||||
assert_eq!(fc.offset, None);
|
||||
assert_eq!(fc.limit, None);
|
||||
}
|
||||
other => panic!("Expected FileContent, got {:?}", other),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parse_single_page() {
|
||||
assert_eq!(parse_page_range("3", 10).unwrap(), vec![2]);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,7 @@ use crate::types::requirements::{Expr, ToolRequirement};
|
|||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
use super::interval::{interval_to_human, parse_interval};
|
||||
use super::types::{ScheduledTask, SchedulerCommand, SchedulerHandle};
|
||||
use super::types::{ScheduledTask, SchedulerCommand, SchedulerHandle, scheduler_tool_error};
|
||||
|
||||
// Canonical /loop wording lives in the light API crate so other consumers can
|
||||
// link it without the tools implementation crate; re-exported to keep paths stable.
|
||||
|
|
@ -200,7 +200,7 @@ impl xai_tool_runtime::Tool for SchedulerCreateTool {
|
|||
"Scheduler actor dropped reply",
|
||||
)
|
||||
})?
|
||||
.map_err(|e| xai_tool_runtime::ToolError::invalid_arguments(e.to_string()))
|
||||
.map_err(scheduler_tool_error)
|
||||
};
|
||||
|
||||
if let Some(task_id) = input.task_id {
|
||||
|
|
@ -294,9 +294,14 @@ mod tests {
|
|||
let cancel_token = tokio_util::sync::CancellationToken::new();
|
||||
let actor = SchedulerActor {
|
||||
resources: shared.clone(),
|
||||
resources_persistence: std::sync::Arc::new(
|
||||
crate::persistence::ResourcesPersistence::noop(),
|
||||
),
|
||||
notification_handle: notif_handle,
|
||||
cmd_rx,
|
||||
cancel_token: cancel_token.clone(),
|
||||
clock: Default::default(),
|
||||
pending_removal: None,
|
||||
};
|
||||
tokio::spawn(actor.run());
|
||||
(shared, cancel_token)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use crate::types::requirements::{Expr, ToolRequirement};
|
|||
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
use super::types::{SchedulerCommand, SchedulerHandle};
|
||||
use super::types::{SchedulerCommand, SchedulerHandle, scheduler_tool_error};
|
||||
|
||||
/// Canonical tool name advertised by `SchedulerDeleteTool::id()`.
|
||||
/// See note on `SCHEDULER_CREATE_TOOL_NAME`.
|
||||
|
|
@ -115,9 +115,15 @@ impl xai_tool_runtime::Tool for SchedulerDeleteTool {
|
|||
xai_tool_runtime::ToolError::custom("process_manager", "Scheduler actor stopped")
|
||||
})?;
|
||||
|
||||
let removed = reply_rx.await.map_err(|_| {
|
||||
xai_tool_runtime::ToolError::custom("process_manager", "Scheduler actor dropped reply")
|
||||
})?;
|
||||
let removed = reply_rx
|
||||
.await
|
||||
.map_err(|_| {
|
||||
xai_tool_runtime::ToolError::custom(
|
||||
"process_manager",
|
||||
"Scheduler actor dropped reply",
|
||||
)
|
||||
})?
|
||||
.map_err(scheduler_tool_error)?;
|
||||
|
||||
if removed {
|
||||
Ok(SchedulerDeleteOutput {
|
||||
|
|
|
|||
|
|
@ -111,14 +111,15 @@ impl xai_tool_runtime::Tool for SchedulerListTool {
|
|||
)
|
||||
})?;
|
||||
|
||||
let tasks = reply_rx.await.map_err(|_| {
|
||||
let snapshot = reply_rx.await.map_err(|_| {
|
||||
xai_tool_runtime::ToolError::execution(
|
||||
xai_tool_protocol::ToolId::new("scheduler_list").expect("valid"),
|
||||
"Scheduler actor dropped reply",
|
||||
)
|
||||
})?;
|
||||
|
||||
let summaries = tasks
|
||||
let summaries = snapshot
|
||||
.tasks
|
||||
.into_iter()
|
||||
.map(|t| {
|
||||
let next_fire = t.next_fire_at().to_rfc3339();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,146 @@ use chrono::{DateTime, Utc};
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct SchedulerVersion {
|
||||
generation: uuid::Uuid,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
impl SchedulerVersion {
|
||||
pub(super) fn generation(self) -> String {
|
||||
self.generation.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn revision(self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SchedulerClock {
|
||||
version: SchedulerVersion,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SchedulerReservation {
|
||||
source: SchedulerVersion,
|
||||
generation: uuid::Uuid,
|
||||
next_revision: u64,
|
||||
remaining: u64,
|
||||
rollover: Option<GenerationRollover>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct GenerationRollover {
|
||||
pub(crate) old_generation: uuid::Uuid,
|
||||
pub(crate) new_generation: uuid::Uuid,
|
||||
}
|
||||
|
||||
pub(crate) struct SchedulerCommit {
|
||||
pub(crate) version: SchedulerVersion,
|
||||
pub(crate) rollover: Option<GenerationRollover>,
|
||||
}
|
||||
|
||||
impl SchedulerReservation {
|
||||
pub(crate) fn version_at(&self, offset: u64) -> SchedulerVersion {
|
||||
assert!(
|
||||
offset < self.remaining,
|
||||
"scheduler reservation offset is exhausted"
|
||||
);
|
||||
SchedulerVersion {
|
||||
generation: self.generation,
|
||||
revision: self.next_revision + offset,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit_next(&mut self, clock: &mut SchedulerClock) -> SchedulerCommit {
|
||||
assert!(self.remaining > 0, "scheduler reservation is exhausted");
|
||||
let rollover = self.rollover;
|
||||
let expected_source = rollover.map_or(
|
||||
SchedulerVersion {
|
||||
generation: self.generation,
|
||||
revision: self.next_revision - 1,
|
||||
},
|
||||
|_| self.source,
|
||||
);
|
||||
assert_eq!(
|
||||
clock.version, expected_source,
|
||||
"stale scheduler reservation"
|
||||
);
|
||||
|
||||
let version = self.version_at(0);
|
||||
clock.version = version;
|
||||
self.rollover = None;
|
||||
self.remaining -= 1;
|
||||
if self.remaining > 0 {
|
||||
self.next_revision = self
|
||||
.next_revision
|
||||
.checked_add(1)
|
||||
.expect("preflighted revision");
|
||||
}
|
||||
SchedulerCommit { version, rollover }
|
||||
}
|
||||
}
|
||||
|
||||
impl SchedulerClock {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
version: SchedulerVersion {
|
||||
generation: uuid::Uuid::now_v7(),
|
||||
revision: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot(&self) -> SchedulerVersion {
|
||||
self.version
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_transition(&self, count: usize) -> SchedulerReservation {
|
||||
assert!(
|
||||
count > 0 && count <= MAX_SCHEDULER_TRANSITIONS,
|
||||
"invalid scheduler reservation size"
|
||||
);
|
||||
let count = count as u64;
|
||||
let rollover =
|
||||
self.version
|
||||
.revision
|
||||
.checked_add(count)
|
||||
.is_none()
|
||||
.then(|| GenerationRollover {
|
||||
old_generation: self.version.generation,
|
||||
new_generation: uuid::Uuid::now_v7(),
|
||||
});
|
||||
SchedulerReservation {
|
||||
source: self.version,
|
||||
generation: rollover
|
||||
.map(|rollover| rollover.new_generation)
|
||||
.unwrap_or(self.version.generation),
|
||||
next_revision: if rollover.is_some() {
|
||||
1
|
||||
} else {
|
||||
self.version.revision + 1
|
||||
},
|
||||
remaining: count,
|
||||
rollover,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn at_revision_for_test(revision: u64) -> Self {
|
||||
let mut clock = Self::new();
|
||||
clock.version.revision = revision;
|
||||
clock
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SchedulerClock {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum SchedulerError {
|
||||
#[error("invalid interval: {0}")]
|
||||
|
|
@ -12,6 +152,39 @@ pub enum SchedulerError {
|
|||
|
||||
#[error("no scheduled task with id {0}; call scheduler_list to see active task ids")]
|
||||
TaskNotFound(String),
|
||||
|
||||
#[error("failed to persist scheduler resources: {0}")]
|
||||
Persistence(#[source] std::io::Error),
|
||||
|
||||
#[error("failed to publish scheduler tombstone: {0}")]
|
||||
Notification(#[source] crate::notification::NotificationAcknowledgementError),
|
||||
|
||||
#[error("durable scheduler removal requires an acknowledging notification consumer")]
|
||||
NoDurableNotificationConsumer,
|
||||
|
||||
#[error("scheduler removal for {0} is pending")]
|
||||
RemovalPending(String),
|
||||
|
||||
#[error("scheduler removal cancelled")]
|
||||
Cancelled,
|
||||
|
||||
#[error("scheduler removal timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
pub fn scheduler_tool_error(error: SchedulerError) -> xai_tool_runtime::ToolError {
|
||||
let code = match &error {
|
||||
SchedulerError::InvalidInterval(_)
|
||||
| SchedulerError::TaskLimitReached(_)
|
||||
| SchedulerError::TaskNotFound(_) => "scheduler_invalid_request",
|
||||
SchedulerError::Persistence(_) => "scheduler_persistence",
|
||||
SchedulerError::Notification(_) => "scheduler_notification",
|
||||
SchedulerError::NoDurableNotificationConsumer => "scheduler_durability_unavailable",
|
||||
SchedulerError::RemovalPending(_) => "scheduler_removal_pending",
|
||||
SchedulerError::Cancelled => "scheduler_cancelled",
|
||||
SchedulerError::Timeout => "scheduler_timeout",
|
||||
};
|
||||
xai_tool_runtime::ToolError::custom(code, error.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -22,6 +195,7 @@ pub struct ScheduledTask {
|
|||
pub prompt: String,
|
||||
#[serde(default = "default_recurring")]
|
||||
pub recurring: bool,
|
||||
#[serde(default)]
|
||||
pub durable: bool,
|
||||
#[serde(default)]
|
||||
pub foreground: bool,
|
||||
|
|
@ -44,6 +218,8 @@ pub const LOOP_FRESH_CHAIN_EVERY: u32 = 10;
|
|||
|
||||
pub const LOOP_COMPLETION_OUTPUT_CAP: usize = 4_000;
|
||||
|
||||
const MAX_SCHEDULER_TRANSITIONS: usize = 50;
|
||||
|
||||
fn default_recurring() -> bool {
|
||||
true
|
||||
}
|
||||
|
|
@ -101,7 +277,6 @@ impl ScheduledTask {
|
|||
}
|
||||
|
||||
/// Persisted state for the scheduler, stored via Resources + ResourcesPersistence.
|
||||
/// Only durable tasks are serialized; non-durable tasks are filtered out before save.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SchedulerState {
|
||||
pub tasks: Vec<ScheduledTask>,
|
||||
|
|
@ -109,6 +284,14 @@ pub struct SchedulerState {
|
|||
|
||||
crate::register_resource!("grok_build", "Scheduler", SchedulerState);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SchedulerSnapshot {
|
||||
// Consumed by the authoritative scheduler snapshot layer in the next migration PR.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) version: SchedulerVersion,
|
||||
pub tasks: Vec<ScheduledTask>,
|
||||
}
|
||||
|
||||
/// Handle for tools to communicate with the SchedulerActor.
|
||||
/// Ephemeral -- not serialized, not persisted. Inserted via `resources.insert()`.
|
||||
#[derive(Clone)]
|
||||
|
|
@ -127,10 +310,10 @@ pub enum SchedulerCommand {
|
|||
},
|
||||
Delete {
|
||||
id: String,
|
||||
reply: oneshot::Sender<bool>,
|
||||
reply: oneshot::Sender<Result<bool, SchedulerError>>,
|
||||
},
|
||||
List {
|
||||
reply: oneshot::Sender<Vec<ScheduledTask>>,
|
||||
reply: oneshot::Sender<SchedulerSnapshot>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -189,22 +372,12 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_without_recurring_field_deserializes_as_recurring() {
|
||||
fn legacy_state_defaults_recurring_and_durable_fields() {
|
||||
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 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);
|
||||
assert!(task.recurring && !task.durable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -214,8 +387,54 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_state_default_is_empty() {
|
||||
let state = SchedulerState::default();
|
||||
assert!(state.tasks.is_empty());
|
||||
fn clocks_start_with_fresh_uuid_v7_generations() {
|
||||
let first = SchedulerClock::new().snapshot();
|
||||
let second = SchedulerClock::new().snapshot();
|
||||
|
||||
assert_ne!(first.generation, second.generation);
|
||||
assert_eq!(first.generation.get_version_num(), 7);
|
||||
assert_eq!(first.revision(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reservation_preflights_and_commits_in_order() {
|
||||
let mut clock = SchedulerClock::new();
|
||||
let mut reservation = clock.prepare_transition(2);
|
||||
|
||||
assert_eq!(clock.snapshot().revision(), 0);
|
||||
let first = reservation.commit_next(&mut clock);
|
||||
assert_eq!(first.version.revision(), 1);
|
||||
assert!(first.rollover.is_none());
|
||||
assert_eq!(reservation.commit_next(&mut clock).version.revision(), 2);
|
||||
assert_eq!(clock.snapshot().revision(), 2);
|
||||
|
||||
let mut boundary = SchedulerClock::at_revision_for_test(u64::MAX - 1);
|
||||
let mut final_step = boundary.prepare_transition(1);
|
||||
assert_eq!(
|
||||
final_step.commit_next(&mut boundary).version.revision(),
|
||||
u64::MAX
|
||||
);
|
||||
let exhausted = SchedulerClock::at_revision_for_test(u64::MAX - 1);
|
||||
let old_generation = exhausted.snapshot().generation;
|
||||
let reservation = exhausted.prepare_transition(2);
|
||||
let rollover = reservation.rollover.unwrap();
|
||||
assert_eq!(rollover.old_generation, old_generation);
|
||||
assert_ne!(rollover.new_generation, old_generation);
|
||||
assert_eq!(exhausted.snapshot().revision(), u64::MAX - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_rollover_commit_does_not_mutate_clock() {
|
||||
let mut clock = SchedulerClock::at_revision_for_test(u64::MAX);
|
||||
let mut stale = clock.prepare_transition(1);
|
||||
clock = SchedulerClock::new();
|
||||
let before = clock.snapshot();
|
||||
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _ = stale.commit_next(&mut clock);
|
||||
}));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(clock.snapshot(), before);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,10 +120,25 @@ impl ChannelBackend {
|
|||
}
|
||||
}
|
||||
|
||||
struct CancelResultReceiverOnDrop {
|
||||
cancel_token: tokio_util::sync::CancellationToken,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl Drop for CancelResultReceiverOnDrop {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
self.cancel_token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SubagentBackend for ChannelBackend {
|
||||
async fn spawn(&self, request: SubagentRequest) -> Result<SubagentResult, ToolError> {
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
let cancel_on_receiver_drop = request.owner.is_workflow();
|
||||
let cancel_token = request.cancel_token.clone();
|
||||
|
||||
// Replace the dummy oneshot with our fresh one. Using struct update
|
||||
// syntax (`..request`) ensures new fields added to `SubagentRequest`
|
||||
|
|
@ -141,7 +156,19 @@ impl SubagentBackend for ChannelBackend {
|
|||
)
|
||||
})?;
|
||||
|
||||
result_rx.await.map_err(|_| {
|
||||
let mut receiver_guard = cancel_on_receiver_drop.then(|| CancelResultReceiverOnDrop {
|
||||
cancel_token: cancel_token.clone(),
|
||||
armed: true,
|
||||
});
|
||||
let result = result_rx.await;
|
||||
if result.is_ok() {
|
||||
if let Some(guard) = receiver_guard.as_mut() {
|
||||
guard.armed = false;
|
||||
}
|
||||
} else if cancel_on_receiver_drop {
|
||||
cancel_token.cancel();
|
||||
}
|
||||
result.map_err(|_| {
|
||||
ToolError::custom(
|
||||
"channel_closed",
|
||||
"Subagent result channel dropped — child session may have crashed",
|
||||
|
|
@ -349,7 +376,10 @@ mod tests {
|
|||
runtime_overrides: Default::default(),
|
||||
run_in_background: false,
|
||||
surface_completion: true,
|
||||
await_to_completion: false,
|
||||
fork_context: false,
|
||||
owner: super::super::types::SubagentOwner::Task,
|
||||
cancel_token: tokio_util::sync::CancellationToken::new(),
|
||||
result_tx: dummy_tx,
|
||||
};
|
||||
|
||||
|
|
@ -381,7 +411,10 @@ mod tests {
|
|||
runtime_overrides: Default::default(),
|
||||
run_in_background: false,
|
||||
surface_completion: true,
|
||||
await_to_completion: false,
|
||||
fork_context: false,
|
||||
owner: super::super::types::SubagentOwner::Task,
|
||||
cancel_token: tokio_util::sync::CancellationToken::new(),
|
||||
result_tx: dummy_tx,
|
||||
};
|
||||
|
||||
|
|
@ -511,6 +544,54 @@ mod tests {
|
|||
assert!(matches!(outcome, SubagentCancelOutcome::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workflow_spawn_future_drop_cancels_but_task_drop_does_not() {
|
||||
fn request_for(owner: super::super::types::SubagentOwner) -> SubagentRequest {
|
||||
let (dummy_tx, _dummy_rx) = oneshot::channel();
|
||||
SubagentRequest {
|
||||
id: "drop-owner-test".to_string(),
|
||||
prompt: "test".to_string(),
|
||||
description: "test".to_string(),
|
||||
subagent_type: "general-purpose".to_string(),
|
||||
parent_session_id: "parent".to_string(),
|
||||
parent_prompt_id: None,
|
||||
resume_from: None,
|
||||
cwd: None,
|
||||
runtime_overrides: Default::default(),
|
||||
run_in_background: false,
|
||||
surface_completion: false,
|
||||
await_to_completion: true,
|
||||
fork_context: false,
|
||||
owner,
|
||||
cancel_token: tokio_util::sync::CancellationToken::new(),
|
||||
result_tx: dummy_tx,
|
||||
}
|
||||
}
|
||||
|
||||
for (owner, should_cancel) in [
|
||||
(super::super::types::SubagentOwner::Task, false),
|
||||
(super::super::types::SubagentOwner::workflow("wf-1"), true),
|
||||
] {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
||||
let backend = Arc::new(ChannelBackend::new(tx));
|
||||
let request = request_for(owner);
|
||||
let cancel_token = request.cancel_token.clone();
|
||||
let task = tokio::spawn({
|
||||
let backend = backend.clone();
|
||||
async move { backend.spawn(request).await }
|
||||
});
|
||||
let spawned = recv_event!(rx, Spawn);
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
assert_eq!(
|
||||
cancel_token.is_cancelled(),
|
||||
should_cancel,
|
||||
"only workflow receiver drop owns cancellation"
|
||||
);
|
||||
drop(spawned.result_tx);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_backend_spawn_result_dropped() {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<SubagentEvent>();
|
||||
|
|
@ -534,7 +615,10 @@ mod tests {
|
|||
runtime_overrides: Default::default(),
|
||||
run_in_background: false,
|
||||
surface_completion: true,
|
||||
await_to_completion: false,
|
||||
fork_context: false,
|
||||
owner: super::super::types::SubagentOwner::Task,
|
||||
cancel_token: tokio_util::sync::CancellationToken::new(),
|
||||
result_tx: dummy_tx,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -315,11 +315,17 @@ impl xai_tool_runtime::Tool for TaskTool {
|
|||
harness_agent_type: None,
|
||||
completion_output_cap: None,
|
||||
spawn_depth: None,
|
||||
output_token_budget: None,
|
||||
output_schema: None,
|
||||
loop_task_id: None,
|
||||
},
|
||||
run_in_background: input.run_in_background,
|
||||
// Model-spawned subagents must still appear in the idle reminder.
|
||||
surface_completion: true,
|
||||
await_to_completion: false,
|
||||
fork_context: false,
|
||||
owner: SubagentOwner::Task,
|
||||
cancel_token: tokio_util::sync::CancellationToken::new(),
|
||||
result_tx,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -20,8 +20,37 @@ use std::sync::Arc;
|
|||
|
||||
use educe::Educe;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use xai_tool_types::{SubagentCapabilityMode, SubagentIsolationMode, WaitMode};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub enum SubagentOwner {
|
||||
#[default]
|
||||
Task,
|
||||
Workflow {
|
||||
run_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SubagentOwner {
|
||||
pub fn workflow(run_id: impl Into<String>) -> Self {
|
||||
Self::Workflow {
|
||||
run_id: run_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workflow_run_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Task => None,
|
||||
Self::Workflow { run_id } => Some(run_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_workflow(&self) -> bool {
|
||||
matches!(self, Self::Workflow { .. })
|
||||
}
|
||||
}
|
||||
|
||||
use crate::register_resource;
|
||||
|
||||
// Request / Response
|
||||
|
|
@ -60,9 +89,12 @@ pub struct SubagentRequest {
|
|||
/// between-turn "idle completion" reminder — used by harness-internal
|
||||
/// subagents like the goal planner/classifier that the model must never see.
|
||||
pub surface_completion: bool,
|
||||
pub await_to_completion: bool,
|
||||
/// Harness-only: seed child with normalized parent conversation, then append
|
||||
/// `prompt`. Not on TaskToolInput. Successful `resume_from` takes precedence.
|
||||
pub fork_context: bool,
|
||||
pub owner: SubagentOwner,
|
||||
pub cancel_token: CancellationToken,
|
||||
/// Oneshot channel for the coordinator to send back the result.
|
||||
#[educe(Debug(ignore))]
|
||||
pub result_tx: oneshot::Sender<SubagentResult>,
|
||||
|
|
@ -107,6 +139,9 @@ pub struct SubagentRuntimeOverrides {
|
|||
pub harness_agent_type: Option<String>,
|
||||
pub completion_output_cap: Option<usize>,
|
||||
pub spawn_depth: Option<u32>,
|
||||
pub output_token_budget: Option<u64>,
|
||||
pub output_schema: Option<serde_json::Value>,
|
||||
pub loop_task_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Re-export of [`xai_tool_types::is_not_sentinel`] for existing call sites.
|
||||
|
|
@ -325,8 +360,10 @@ pub struct SubagentResult {
|
|||
pub tool_calls: u32,
|
||||
pub turns: u32,
|
||||
pub duration_ms: u64,
|
||||
/// Total tokens consumed by the subagent's context window.
|
||||
pub tokens_used: u64,
|
||||
pub output_tokens_used: u64,
|
||||
pub total_tokens_used: u64,
|
||||
pub output_usage_incomplete: bool,
|
||||
/// Path to the isolated worktree if one was created.
|
||||
pub worktree_path: Option<String>,
|
||||
/// Set when a blocking subagent exceeded its await budget and was
|
||||
|
|
@ -349,6 +386,9 @@ impl Default for SubagentResult {
|
|||
turns: 0,
|
||||
duration_ms: 0,
|
||||
tokens_used: 0,
|
||||
output_tokens_used: 0,
|
||||
total_tokens_used: 0,
|
||||
output_usage_incomplete: false,
|
||||
worktree_path: None,
|
||||
backgrounded: false,
|
||||
}
|
||||
|
|
@ -385,6 +425,14 @@ pub struct SubagentQueryRequest {
|
|||
pub respond_to: oneshot::Sender<Option<SubagentSnapshot>>,
|
||||
}
|
||||
|
||||
#[derive(Educe)]
|
||||
#[educe(Debug)]
|
||||
pub struct SubagentLoopUnitActiveRequest {
|
||||
pub task_id: String,
|
||||
#[educe(Debug(ignore))]
|
||||
pub respond_to: oneshot::Sender<bool>,
|
||||
}
|
||||
|
||||
/// Point-in-time snapshot of a subagent's state.
|
||||
/// Returned by the coordinator in response to a `SubagentQueryRequest`.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -455,6 +503,7 @@ impl SubagentSnapshotStatus {
|
|||
pub enum SubagentCancelTarget {
|
||||
SubagentId(String),
|
||||
ParentPromptId(String),
|
||||
WorkflowRunId(String),
|
||||
}
|
||||
|
||||
/// Cancel request sent by KillTaskTool or session cancellation paths,
|
||||
|
|
@ -663,6 +712,7 @@ pub enum SubagentEvent {
|
|||
MarkUsageNotApplied(SubagentMarkUsageNotAppliedRequest),
|
||||
ValidateType(SubagentValidateTypeRequest),
|
||||
DescribeType(SubagentDescribeRequest),
|
||||
LoopUnitActive(SubagentLoopUnitActiveRequest),
|
||||
}
|
||||
|
||||
// Resource types
|
||||
|
|
|
|||
|
|
@ -0,0 +1,414 @@
|
|||
use crate::types::requirements::{Expr, ToolRequirement};
|
||||
use crate::types::tool::{ToolKind, ToolNamespace};
|
||||
|
||||
use super::task::MAX_SUBAGENT_DEPTH;
|
||||
use super::task::types::SubagentDepthCounter;
|
||||
|
||||
pub use xai_grok_tools_api::slash_commands::WORKFLOW_TOOL_NAME;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct WorkflowToolInput {
|
||||
#[serde(default)]
|
||||
#[schemars(
|
||||
range(min = 1, max = 1024),
|
||||
description = "Absolute cumulative cap on logical child-agent calls for this run. Every agent() and every parallel() item consumes one slot; schema retries do not. Defaults to 128 and may be set from 1 through 1,024. A panel that would exceed the remaining budget is rejected before any of its children launch."
|
||||
)]
|
||||
pub agent_budget: Option<u64>,
|
||||
|
||||
#[serde(default)]
|
||||
#[schemars(
|
||||
description = "Name of a registered workflow (built-in, or discovered from the project `.grok/workflows/` or user `~/.grok/workflows/`). Exactly one of `name`, `script`, or `script_path` must be set."
|
||||
)]
|
||||
pub name: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[schemars(
|
||||
description = "Inline Rhai workflow script. It must start with a pure-literal `let meta = #{ name: ..., description: ... };` map. Before authoring, read the `create-workflow` skill's SKILL.md. Run the path-specific `validate_only` smoke check with representative args."
|
||||
)]
|
||||
pub script: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Path to a .rhai workflow script on disk.")]
|
||||
pub script_path: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[schemars(
|
||||
description = "JSON value bound to the script's `args` global. Use an object for named arguments."
|
||||
)]
|
||||
pub args: Option<serde_json::Value>,
|
||||
|
||||
#[serde(default)]
|
||||
#[schemars(
|
||||
description = "Resume a same-process paused run, continuing its original immutable script and args; do not also pass name, script, script_path, or args. A budget-limited run resumes only when agent_budget is passed with a higher cap. Process-restart interruptions are terminal."
|
||||
)]
|
||||
pub resume_from_run_id: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
#[schemars(
|
||||
description = "Run a path-specific smoke check without launching: validate metadata, compile the full script, and execute the single path selected by the supplied args and canned host results. It does not exercise every branch or prove live tools and agent outputs work."
|
||||
)]
|
||||
pub validate_only: bool,
|
||||
}
|
||||
|
||||
impl WorkflowToolInput {
|
||||
pub const MAX_AGENT_BUDGET: u64 = 1_024;
|
||||
|
||||
pub fn normalize(&mut self) {
|
||||
self.name = blank_to_none(self.name.take());
|
||||
self.script = blank_to_none(self.script.take());
|
||||
self.script_path = blank_to_none(self.script_path.take());
|
||||
self.resume_from_run_id = blank_to_none(self.resume_from_run_id.take());
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if let Some(budget) = self.agent_budget {
|
||||
if budget == 0 {
|
||||
return Err("`agent_budget` must be a positive integer".into());
|
||||
}
|
||||
if budget > Self::MAX_AGENT_BUDGET {
|
||||
return Err(format!(
|
||||
"`agent_budget` must be at most {} agents",
|
||||
Self::MAX_AGENT_BUDGET
|
||||
));
|
||||
}
|
||||
}
|
||||
let present = |v: &Option<String>| v.as_deref().is_some_and(|s| !s.trim().is_empty());
|
||||
let sources = [
|
||||
present(&self.name),
|
||||
present(&self.script),
|
||||
present(&self.script_path),
|
||||
]
|
||||
.iter()
|
||||
.filter(|v| **v)
|
||||
.count();
|
||||
if present(&self.resume_from_run_id) {
|
||||
return match sources {
|
||||
0 => Ok(()),
|
||||
_ => Err(
|
||||
"`resume_from_run_id` continues a same-process paused run's original immutable script and args; do not combine it with `name`, `script`, or `script_path`"
|
||||
.into(),
|
||||
),
|
||||
};
|
||||
}
|
||||
match sources {
|
||||
0 => Err("provide one of `name`, `script`, or `script_path`".into()),
|
||||
1 => Ok(()),
|
||||
_ => Err("`name`, `script`, and `script_path` are mutually exclusive".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn blank_to_none(v: Option<String>) -> Option<String> {
|
||||
v.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowLaunchRequest {
|
||||
pub input: WorkflowToolInput,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WorkflowLaunchAck {
|
||||
Started {
|
||||
run_id: String,
|
||||
task_id: String,
|
||||
name: String,
|
||||
script_path: Option<String>,
|
||||
},
|
||||
Validated {
|
||||
name: String,
|
||||
phases: usize,
|
||||
summary: String,
|
||||
},
|
||||
Rejected {
|
||||
code: &'static str,
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub type WorkflowLaunchEnvelope = (
|
||||
WorkflowLaunchRequest,
|
||||
tokio::sync::oneshot::Sender<WorkflowLaunchAck>,
|
||||
);
|
||||
|
||||
pub struct WorkflowLaunchHandle(pub tokio::sync::mpsc::UnboundedSender<WorkflowLaunchEnvelope>);
|
||||
|
||||
impl std::fmt::Debug for WorkflowLaunchHandle {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WorkflowLaunchHandle").finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct WorkflowToolOutput {
|
||||
pub run_id: String,
|
||||
#[schemars(
|
||||
description = "Alias of run_id; workflow runs are not background tasks — do not pass to task_output/wait_tasks. Completion notifies automatically."
|
||||
)]
|
||||
pub task_id: String,
|
||||
#[schemars(
|
||||
description = "The session-unique display handle for this run, such as review-changes or review-changes-2. Use it in user-facing status and /workflow management; keep run_id internal."
|
||||
)]
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub script_path: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl xai_tool_runtime::ToolOutput for WorkflowToolOutput {}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WorkflowTool;
|
||||
|
||||
impl crate::types::tool_metadata::ToolMetadata for WorkflowTool {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Workflow
|
||||
}
|
||||
|
||||
fn tool_namespace(&self) -> ToolNamespace {
|
||||
ToolNamespace::GrokBuild
|
||||
}
|
||||
|
||||
fn description_template(&self) -> &str {
|
||||
r##"Launch a workflow: a Rhai script that orchestrates subagents as one background run. Provide exactly one source: `name` (a registered workflow — built-in, or from the project `.grok/workflows/` or user `~/.grok/workflows/`), an inline `script`, or a `script_path`. Optionally pass `args` (bound to the script's `args`) and `agent_budget`, an absolute cap on cumulative child-agent calls: every agent() and parallel() item consumes one slot (schema retries do not); default 128. The call returns immediately; progress appears in `/workflows` and completion is reported automatically — do not poll or sleep-wait.
|
||||
|
||||
Prefer a registered workflow when one fits; author a script for bounded fan-out over a known work list, staged research and verification, or several independent perspectives, and confirm unusually large fan-out first. Before writing or editing a script, read the `create-workflow` skill's SKILL.md. `validate_only: true` runs a path-specific smoke check (metadata, compile, one canned-host path) — not proof that every branch or live tool works.
|
||||
|
||||
A started run gets a session-unique display name (e.g. `review-changes`, `review-changes-2`) — the handle to show the user and use with `/workflow pause|resume|stop <name>`; keep run IDs internal. Each launch persists an editable `script_path`; edit it and launch as a new run to iterate. Use `resume_from_run_id` only for a same-process paused run (process restarts are terminal); a budget-limited run resumes only with a higher `agent_budget`. Save reusable scripts to `.grok/workflows/<name>.rhai`."##
|
||||
}
|
||||
|
||||
fn requires_expr(&self) -> Expr<ToolRequirement> {
|
||||
Expr::True
|
||||
}
|
||||
|
||||
fn is_read_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl xai_tool_runtime::Tool for WorkflowTool {
|
||||
type Args = WorkflowToolInput;
|
||||
type Output = WorkflowToolOutput;
|
||||
|
||||
fn id(&self) -> xai_tool_protocol::ToolId {
|
||||
xai_tool_protocol::ToolId::new(WORKFLOW_TOOL_NAME).expect("valid tool id")
|
||||
}
|
||||
|
||||
fn description(
|
||||
&self,
|
||||
_ctx: &::xai_tool_runtime::ListToolsContext,
|
||||
) -> xai_tool_types::ToolDescription {
|
||||
xai_tool_types::ToolDescription::new(
|
||||
WORKFLOW_TOOL_NAME,
|
||||
crate::types::tool_metadata::ToolMetadata::description_template(self),
|
||||
)
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> xai_tool_protocol::ToolCapabilities {
|
||||
xai_tool_protocol::ToolCapabilities {
|
||||
is_read_only: false,
|
||||
tool_scope: Some(xai_tool_protocol::ToolScope::Write),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "new_tool.workflow", skip_all)]
|
||||
async fn run(
|
||||
&self,
|
||||
ctx: xai_tool_runtime::ToolCallContext,
|
||||
mut input: WorkflowToolInput,
|
||||
) -> Result<WorkflowToolOutput, xai_tool_runtime::ToolError> {
|
||||
use crate::types::tool_metadata::shared_resources;
|
||||
let resources = shared_resources(&ctx)?;
|
||||
|
||||
input.normalize();
|
||||
|
||||
if let Err(detail) = input.validate() {
|
||||
return Err(xai_tool_runtime::ToolError::custom(
|
||||
"workflow_invalid_input",
|
||||
detail,
|
||||
));
|
||||
}
|
||||
|
||||
let (depth, sender) = {
|
||||
let res = resources.lock().await;
|
||||
let depth = res.get::<SubagentDepthCounter>().map(|d| d.0).unwrap_or(0);
|
||||
let sender = res.get::<WorkflowLaunchHandle>().map(|h| h.0.clone());
|
||||
(depth, sender)
|
||||
};
|
||||
|
||||
if depth >= MAX_SUBAGENT_DEPTH {
|
||||
return Err(xai_tool_runtime::ToolError::custom(
|
||||
"workflow_depth_exceeded",
|
||||
"Workflows can only be launched from a top-level session (subagents and \
|
||||
workflow-spawned agents cannot start workflows)",
|
||||
));
|
||||
}
|
||||
|
||||
let sender = sender.ok_or_else(|| {
|
||||
xai_tool_runtime::ToolError::custom(
|
||||
"workflow_not_available",
|
||||
"Workflow launching is not available in this session (WorkflowLaunchHandle not \
|
||||
registered)",
|
||||
)
|
||||
})?;
|
||||
|
||||
let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::<WorkflowLaunchAck>();
|
||||
sender
|
||||
.send((WorkflowLaunchRequest { input }, ack_tx))
|
||||
.map_err(|_| {
|
||||
xai_tool_runtime::ToolError::custom(
|
||||
"workflow_channel_closed",
|
||||
"Workflow launch channel closed — the session may be shutting down",
|
||||
)
|
||||
})?;
|
||||
|
||||
match ack_rx.await {
|
||||
Ok(WorkflowLaunchAck::Started {
|
||||
run_id,
|
||||
task_id,
|
||||
name,
|
||||
script_path,
|
||||
}) => Ok(WorkflowToolOutput {
|
||||
message: {
|
||||
let iterate = script_path
|
||||
.as_deref()
|
||||
.map(|p| {
|
||||
format!(
|
||||
" The editable script projection is at {p}. Edit it and launch \
|
||||
that `script_path` as a new run to iterate; same-process pause \
|
||||
resume continues only this run's original immutable source."
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"Workflow '{name}' started in the background. Progress appears in \
|
||||
/workflows and completion is reported automatically. '{name}' is the \
|
||||
session-unique display handle for user-facing status and /workflow \
|
||||
management; keep the structured run id internal.{iterate}"
|
||||
)
|
||||
},
|
||||
run_id,
|
||||
task_id,
|
||||
name,
|
||||
script_path,
|
||||
}),
|
||||
Ok(WorkflowLaunchAck::Validated {
|
||||
name,
|
||||
phases,
|
||||
summary,
|
||||
}) => Ok(WorkflowToolOutput {
|
||||
message: format!(
|
||||
"Smoke check passed for workflow '{name}' ({phases} declared phases; \
|
||||
canned-host path {summary}). This did not launch the workflow and did not \
|
||||
exercise every branch or live dependency. Offer a real run next."
|
||||
),
|
||||
run_id: String::new(),
|
||||
task_id: String::new(),
|
||||
name,
|
||||
script_path: None,
|
||||
}),
|
||||
Ok(WorkflowLaunchAck::Rejected { code, detail }) => {
|
||||
Err(xai_tool_runtime::ToolError::custom(code, detail))
|
||||
}
|
||||
Err(_) => Err(xai_tool_runtime::ToolError::custom(
|
||||
"workflow_launch_no_ack",
|
||||
"The session dropped the launch channel before answering; the workflow may not \
|
||||
have started.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validation_requires_exactly_one_source_and_bounded_positive_budget() {
|
||||
let base = WorkflowToolInput {
|
||||
agent_budget: None,
|
||||
name: None,
|
||||
script: None,
|
||||
script_path: None,
|
||||
args: None,
|
||||
resume_from_run_id: None,
|
||||
validate_only: false,
|
||||
};
|
||||
assert!(base.validate().is_err());
|
||||
|
||||
let named = WorkflowToolInput {
|
||||
name: Some("deep-research".into()),
|
||||
..base.clone()
|
||||
};
|
||||
assert!(named.validate().is_ok());
|
||||
|
||||
let both = WorkflowToolInput {
|
||||
name: Some("goal".into()),
|
||||
script: Some("let meta = #{};".into()),
|
||||
..base.clone()
|
||||
};
|
||||
assert!(both.validate().is_err());
|
||||
|
||||
let resume_only = WorkflowToolInput {
|
||||
resume_from_run_id: Some("wf_123".into()),
|
||||
..base.clone()
|
||||
};
|
||||
assert!(resume_only.validate().is_ok());
|
||||
|
||||
let edited_resume = WorkflowToolInput {
|
||||
script_path: Some("edited.rhai".into()),
|
||||
resume_from_run_id: Some("wf_123".into()),
|
||||
..base.clone()
|
||||
};
|
||||
assert!(edited_resume.validate().is_err());
|
||||
assert!(
|
||||
WorkflowToolInput {
|
||||
agent_budget: Some(10),
|
||||
resume_from_run_id: Some("wf_123".into()),
|
||||
name: None,
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
assert!(
|
||||
WorkflowToolInput {
|
||||
agent_budget: Some(0),
|
||||
name: Some("deep-research".into()),
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
WorkflowToolInput {
|
||||
agent_budget: Some(WorkflowToolInput::MAX_AGENT_BUDGET + 1),
|
||||
name: Some("deep-research".into()),
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
WorkflowToolInput {
|
||||
agent_budget: Some(1),
|
||||
name: Some("deep-research".into()),
|
||||
..base.clone()
|
||||
}
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
WorkflowToolInput {
|
||||
agent_budget: Some(1),
|
||||
script: Some("let meta = #{};".into()),
|
||||
validate_only: true,
|
||||
..base
|
||||
}
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,8 +16,7 @@ pub enum LspConfig {
|
|||
|
||||
impl LspConfig {
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
matches!(self, Self::Enabled { servers, .. }
|
||||
if !servers.is_empty())
|
||||
matches!(self, Self::Enabled { servers, .. } if !servers.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ pub fn canonical_input(input: &ToolInput) -> Option<serde_json::Value> {
|
|||
| ToolInput::SchedulerDelete(_)
|
||||
| ToolInput::SchedulerList(_)
|
||||
| ToolInput::UpdateGoal(_)
|
||||
| ToolInput::Workflow(_)
|
||||
| ToolInput::Dynamic(_) => return None,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,11 +40,9 @@ pub enum NotificationAcknowledgementError {
|
|||
#[must_use = "acknowledged notification receipts must be awaited"]
|
||||
pub struct NotificationAcknowledgementBatch {
|
||||
receipts: Vec<tokio::sync::oneshot::Receiver<Result<(), String>>>,
|
||||
durable_targets: usize,
|
||||
dispatch_closed: usize,
|
||||
}
|
||||
|
||||
/// Whether an acknowledged send has configured durable notification targets.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DurableNotificationTargets {
|
||||
None,
|
||||
|
|
@ -52,15 +50,6 @@ pub enum DurableNotificationTargets {
|
|||
}
|
||||
|
||||
impl NotificationAcknowledgementBatch {
|
||||
/// Whether any target was configured for durable acknowledgement.
|
||||
pub fn durable_targets(&self) -> DurableNotificationTargets {
|
||||
if self.durable_targets == 0 {
|
||||
DurableNotificationTargets::None
|
||||
} else {
|
||||
DurableNotificationTargets::Present
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for every live durable target and report all observed failure classes.
|
||||
pub async fn wait(self) -> Result<(), NotificationAcknowledgementError> {
|
||||
let mut acknowledgements_dropped = 0;
|
||||
|
|
@ -167,6 +156,16 @@ impl ToolNotificationHandle {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn durable_targets(&self) -> DurableNotificationTargets {
|
||||
if self.targets.iter().any(|target| {
|
||||
matches!(target, ToolNotificationTarget::Acknowledged(sender) if !sender.is_closed())
|
||||
}) {
|
||||
DurableNotificationTargets::Present
|
||||
} else {
|
||||
DurableNotificationTargets::None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&self, notification: ToolNotification) {
|
||||
let last = self.targets.len().saturating_sub(1);
|
||||
let mut notification = Some(notification);
|
||||
|
|
@ -204,7 +203,6 @@ impl ToolNotificationHandle {
|
|||
let notification = ToolNotification::ScheduledTaskRemoved(removed);
|
||||
let mut batch = NotificationAcknowledgementBatch {
|
||||
receipts: Vec::new(),
|
||||
durable_targets: 0,
|
||||
dispatch_closed: 0,
|
||||
};
|
||||
for target in self.targets.iter() {
|
||||
|
|
@ -213,7 +211,6 @@ impl ToolNotificationHandle {
|
|||
let _ = target.send(notification.clone());
|
||||
}
|
||||
ToolNotificationTarget::Acknowledged(target) => {
|
||||
batch.durable_targets += 1;
|
||||
let (acknowledgement, receipt) = tokio::sync::oneshot::channel();
|
||||
if target
|
||||
.send(AcknowledgedToolNotification {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ use super::*;
|
|||
fn removed(task_id: &str) -> ScheduledTaskRemoved {
|
||||
ScheduledTaskRemoved {
|
||||
task_id: task_id.into(),
|
||||
generation: String::new(),
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -12,6 +14,8 @@ fn created(task_id: &str) -> ScheduledTaskCreated {
|
|||
prompt: task_id.into(),
|
||||
human_schedule: "every 5 minutes".into(),
|
||||
next_fire_at: None,
|
||||
generation: String::new(),
|
||||
revision: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +44,6 @@ async fn acknowledged_removal_stays_in_fifo() {
|
|||
assert_eq!(task_id(&third.notification), "after");
|
||||
assert!(third.acknowledgement.is_none());
|
||||
|
||||
assert_eq!(batch.durable_targets(), DurableNotificationTargets::Present);
|
||||
batch.wait().await.unwrap();
|
||||
}
|
||||
|
||||
|
|
@ -103,15 +106,3 @@ async fn batch_distinguishes_dropped_and_rejected_acknowledgements() {
|
|||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plain_and_noop_batches_make_zero_durable_targets_explicit() {
|
||||
for handle in [
|
||||
ToolNotificationHandle::channel().0,
|
||||
ToolNotificationHandle::noop(),
|
||||
] {
|
||||
let batch = handle.send_scheduled_task_removed_acknowledged(removed("deleted"));
|
||||
assert_eq!(batch.durable_targets(), DurableNotificationTargets::None);
|
||||
batch.wait().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,6 +312,10 @@ pub struct ScheduledTaskFired {
|
|||
/// RFC3339 timestamp of next fire (for live countdown viz).
|
||||
pub next_fire_at: Option<String>,
|
||||
pub subagent_id: Option<String>,
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub generation: String,
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
/// Notification that a scheduled task was removed (deleted, expired, or one-shot completed).
|
||||
|
|
@ -319,6 +323,10 @@ pub struct ScheduledTaskFired {
|
|||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct ScheduledTaskRemoved {
|
||||
pub task_id: String,
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub generation: String,
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
/// Notification that a scheduled task was created and should appear in the tasks pane.
|
||||
|
|
@ -333,6 +341,10 @@ pub struct ScheduledTaskCreated {
|
|||
pub human_schedule: String,
|
||||
/// RFC3339 timestamp of next fire (for live countdown viz).
|
||||
pub next_fire_at: Option<String>,
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub generation: String,
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub revision: u64,
|
||||
}
|
||||
|
||||
/// A streaming event from a Monitor tool background process.
|
||||
|
|
@ -605,4 +617,21 @@ mod tests {
|
|||
other => panic!("expected BashOutputChunk, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_lifecycle_versions_default_for_legacy_json_and_round_trip() {
|
||||
let legacy: ScheduledTaskRemoved =
|
||||
serde_json::from_value(serde_json::json!({ "task_id": "loop-1" })).unwrap();
|
||||
assert_eq!(legacy.generation, "");
|
||||
assert_eq!(legacy.revision, 0);
|
||||
|
||||
let current = ScheduledTaskRemoved {
|
||||
task_id: "loop-1".into(),
|
||||
generation: "019b0000-0000-7000-8000-000000000000".into(),
|
||||
revision: 7,
|
||||
};
|
||||
let round_trip: ScheduledTaskRemoved =
|
||||
serde_json::from_value(serde_json::to_value(¤t).unwrap()).unwrap();
|
||||
assert_eq!(round_trip, current);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,10 +140,10 @@ mod tests {
|
|||
assert_eq!(err.tool_id, "GrokBuild:bash");
|
||||
assert_eq!(err.field_path(), "tools[3].params_json");
|
||||
assert!(matches!(
|
||||
&err.kind,
|
||||
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. }
|
||||
if raw == "{not json"
|
||||
));
|
||||
&err.kind,
|
||||
ToolConfigEntryErrorKind::ParamsJsonParse { raw, .. }
|
||||
if raw == "{not json"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -181,10 +181,10 @@ mod tests {
|
|||
assert_eq!(err.field_path(), "tools[2].name_override");
|
||||
assert!(
|
||||
matches!(
|
||||
&err.kind,
|
||||
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. }
|
||||
if n == name
|
||||
),
|
||||
&err.kind,
|
||||
ToolConfigEntryErrorKind::NameOverrideInvalid { name: n, .. }
|
||||
if n == name
|
||||
),
|
||||
"name={name:?} kind={:?}",
|
||||
err.kind
|
||||
);
|
||||
|
|
|
|||
|
|
@ -672,6 +672,7 @@ impl ToolRegistryBuilder {
|
|||
b.register::<grok_build::KillTerminalCommandTool>();
|
||||
b.register::<grok_build::TodoWriteTool>();
|
||||
b.register::<grok_build::UpdateGoalTool>();
|
||||
b.register::<grok_build::WorkflowTool>();
|
||||
b.register::<grok_build::TaskOutputTool>();
|
||||
b.register::<grok_build::GetTerminalCommandOutputTool>();
|
||||
b.register::<grok_build::WaitTasksTool>();
|
||||
|
|
@ -1185,9 +1186,12 @@ impl ToolRegistryBuilder {
|
|||
if let (Some(cmd_rx), Some(cancel_token)) = (scheduler_cmd_rx, &scheduler_cancel_token) {
|
||||
let actor = crate::implementations::grok_build::scheduler::actor::SchedulerActor {
|
||||
resources: shared_resources.clone(),
|
||||
resources_persistence: persistence.clone(),
|
||||
notification_handle: scheduler_notification_handle,
|
||||
cmd_rx,
|
||||
cancel_token: cancel_token.clone(),
|
||||
clock: Default::default(),
|
||||
pending_removal: None,
|
||||
};
|
||||
tokio::spawn(actor.run());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -596,6 +596,7 @@ pub fn consumed_completion_ids(output: &ToolOutput) -> Vec<&str> {
|
|||
| ToolOutput::SchedulerDelete(_)
|
||||
| ToolOutput::SchedulerList(_)
|
||||
| ToolOutput::UpdateGoal(_)
|
||||
| ToolOutput::Workflow(_)
|
||||
| ToolOutput::ImageGen(_)
|
||||
| ToolOutput::ImageToVideo(_)
|
||||
| ToolOutput::ReferenceToVideo(_)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ impl ToolKind {
|
|||
ToolKind::UseTool => "Use Tool",
|
||||
ToolKind::Monitor => "Monitor",
|
||||
ToolKind::GoalUpdate => "Update Goal",
|
||||
ToolKind::Workflow => "Workflow",
|
||||
ToolKind::Other => "Tool",
|
||||
}
|
||||
}
|
||||
|
|
@ -109,6 +110,7 @@ impl ToolKind {
|
|||
| ToolKind::UseTool
|
||||
| ToolKind::Monitor
|
||||
| ToolKind::GoalUpdate
|
||||
| ToolKind::Workflow
|
||||
| ToolKind::Other => false,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -647,6 +647,7 @@ pub enum ToolOutput {
|
|||
SchedulerDelete(crate::implementations::grok_build::scheduler::delete::SchedulerDeleteOutput),
|
||||
SchedulerList(crate::implementations::grok_build::scheduler::list::SchedulerListOutput),
|
||||
UpdateGoal(crate::implementations::grok_build::update_goal::UpdateGoalOutput),
|
||||
Workflow(crate::implementations::grok_build::workflow::WorkflowToolOutput),
|
||||
/// Dynamic output for runtime-registered tools (MCP, test tools, etc.)
|
||||
Dynamic(DynamicOutput),
|
||||
/// Generic text output for tools that produce simple formatted text
|
||||
|
|
@ -976,6 +977,7 @@ impl ToolOutput {
|
|||
}
|
||||
}
|
||||
ToolOutput::UpdateGoal(o) => o.summary.clone(),
|
||||
ToolOutput::Workflow(o) => o.message.clone(),
|
||||
ToolOutput::Dynamic(v) => serde_json::to_string_pretty(&v.value).unwrap_or_default(),
|
||||
ToolOutput::Text(text) => text.text.clone(),
|
||||
ToolOutput::ImageGen(m) => m.prompt_text("Image generated"),
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ pub enum ToolKind {
|
|||
UseTool,
|
||||
Monitor,
|
||||
GoalUpdate,
|
||||
Workflow,
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ pub enum ToolInput {
|
|||
SchedulerDelete(crate::implementations::grok_build::scheduler::delete::SchedulerDeleteInput),
|
||||
SchedulerList(crate::implementations::grok_build::scheduler::list::SchedulerListInput),
|
||||
UpdateGoal(UpdateGoalInput),
|
||||
Workflow(crate::implementations::grok_build::workflow::WorkflowToolInput),
|
||||
/// Dynamic input for runtime-registered tools (MCP, etc.)
|
||||
Dynamic(serde_json::Value),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue