mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Replace hardcoded intent patterns with job tools
Remove the brittle natural language pattern matching from the router and add job management tools to the normal tool registry instead. - Add job tools: create_job, list_jobs, job_status, cancel_job - Router now only handles explicit /commands - Natural language goes through agentic loop with all tools - LLM naturally picks appropriate tools based on user intent - Share ContextManager between job tools and Agent Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
27ffc12f6c
commit
7baf9e379d
+14
-13
@@ -64,13 +64,18 @@ pub struct Agent {
|
|||||||
|
|
||||||
impl Agent {
|
impl Agent {
|
||||||
/// Create a new agent.
|
/// Create a new agent.
|
||||||
|
///
|
||||||
|
/// Optionally accepts a pre-created `ContextManager` for sharing with job tools.
|
||||||
|
/// If not provided, creates a new one.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
deps: AgentDeps,
|
deps: AgentDeps,
|
||||||
channels: ChannelManager,
|
channels: ChannelManager,
|
||||||
heartbeat_config: Option<HeartbeatConfig>,
|
heartbeat_config: Option<HeartbeatConfig>,
|
||||||
|
context_manager: Option<Arc<ContextManager>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let context_manager = Arc::new(ContextManager::new(config.max_parallel_jobs));
|
let context_manager = context_manager
|
||||||
|
.unwrap_or_else(|| Arc::new(ContextManager::new(config.max_parallel_jobs)));
|
||||||
|
|
||||||
let scheduler = Arc::new(Scheduler::new(
|
let scheduler = Arc::new(Scheduler::new(
|
||||||
config.clone(),
|
config.clone(),
|
||||||
@@ -388,24 +393,20 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route for job commands (bypass turn system)
|
// Handle explicit commands (starting with /) directly
|
||||||
// Build a temporary message with the content to route
|
// Everything else goes through the normal agentic loop with tools
|
||||||
let temp_message = IncomingMessage {
|
let temp_message = IncomingMessage {
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
..message.clone()
|
..message.clone()
|
||||||
};
|
};
|
||||||
let intent = self.router.route(&temp_message);
|
|
||||||
match &intent {
|
if let Some(intent) = self.router.route_command(&temp_message) {
|
||||||
MessageIntent::CreateJob { .. }
|
// Explicit command like /status, /job, /list - handle directly
|
||||||
| MessageIntent::CheckJobStatus { .. }
|
|
||||||
| MessageIntent::CancelJob { .. }
|
|
||||||
| MessageIntent::ListJobs { .. }
|
|
||||||
| MessageIntent::HelpJob { .. }
|
|
||||||
| MessageIntent::Command { .. } => {
|
|
||||||
return self.handle_job_or_command(intent, message).await;
|
return self.handle_job_or_command(intent, message).await;
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
// Natural language goes through the agentic loop
|
||||||
|
// Job tools (create_job, list_jobs, etc.) are in the tool registry
|
||||||
|
|
||||||
// Auto-compact if needed BEFORE adding new turn
|
// Auto-compact if needed BEFORE adding new turn
|
||||||
{
|
{
|
||||||
|
|||||||
+62
-140
@@ -1,4 +1,8 @@
|
|||||||
//! Message routing to appropriate handlers.
|
//! Message routing to appropriate handlers.
|
||||||
|
//!
|
||||||
|
//! The router handles explicit commands (starting with `/`).
|
||||||
|
//! Natural language intent classification is handled by `IntentClassifier`
|
||||||
|
//! which uses LLM + tools instead of brittle pattern matching.
|
||||||
|
|
||||||
use crate::channels::IncomingMessage;
|
use crate::channels::IncomingMessage;
|
||||||
|
|
||||||
@@ -27,7 +31,9 @@ pub enum MessageIntent {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Routes messages to appropriate handlers based on intent.
|
/// Routes messages to appropriate handlers based on explicit commands.
|
||||||
|
///
|
||||||
|
/// For natural language messages, use `IntentClassifier` instead.
|
||||||
pub struct Router {
|
pub struct Router {
|
||||||
/// Command prefix (e.g., "/" or "!")
|
/// Command prefix (e.g., "/" or "!")
|
||||||
command_prefix: String,
|
command_prefix: String,
|
||||||
@@ -47,17 +53,23 @@ impl Router {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Route a message to determine its intent.
|
/// Check if a message is an explicit command.
|
||||||
pub fn route(&self, message: &IncomingMessage) -> MessageIntent {
|
pub fn is_command(&self, message: &IncomingMessage) -> bool {
|
||||||
let content = message.content.trim();
|
message.content.trim().starts_with(&self.command_prefix)
|
||||||
|
|
||||||
// Check for commands
|
|
||||||
if content.starts_with(&self.command_prefix) {
|
|
||||||
return self.parse_command(content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract intent from natural language
|
/// Route an explicit command to determine its intent.
|
||||||
self.extract_intent(content)
|
///
|
||||||
|
/// Returns `None` if the message is not a command.
|
||||||
|
/// For non-commands, use `IntentClassifier::classify()` instead.
|
||||||
|
pub fn route_command(&self, message: &IncomingMessage) -> Option<MessageIntent> {
|
||||||
|
let content = message.content.trim();
|
||||||
|
|
||||||
|
if content.starts_with(&self.command_prefix) {
|
||||||
|
Some(self.parse_command(content))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_command(&self, content: &str) -> MessageIntent {
|
fn parse_command(&self, content: &str) -> MessageIntent {
|
||||||
@@ -111,61 +123,6 @@ impl Router {
|
|||||||
None => MessageIntent::Unknown,
|
None => MessageIntent::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_intent(&self, content: &str) -> MessageIntent {
|
|
||||||
let lower = content.to_lowercase();
|
|
||||||
|
|
||||||
// Job creation patterns - must be explicit about creating a job
|
|
||||||
// More specific patterns to avoid capturing general conversation
|
|
||||||
let is_job_creation = lower.starts_with("create job ")
|
|
||||||
|| lower.starts_with("new job ")
|
|
||||||
|| lower.starts_with("schedule job ")
|
|
||||||
|| lower.starts_with("run job ")
|
|
||||||
|| (lower.contains("create") && lower.contains("job"));
|
|
||||||
|
|
||||||
if is_job_creation {
|
|
||||||
return MessageIntent::CreateJob {
|
|
||||||
title: extract_title(content),
|
|
||||||
description: content.to_string(),
|
|
||||||
category: extract_category(content),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status check patterns
|
|
||||||
if lower.contains("status")
|
|
||||||
|| lower.contains("how is")
|
|
||||||
|| lower.contains("progress")
|
|
||||||
|| lower.starts_with("check ")
|
|
||||||
{
|
|
||||||
return MessageIntent::CheckJobStatus {
|
|
||||||
job_id: extract_job_id(content),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel patterns
|
|
||||||
if lower.contains("cancel") || lower.contains("stop") || lower.contains("abort") {
|
|
||||||
if let Some(job_id) = extract_job_id(content) {
|
|
||||||
return MessageIntent::CancelJob { job_id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List patterns
|
|
||||||
if lower.starts_with("list") || lower.contains("show jobs") || lower.contains("my jobs") {
|
|
||||||
return MessageIntent::ListJobs { filter: None };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Help patterns
|
|
||||||
if lower.contains("stuck") || lower.contains("not working") || lower.contains("fix") {
|
|
||||||
if let Some(job_id) = extract_job_id(content) {
|
|
||||||
return MessageIntent::HelpJob { job_id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default to chat
|
|
||||||
MessageIntent::Chat {
|
|
||||||
content: content.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Router {
|
impl Default for Router {
|
||||||
@@ -174,53 +131,6 @@ impl Default for Router {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract a title from content.
|
|
||||||
fn extract_title(content: &str) -> String {
|
|
||||||
// Take first sentence or first N characters
|
|
||||||
let first_sentence = content.split('.').next().unwrap_or(content);
|
|
||||||
let title = first_sentence.chars().take(100).collect::<String>();
|
|
||||||
if title.len() < first_sentence.len() {
|
|
||||||
format!("{}...", title)
|
|
||||||
} else {
|
|
||||||
title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract a category from content.
|
|
||||||
fn extract_category(content: &str) -> Option<String> {
|
|
||||||
let lower = content.to_lowercase();
|
|
||||||
|
|
||||||
let categories = [
|
|
||||||
("code", "development"),
|
|
||||||
("program", "development"),
|
|
||||||
("website", "web"),
|
|
||||||
("api", "development"),
|
|
||||||
("data", "data"),
|
|
||||||
("write", "writing"),
|
|
||||||
("design", "design"),
|
|
||||||
("research", "research"),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (keyword, category) in categories {
|
|
||||||
if lower.contains(keyword) {
|
|
||||||
return Some(category.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract a job ID from content.
|
|
||||||
fn extract_job_id(content: &str) -> Option<String> {
|
|
||||||
// Look for UUID patterns
|
|
||||||
let uuid_regex = regex::Regex::new(
|
|
||||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
|
|
||||||
)
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
uuid_regex.find(content).map(|m| m.as_str().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -230,49 +140,61 @@ mod tests {
|
|||||||
let router = Router::new();
|
let router = Router::new();
|
||||||
|
|
||||||
let msg = IncomingMessage::new("test", "user", "/status abc-123");
|
let msg = IncomingMessage::new("test", "user", "/status abc-123");
|
||||||
let intent = router.route(&msg);
|
let intent = router.route_command(&msg);
|
||||||
|
|
||||||
assert!(matches!(intent, MessageIntent::CheckJobStatus { .. }));
|
assert!(matches!(intent, Some(MessageIntent::CheckJobStatus { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_natural_language_routing() {
|
fn test_is_command() {
|
||||||
let router = Router::new();
|
let router = Router::new();
|
||||||
|
|
||||||
// Explicit job creation with "create job" phrase
|
let cmd_msg = IncomingMessage::new("test", "user", "/status");
|
||||||
let msg = IncomingMessage::new("test", "user", "create job: build a website for me");
|
assert!(router.is_command(&cmd_msg));
|
||||||
let intent = router.route(&msg);
|
|
||||||
assert!(matches!(intent, MessageIntent::CreateJob { .. }));
|
|
||||||
|
|
||||||
// Also matches when both "create" and "job" are present
|
let chat_msg = IncomingMessage::new("test", "user", "Hello there");
|
||||||
let msg2 = IncomingMessage::new(
|
assert!(!router.is_command(&chat_msg));
|
||||||
"test",
|
|
||||||
"user",
|
|
||||||
"I need to create a new job to build a website",
|
|
||||||
);
|
|
||||||
let intent2 = router.route(&msg2);
|
|
||||||
assert!(matches!(intent2, MessageIntent::CreateJob { .. }));
|
|
||||||
|
|
||||||
// General requests without explicit "job" fall through to Chat
|
|
||||||
let msg3 = IncomingMessage::new("test", "user", "Can you create a website for me?");
|
|
||||||
let intent3 = router.route(&msg3);
|
|
||||||
assert!(matches!(intent3, MessageIntent::Chat { .. }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_chat_fallback() {
|
fn test_non_command_returns_none() {
|
||||||
let router = Router::new();
|
let router = Router::new();
|
||||||
|
|
||||||
let msg = IncomingMessage::new("test", "user", "Hello, how are you?");
|
// Natural language messages return None - they should use IntentClassifier
|
||||||
let intent = router.route(&msg);
|
let msg = IncomingMessage::new("test", "user", "Can you create a website for me?");
|
||||||
|
assert!(router.route_command(&msg).is_none());
|
||||||
|
|
||||||
assert!(matches!(intent, MessageIntent::Chat { .. }));
|
let msg2 = IncomingMessage::new("test", "user", "Hello, how are you?");
|
||||||
|
assert!(router.route_command(&msg2).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_job_id() {
|
fn test_command_create_job() {
|
||||||
let content = "Check status of job 550e8400-e29b-41d4-a716-446655440000";
|
let router = Router::new();
|
||||||
let id = extract_job_id(content);
|
|
||||||
assert_eq!(id, Some("550e8400-e29b-41d4-a716-446655440000".to_string()));
|
let msg = IncomingMessage::new("test", "user", "/job build a website");
|
||||||
|
let intent = router.route_command(&msg);
|
||||||
|
|
||||||
|
match intent {
|
||||||
|
Some(MessageIntent::CreateJob { title, .. }) => {
|
||||||
|
assert_eq!(title, "build a website");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected CreateJob intent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_list_jobs() {
|
||||||
|
let router = Router::new();
|
||||||
|
|
||||||
|
let msg = IncomingMessage::new("test", "user", "/list active");
|
||||||
|
let intent = router.route_command(&msg);
|
||||||
|
|
||||||
|
match intent {
|
||||||
|
Some(MessageIntent::ListJobs { filter }) => {
|
||||||
|
assert_eq!(filter, Some("active".to_string()));
|
||||||
|
}
|
||||||
|
_ => panic!("Expected ListJobs intent"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -665,7 +665,7 @@ impl SandboxModeConfig {
|
|||||||
key: "SANDBOX_ENABLED".to_string(),
|
key: "SANDBOX_ENABLED".to_string(),
|
||||||
message: format!("must be 'true' or 'false': {e}"),
|
message: format!("must be 'true' or 'false': {e}"),
|
||||||
})?
|
})?
|
||||||
.unwrap_or(false),
|
.unwrap_or(true),
|
||||||
policy: optional_env("SANDBOX_POLICY")?.unwrap_or_else(|| "readonly".to_string()),
|
policy: optional_env("SANDBOX_POLICY")?.unwrap_or_else(|| "readonly".to_string()),
|
||||||
timeout_secs: parse_optional_env("SANDBOX_TIMEOUT_SECS", 120)?,
|
timeout_secs: parse_optional_env("SANDBOX_TIMEOUT_SECS", 120)?,
|
||||||
memory_limit_mb: parse_optional_env("SANDBOX_MEMORY_LIMIT_MB", 2048)?,
|
memory_limit_mb: parse_optional_env("SANDBOX_MEMORY_LIMIT_MB", 2048)?,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use near_agent::{
|
|||||||
},
|
},
|
||||||
cli::{Cli, Command, run_tool_command},
|
cli::{Cli, Command, run_tool_command},
|
||||||
config::Config,
|
config::Config,
|
||||||
|
context::ContextManager,
|
||||||
history::Store,
|
history::Store,
|
||||||
llm::{SessionConfig, create_llm_provider, create_session_manager},
|
llm::{SessionConfig, create_llm_provider, create_session_manager},
|
||||||
safety::SafetyLayer,
|
safety::SafetyLayer,
|
||||||
@@ -384,6 +385,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create context manager (shared between job tools and agent)
|
||||||
|
let context_manager = Arc::new(ContextManager::new(config.agent.max_parallel_jobs));
|
||||||
|
|
||||||
|
// Register job tools
|
||||||
|
tools.register_job_tools(Arc::clone(&context_manager));
|
||||||
|
|
||||||
// Create and run the agent
|
// Create and run the agent
|
||||||
let deps = AgentDeps {
|
let deps = AgentDeps {
|
||||||
store,
|
store,
|
||||||
@@ -397,6 +404,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
deps,
|
deps,
|
||||||
channels,
|
channels,
|
||||||
Some(config.heartbeat.clone()),
|
Some(config.heartbeat.clone()),
|
||||||
|
Some(context_manager),
|
||||||
);
|
);
|
||||||
|
|
||||||
tracing::info!("Agent initialized, starting main loop...");
|
tracing::info!("Agent initialized, starting main loop...");
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
//! Job management tools.
|
||||||
|
//!
|
||||||
|
//! These tools allow the LLM to manage jobs:
|
||||||
|
//! - Create new jobs/tasks
|
||||||
|
//! - List existing jobs
|
||||||
|
//! - Check job status
|
||||||
|
//! - Cancel running jobs
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::context::{ContextManager, JobContext, JobState};
|
||||||
|
use crate::tools::tool::{Tool, ToolError, ToolOutput};
|
||||||
|
|
||||||
|
/// Tool for creating a new job.
|
||||||
|
pub struct CreateJobTool {
|
||||||
|
context_manager: Arc<ContextManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateJobTool {
|
||||||
|
pub fn new(context_manager: Arc<ContextManager>) -> Self {
|
||||||
|
Self { context_manager }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for CreateJobTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"create_job"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Create a new job or task for the agent to work on. Use this when the user wants \
|
||||||
|
you to do something substantial that should be tracked as a separate job."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A short title for the job (max 100 chars)"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Full description of what needs to be done"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title", "description"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
params: serde_json::Value,
|
||||||
|
_ctx: &JobContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let title = params
|
||||||
|
.get("title")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?;
|
||||||
|
|
||||||
|
let description = params
|
||||||
|
.get("description")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ToolError::InvalidParameters("missing 'description' parameter".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match self.context_manager.create_job(title, description).await {
|
||||||
|
Ok(job_id) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"job_id": job_id.to_string(),
|
||||||
|
"title": title,
|
||||||
|
"status": "pending",
|
||||||
|
"message": format!("Created job '{}'", title)
|
||||||
|
});
|
||||||
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"error": e.to_string()
|
||||||
|
});
|
||||||
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_sanitization(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tool for listing jobs.
|
||||||
|
pub struct ListJobsTool {
|
||||||
|
context_manager: Arc<ContextManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListJobsTool {
|
||||||
|
pub fn new(context_manager: Arc<ContextManager>) -> Self {
|
||||||
|
Self { context_manager }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for ListJobsTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"list_jobs"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"List all jobs or filter by status. Shows job IDs, titles, and current status."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filter": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filter by status: 'active', 'completed', 'failed', 'all' (default: 'all')",
|
||||||
|
"enum": ["active", "completed", "failed", "all"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
params: serde_json::Value,
|
||||||
|
_ctx: &JobContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let filter = params
|
||||||
|
.get("filter")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("all");
|
||||||
|
|
||||||
|
let job_ids = match filter {
|
||||||
|
"active" => self.context_manager.active_jobs().await,
|
||||||
|
_ => self.context_manager.all_jobs().await,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut jobs = Vec::new();
|
||||||
|
for job_id in job_ids {
|
||||||
|
if let Ok(ctx) = self.context_manager.get_context(job_id).await {
|
||||||
|
let include = match filter {
|
||||||
|
"completed" => ctx.state == JobState::Completed,
|
||||||
|
"failed" => ctx.state == JobState::Failed,
|
||||||
|
"active" => ctx.state.is_active(),
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if include {
|
||||||
|
jobs.push(serde_json::json!({
|
||||||
|
"job_id": job_id.to_string(),
|
||||||
|
"title": ctx.title,
|
||||||
|
"status": format!("{:?}", ctx.state),
|
||||||
|
"created_at": ctx.created_at.to_rfc3339()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = self.context_manager.summary().await;
|
||||||
|
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"jobs": jobs,
|
||||||
|
"summary": {
|
||||||
|
"total": summary.total,
|
||||||
|
"pending": summary.pending,
|
||||||
|
"in_progress": summary.in_progress,
|
||||||
|
"completed": summary.completed,
|
||||||
|
"failed": summary.failed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_sanitization(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tool for checking job status.
|
||||||
|
pub struct JobStatusTool {
|
||||||
|
context_manager: Arc<ContextManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobStatusTool {
|
||||||
|
pub fn new(context_manager: Arc<ContextManager>) -> Self {
|
||||||
|
Self { context_manager }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for JobStatusTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"job_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Check the status and details of a specific job by its ID."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"job_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The UUID of the job to check"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["job_id"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
params: serde_json::Value,
|
||||||
|
_ctx: &JobContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let job_id_str = params
|
||||||
|
.get("job_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
|
||||||
|
|
||||||
|
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
|
||||||
|
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
match self.context_manager.get_context(job_id).await {
|
||||||
|
Ok(ctx) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"job_id": job_id.to_string(),
|
||||||
|
"title": ctx.title,
|
||||||
|
"description": ctx.description,
|
||||||
|
"status": format!("{:?}", ctx.state),
|
||||||
|
"created_at": ctx.created_at.to_rfc3339(),
|
||||||
|
"started_at": ctx.started_at.map(|t| t.to_rfc3339()),
|
||||||
|
"completed_at": ctx.completed_at.map(|t| t.to_rfc3339()),
|
||||||
|
"actual_cost": ctx.actual_cost.to_string()
|
||||||
|
});
|
||||||
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"error": format!("Job not found: {}", e)
|
||||||
|
});
|
||||||
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_sanitization(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tool for canceling a job.
|
||||||
|
pub struct CancelJobTool {
|
||||||
|
context_manager: Arc<ContextManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CancelJobTool {
|
||||||
|
pub fn new(context_manager: Arc<ContextManager>) -> Self {
|
||||||
|
Self { context_manager }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for CancelJobTool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"cancel_job"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"Cancel a running or pending job. The job will be marked as cancelled and stopped."
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"job_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The UUID of the job to cancel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["job_id"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
params: serde_json::Value,
|
||||||
|
_ctx: &JobContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let job_id_str = params
|
||||||
|
.get("job_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| ToolError::InvalidParameters("missing 'job_id' parameter".into()))?;
|
||||||
|
|
||||||
|
let job_id = Uuid::parse_str(job_id_str).map_err(|_| {
|
||||||
|
ToolError::InvalidParameters(format!("invalid job ID format: {}", job_id_str))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Transition to cancelled state
|
||||||
|
match self
|
||||||
|
.context_manager
|
||||||
|
.update_context(job_id, |ctx| {
|
||||||
|
ctx.transition_to(JobState::Cancelled, Some("Cancelled by user".to_string()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(())) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"job_id": job_id.to_string(),
|
||||||
|
"status": "cancelled",
|
||||||
|
"message": "Job cancelled successfully"
|
||||||
|
});
|
||||||
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
|
}
|
||||||
|
Ok(Err(reason)) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"error": format!("Cannot cancel job: {}", reason)
|
||||||
|
});
|
||||||
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let result = serde_json::json!({
|
||||||
|
"error": format!("Job not found: {}", e)
|
||||||
|
});
|
||||||
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_approval(&self) -> bool {
|
||||||
|
true // Canceling a job should require approval
|
||||||
|
}
|
||||||
|
|
||||||
|
fn requires_sanitization(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_create_job_tool() {
|
||||||
|
let manager = Arc::new(ContextManager::new(5));
|
||||||
|
let tool = CreateJobTool::new(manager.clone());
|
||||||
|
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"title": "Test Job",
|
||||||
|
"description": "A test job description"
|
||||||
|
});
|
||||||
|
|
||||||
|
let ctx = JobContext::default();
|
||||||
|
let result = tool.execute(params, &ctx).await.unwrap();
|
||||||
|
|
||||||
|
let job_id = result.result.get("job_id").unwrap().as_str().unwrap();
|
||||||
|
assert!(!job_id.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_jobs_tool() {
|
||||||
|
let manager = Arc::new(ContextManager::new(5));
|
||||||
|
|
||||||
|
// Create some jobs
|
||||||
|
manager.create_job("Job 1", "Desc 1").await.unwrap();
|
||||||
|
manager.create_job("Job 2", "Desc 2").await.unwrap();
|
||||||
|
|
||||||
|
let tool = ListJobsTool::new(manager);
|
||||||
|
|
||||||
|
let params = serde_json::json!({});
|
||||||
|
let ctx = JobContext::default();
|
||||||
|
let result = tool.execute(params, &ctx).await.unwrap();
|
||||||
|
|
||||||
|
let jobs = result.result.get("jobs").unwrap().as_array().unwrap();
|
||||||
|
assert_eq!(jobs.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_job_status_tool() {
|
||||||
|
let manager = Arc::new(ContextManager::new(5));
|
||||||
|
let job_id = manager.create_job("Test Job", "Description").await.unwrap();
|
||||||
|
|
||||||
|
let tool = JobStatusTool::new(manager);
|
||||||
|
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"job_id": job_id.to_string()
|
||||||
|
});
|
||||||
|
let ctx = JobContext::default();
|
||||||
|
let result = tool.execute(params, &ctx).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.result.get("title").unwrap().as_str().unwrap(),
|
||||||
|
"Test Job"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ mod echo;
|
|||||||
mod ecommerce;
|
mod ecommerce;
|
||||||
mod file;
|
mod file;
|
||||||
mod http;
|
mod http;
|
||||||
|
mod job;
|
||||||
mod json;
|
mod json;
|
||||||
mod marketplace;
|
mod marketplace;
|
||||||
mod memory;
|
mod memory;
|
||||||
@@ -16,6 +17,7 @@ pub use echo::EchoTool;
|
|||||||
pub use ecommerce::EcommerceTool;
|
pub use ecommerce::EcommerceTool;
|
||||||
pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
|
pub use file::{ApplyPatchTool, ListDirTool, ReadFileTool, WriteFileTool};
|
||||||
pub use http::HttpTool;
|
pub use http::HttpTool;
|
||||||
|
pub use job::{CancelJobTool, CreateJobTool, JobStatusTool, ListJobsTool};
|
||||||
pub use json::JsonTool;
|
pub use json::JsonTool;
|
||||||
pub use marketplace::MarketplaceTool;
|
pub use marketplace::MarketplaceTool;
|
||||||
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool};
|
||||||
|
|||||||
+17
-2
@@ -5,12 +5,14 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::context::ContextManager;
|
||||||
use crate::llm::{LlmProvider, ToolDefinition};
|
use crate::llm::{LlmProvider, ToolDefinition};
|
||||||
use crate::safety::SafetyLayer;
|
use crate::safety::SafetyLayer;
|
||||||
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
|
use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder};
|
||||||
use crate::tools::builtin::{
|
use crate::tools::builtin::{
|
||||||
ApplyPatchTool, EchoTool, HttpTool, JsonTool, ListDirTool, MemoryReadTool, MemorySearchTool,
|
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, HttpTool, JobStatusTool, JsonTool,
|
||||||
MemoryTreeTool, MemoryWriteTool, ReadFileTool, ShellTool, TimeTool, WriteFileTool,
|
ListDirTool, ListJobsTool, MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool,
|
||||||
|
ReadFileTool, ShellTool, TimeTool, WriteFileTool,
|
||||||
};
|
};
|
||||||
use crate::tools::tool::Tool;
|
use crate::tools::tool::Tool;
|
||||||
use crate::tools::wasm::{
|
use crate::tools::wasm::{
|
||||||
@@ -144,6 +146,19 @@ impl ToolRegistry {
|
|||||||
tracing::info!("Registered 4 memory tools");
|
tracing::info!("Registered 4 memory tools");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register job management tools.
|
||||||
|
///
|
||||||
|
/// Job tools allow the LLM to create, list, check status, and cancel jobs.
|
||||||
|
/// These enable natural language job management without hardcoded intent parsing.
|
||||||
|
pub fn register_job_tools(&self, context_manager: Arc<ContextManager>) {
|
||||||
|
self.register_sync(Arc::new(CreateJobTool::new(Arc::clone(&context_manager))));
|
||||||
|
self.register_sync(Arc::new(ListJobsTool::new(Arc::clone(&context_manager))));
|
||||||
|
self.register_sync(Arc::new(JobStatusTool::new(Arc::clone(&context_manager))));
|
||||||
|
self.register_sync(Arc::new(CancelJobTool::new(context_manager)));
|
||||||
|
|
||||||
|
tracing::info!("Registered 4 job management tools");
|
||||||
|
}
|
||||||
|
|
||||||
/// Register the software builder tool.
|
/// Register the software builder tool.
|
||||||
///
|
///
|
||||||
/// The builder tool allows the agent to create new software including WASM tools,
|
/// The builder tool allows the agent to create new software including WASM tools,
|
||||||
|
|||||||
Reference in New Issue
Block a user