use async_trait::async_trait;
use everruns_core::ToolHints;
use everruns_core::tool_output_sanitizer::{
READ_FILE_DEFAULT_LIMIT, build_text_read_file_result, parse_read_file_window_args,
};
use everruns_core::tools::{Tool, ToolExecutionResult};
use everruns_core::traits::ToolContext;
use serde_json::{Value, json};
use tracing::debug;
use crate::client::{CreateSandboxRequest, DenoClient};
use crate::state::{
SandboxState, delete_sandbox_state, get_credentials, get_sandbox_state, list_sandbox_states,
release_sandbox_lease, required_str, save_sandbox_state, touch_sandbox_lease,
};
use crate::{DENO_DEFAULT_MEMORY_MB, DENO_MAX_MEMORY_MB, DENO_SANDBOX_TIMEOUT};
fn parse_memory_mb(arguments: &Value) -> Result<Option<u64>, String> {
let Some(value) = arguments.get("memory_mb") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let memory_mb = value
.as_u64()
.ok_or_else(|| "Invalid 'memory_mb': must be a positive integer".to_string())?;
if memory_mb == 0 || memory_mb > DENO_MAX_MEMORY_MB {
return Err(format!(
"Invalid 'memory_mb': must be between 1 and {DENO_MAX_MEMORY_MB}"
));
}
Ok(Some(memory_mb))
}
fn parse_timeout_seconds(timeout: &str) -> Result<u64, String> {
if timeout == "session" {
return Err("Deno sandboxes cannot use timeout='session' because Everruns closes the creator websocket after each tool. Use a concrete duration like '20m'.".to_string());
}
if let Some(minutes) = timeout.strip_suffix('m') {
let minutes = minutes
.parse::<u64>()
.map_err(|_| "Invalid timeout: expected e.g. '20m' or '600s'".to_string())?;
return minutes
.checked_mul(60)
.ok_or_else(|| "Timeout too large".to_string());
}
if let Some(seconds) = timeout.strip_suffix('s') {
return seconds
.parse::<u64>()
.map_err(|_| "Invalid timeout: expected e.g. '20m' or '600s'".to_string());
}
Err("Invalid timeout: expected e.g. '20m' or '600s'".to_string())
}
pub struct DenoCreateSandboxTool;
#[async_trait]
impl Tool for DenoCreateSandboxTool {
fn name(&self) -> &str {
"deno_create_sandbox"
}
fn description(&self) -> &str {
"Create a new Deno sandbox. Returns the sandbox id and workspace path."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"title": { "type": "string", "description": "Sandbox label shown in Deno" },
"region": { "type": "string", "description": "Region (for example 'ord' or 'ams')" },
"timeout": { "type": "string", "description": "Sandbox lifetime, e.g. '20m' or '600s'", "default": DENO_SANDBOX_TIMEOUT },
"memory_mb": { "type": "integer", "description": "Sandbox memory in MiB (1-16384)", "minimum": 1, "maximum": DENO_MAX_MEMORY_MB, "default": DENO_DEFAULT_MEMORY_MB },
"allow_net": {
"type": "array",
"items": { "type": "string" },
"description": "Optional outbound network allowlist hosts/IPs"
}
},
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_open_world(true)
.with_requires_secrets(true)
.with_long_running(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"deno_create_sandbox requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let credentials = match get_credentials(context).await {
Ok(credentials) => credentials,
Err(error) => return error,
};
let timeout = arguments
.get("timeout")
.and_then(Value::as_str)
.unwrap_or(DENO_SANDBOX_TIMEOUT);
let timeout_seconds = match parse_timeout_seconds(timeout) {
Ok(timeout) => timeout,
Err(error) => return ToolExecutionResult::tool_error(error),
};
let memory_mb = match parse_memory_mb(&arguments) {
Ok(memory_mb) => memory_mb,
Err(error) => return ToolExecutionResult::tool_error(error),
};
let region = arguments
.get("region")
.and_then(Value::as_str)
.map(str::to_string);
let title = arguments
.get("title")
.and_then(Value::as_str)
.unwrap_or("Everruns Deno Sandbox");
let allow_net = arguments
.get("allow_net")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut labels = serde_json::Map::new();
labels.insert("everruns".to_string(), json!("true"));
labels.insert(
"everruns.session_id".to_string(),
json!(context.session_id.to_string()),
);
labels.insert("everruns.title".to_string(), json!(title));
if let Some(session_store) = &context.session_store
&& let Ok(Some(session)) = session_store.get_session(context.session_id).await
{
labels.insert(
"everruns.harness_id".to_string(),
json!(session.harness_id.to_string()),
);
labels.insert(
"everruns.org_id".to_string(),
json!(session.organization_id.to_string()),
);
if let Some(agent_id) = &session.agent_id {
labels.insert("everruns.agent_id".to_string(), json!(agent_id.to_string()));
}
}
let client = DenoClient::new(credentials.token, credentials.org);
let created = match client
.create_sandbox(CreateSandboxRequest {
region,
timeout_seconds: Some(timeout_seconds),
memory_mb,
labels,
allow_net,
})
.await
{
Ok(created) => created,
Err(error) => return ToolExecutionResult::tool_error(error),
};
let state = SandboxState {
sandbox_id: created.sandbox_id.clone(),
region: created.region.clone(),
org: client.org().map(str::to_string),
workspace_path: created.workspace_path.clone(),
started_at: chrono::Utc::now().to_rfc3339(),
};
if let Err(error) = save_sandbox_state(context, &state).await {
return error;
}
if let Err(error) = touch_sandbox_lease(context, &state, Some(title.to_string())).await {
return error;
}
ToolExecutionResult::success(json!({
"sandbox_id": created.sandbox_id,
"region": created.region,
"workspace_path": created.workspace_path,
"status": "running",
"timeout": timeout,
}))
}
fn requires_context(&self) -> bool {
true
}
}
pub struct DenoExecTool;
#[async_trait]
impl Tool for DenoExecTool {
fn narrate(
&self,
tool_call: &everruns_core::tool_types::ToolCall,
phase: everruns_core::tool_narration::ToolNarrationPhase,
locale: Option<&str>,
_ctx: everruns_core::tool_narration::ToolNarrationContext<'_>,
) -> Option<String> {
let fallback = self.display_name().unwrap_or("Deno");
Some(everruns_core::tool_narration::narrate_shell_exec(
&tool_call.arguments,
fallback,
phase,
locale,
))
}
fn name(&self) -> &str {
"deno_exec"
}
fn description(&self) -> &str {
"Execute a shell command in a Deno sandbox. Returns stdout, stderr, and exit code."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"sandbox_id": { "type": "string", "description": "Sandbox ID" },
"command": { "type": "string", "description": "Shell command to run" },
"cwd": { "type": "string", "description": "Optional working directory" },
"output": everruns_core::tool_output_sanitizer::output_verbosity_schema()
},
"required": ["sandbox_id", "command"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_open_world(true)
.with_requires_secrets(true)
.with_long_running(true)
.with_persist_output(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"deno_exec requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let sandbox_id = match required_str(&arguments, "sandbox_id") {
Ok(value) => value,
Err(error) => return error,
};
let command = match required_str(&arguments, "command") {
Ok(value) => value,
Err(error) => return error,
};
let cwd = arguments.get("cwd").and_then(Value::as_str);
let output_mode = arguments
.get("output")
.and_then(|v| v.as_str())
.unwrap_or("auto");
let credentials = match get_credentials(context).await {
Ok(credentials) => credentials,
Err(error) => return error,
};
let state = match get_sandbox_state(context, sandbox_id).await {
Ok(state) => state,
Err(error) => return error,
};
let client = DenoClient::new(credentials.token, credentials.org);
debug!(sandbox_id, command, "Executing command in Deno sandbox");
let exec = match client.exec(sandbox_id, &state.region, command, cwd).await {
Ok(exec) => exec,
Err(error) => return ToolExecutionResult::tool_error(error),
};
if let Err(error) = touch_sandbox_lease(context, &state, None).await {
return error;
}
{
use everruns_core::tool_output_sanitizer::{
clean_exec_output, output_verbosity_budget, priority_aware_truncate,
resolve_auto_mode,
};
let clean_stdout = clean_exec_output(&exec.stdout);
let clean_stderr = clean_exec_output(&exec.stderr);
let effective_mode = resolve_auto_mode(output_mode, exec.exit_code);
let (stdout, stderr) = if let Some(budget) = output_verbosity_budget(effective_mode) {
(
priority_aware_truncate(&clean_stdout, budget),
priority_aware_truncate(&clean_stderr, budget.min(4096)),
)
} else {
(clean_stdout.clone(), clean_stderr.clone())
};
let mut raw = clean_stdout;
if !clean_stderr.is_empty() {
raw.push_str("\n--- stderr ---\n");
raw.push_str(&clean_stderr);
}
ToolExecutionResult::success_with_raw_output(
json!({
"exit_code": exec.exit_code,
"stdout": stdout,
"stderr": stderr,
}),
raw,
)
}
}
fn requires_context(&self) -> bool {
true
}
}
pub struct DenoReadFileTool;
#[async_trait]
impl Tool for DenoReadFileTool {
fn name(&self) -> &str {
"deno_read_file"
}
fn description(&self) -> &str {
"Read a text file from a remote Deno sandbox filesystem (NOT the session /workspace). Requires sandbox_id."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"sandbox_id": { "type": "string", "description": "Sandbox ID" },
"path": { "type": "string", "description": "Path to read" },
"offset": {
"type": "integer",
"minimum": 0,
"default": 0,
"description": "Zero-based line offset to start reading from"
},
"limit": {
"type": "integer",
"minimum": 1,
"default": READ_FILE_DEFAULT_LIMIT,
"description": "Maximum number of lines to return"
}
},
"required": ["sandbox_id", "path"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_readonly(true)
.with_open_world(true)
.with_requires_secrets(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"deno_read_file requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let sandbox_id = match required_str(&arguments, "sandbox_id") {
Ok(value) => value,
Err(error) => return error,
};
let path = match required_str(&arguments, "path") {
Ok(value) => value,
Err(error) => return error,
};
let (offset, limit) = match parse_read_file_window_args(&arguments) {
Ok(window) => window,
Err(err) => return ToolExecutionResult::tool_error(err),
};
let credentials = match get_credentials(context).await {
Ok(credentials) => credentials,
Err(error) => return error,
};
let state = match get_sandbox_state(context, sandbox_id).await {
Ok(state) => state,
Err(error) => return error,
};
let client = DenoClient::new(credentials.token, credentials.org);
let content = match client.read_text_file(sandbox_id, &state.region, path).await {
Ok(content) => content,
Err(error) => return ToolExecutionResult::tool_error(error),
};
if let Err(error) = touch_sandbox_lease(context, &state, None).await {
return error;
}
let mut result =
build_text_read_file_result("deno_read_file", path, &content, "text", offset, limit);
result["sandbox_id"] = json!(sandbox_id);
ToolExecutionResult::success(result)
}
fn requires_context(&self) -> bool {
true
}
}
pub struct DenoWriteFileTool;
#[async_trait]
impl Tool for DenoWriteFileTool {
fn name(&self) -> &str {
"deno_write_file"
}
fn description(&self) -> &str {
"Write a text file into a Deno sandbox filesystem."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"sandbox_id": { "type": "string", "description": "Sandbox ID" },
"path": { "type": "string", "description": "Path to write" },
"content": { "type": "string", "description": "File content" }
},
"required": ["sandbox_id", "path", "content"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_open_world(true)
.with_requires_secrets(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"deno_write_file requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let sandbox_id = match required_str(&arguments, "sandbox_id") {
Ok(value) => value,
Err(error) => return error,
};
let path = match required_str(&arguments, "path") {
Ok(value) => value,
Err(error) => return error,
};
let content = match required_str(&arguments, "content") {
Ok(value) => value,
Err(error) => return error,
};
let credentials = match get_credentials(context).await {
Ok(credentials) => credentials,
Err(error) => return error,
};
let state = match get_sandbox_state(context, sandbox_id).await {
Ok(state) => state,
Err(error) => return error,
};
let client = DenoClient::new(credentials.token, credentials.org);
match client
.write_text_file(sandbox_id, &state.region, path, content)
.await
{
Ok(()) => {}
Err(error) => return ToolExecutionResult::tool_error(error),
}
if let Err(error) = touch_sandbox_lease(context, &state, None).await {
return error;
}
ToolExecutionResult::success(json!({
"path": path,
"success": true,
}))
}
fn requires_context(&self) -> bool {
true
}
}
pub struct DenoListSandboxesTool;
#[async_trait]
impl Tool for DenoListSandboxesTool {
fn name(&self) -> &str {
"deno_list_sandboxes"
}
fn description(&self) -> &str {
"List all Deno sandboxes created in this session."
}
fn parameters_schema(&self) -> Value {
json!({ "type": "object", "properties": {}, "additionalProperties": false })
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_readonly(true)
.with_idempotent(true)
.with_open_world(true)
.with_requires_secrets(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"deno_list_sandboxes requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
_arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let sandboxes = match list_sandbox_states(context).await {
Ok(sandboxes) => sandboxes,
Err(error) => return error,
};
ToolExecutionResult::success(json!({
"sandboxes": sandboxes.iter().map(|state| {
json!({
"sandbox_id": state.sandbox_id,
"region": state.region,
"workspace_path": state.workspace_path,
"started_at": state.started_at,
})
}).collect::<Vec<_>>(),
"count": sandboxes.len(),
}))
}
fn requires_context(&self) -> bool {
true
}
}
pub struct DenoManageSandboxTool;
#[async_trait]
impl Tool for DenoManageSandboxTool {
fn name(&self) -> &str {
"deno_manage_sandbox"
}
fn description(&self) -> &str {
"Delete a Deno sandbox."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"sandbox_id": { "type": "string", "description": "Sandbox ID" },
"action": { "type": "string", "enum": ["delete"], "description": "Lifecycle action" }
},
"required": ["sandbox_id", "action"],
"additionalProperties": false
})
}
fn hints(&self) -> ToolHints {
ToolHints::default()
.with_open_world(true)
.with_requires_secrets(true)
.with_destructive(true)
}
async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"deno_manage_sandbox requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: Value,
context: &ToolContext,
) -> ToolExecutionResult {
let sandbox_id = match required_str(&arguments, "sandbox_id") {
Ok(value) => value,
Err(error) => return error,
};
let action = match required_str(&arguments, "action") {
Ok(value) => value,
Err(error) => return error,
};
if action != "delete" {
return ToolExecutionResult::tool_error(
"Unsupported action. Deno sandboxes currently support only action='delete'.",
);
}
let credentials = match get_credentials(context).await {
Ok(credentials) => credentials,
Err(error) => return error,
};
let state = match get_sandbox_state(context, sandbox_id).await {
Ok(state) => state,
Err(error) => return error,
};
let client = DenoClient::new(credentials.token, credentials.org);
if let Err(error) = client.delete_sandbox(sandbox_id, &state.region).await {
return ToolExecutionResult::tool_error(error);
}
if let Err(error) = delete_sandbox_state(context, sandbox_id).await {
return error;
}
if let Err(error) = release_sandbox_lease(context, sandbox_id).await {
return error;
}
ToolExecutionResult::success(json!({
"sandbox_id": sandbox_id,
"action": action,
"success": true,
}))
}
fn requires_context(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_timeout_rejects_session() {
let err = parse_timeout_seconds("session").unwrap_err();
assert!(err.contains("session"));
}
#[test]
fn parse_memory_validates_bounds() {
assert!(parse_memory_mb(&json!({"memory_mb": 0})).is_err());
assert!(
parse_memory_mb(&json!({"memory_mb": 1280}))
.unwrap()
.is_some()
);
}
#[test]
fn create_tool_mentions_fixed_timeout() {
let tool = DenoCreateSandboxTool;
let schema = tool.parameters_schema();
assert_eq!(
schema["properties"]["timeout"]["default"],
DENO_SANDBOX_TIMEOUT
);
assert_eq!(crate::DENO_WORKSPACE_PATH, "/home/app");
}
}