use super::{
CustomEnv, DEFAULT_OUTPUT_BYTES, ExecArgs, ExecOutput, Executor, MAX_OUTPUT_BYTES, ShellTool,
ShellToolHook,
};
use crate::{
context::BaseCtx,
extension::{hooked_call, tool_definition},
hook::{DynToolHook, ToolHook},
};
use anda_core::{BoxError, FunctionDefinition, Resource, Tool, ToolGroupInfo, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::Duration};
#[derive(Clone, Debug)]
pub struct ShellSessionScope(pub(crate) u128);
impl ShellSessionScope {
pub fn new() -> Self {
Self(rand::random())
}
}
impl Default for ShellSessionScope {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct SessionLimits {
pub max_sessions: usize,
pub max_runtime: Duration,
pub max_output_bytes: usize,
pub max_log_bytes: usize,
pub retention: Duration,
pub allow_stdin: bool,
pub allow_pty: bool,
}
impl Default for SessionLimits {
fn default() -> Self {
Self {
max_sessions: 64,
max_runtime: Duration::from_secs(600),
max_output_bytes: MAX_OUTPUT_BYTES,
max_log_bytes: 32 * 1024 * 1024,
retention: Duration::from_secs(300),
allow_stdin: false,
allow_pty: false,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct CommandArgs {
pub command: String,
#[serde(default)]
pub env_keys: Vec<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub background: bool,
#[serde(default)]
pub yield_time_ms: Option<u64>,
#[serde(default)]
pub max_output_bytes: Option<usize>,
#[serde(default)]
pub timeout_ms: Option<u64>,
#[serde(default)]
pub stdin: bool,
#[serde(default)]
pub tty: bool,
}
impl CommandArgs {
pub(crate) fn legacy_args(&self) -> ExecArgs {
ExecArgs {
command: self.command.clone(),
env_keys: self.env_keys.clone(),
background: self.background,
}
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CommandState {
#[default]
Running,
Exited,
Cancelled,
TimedOut,
Failed,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct CommandOutput {
#[serde(flatten)]
pub output: ExecOutput,
pub task_id: String,
pub state: CommandState,
pub exit_code: Option<i32>,
pub signal: Option<i32>,
pub omitted_bytes: usize,
pub log_complete: bool,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionAction {
#[default]
Poll,
Write,
CloseStdin,
Stop,
ReadLog,
List,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct SessionArgs {
#[schemars(with = "String", extend("enum" = ["poll", "write", "close_stdin", "stop", "read_log", "list"]))]
pub action: SessionAction,
#[serde(default)]
pub task_id: Option<String>,
#[serde(default)]
pub input: Option<String>,
#[serde(default)]
pub yield_time_ms: Option<u64>,
#[serde(default)]
pub max_output_bytes: Option<usize>,
#[serde(default)]
pub offset: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionInfo {
pub task_id: String,
pub state: CommandState,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct LogChunk {
pub text: String,
pub next_offset: u64,
pub eof: bool,
pub complete: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct SessionOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<CommandOutput>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sessions: Vec<SessionInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log: Option<LogChunk>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ExecutorCapabilities {
pub sessions: bool,
pub stdin: bool,
pub tty: bool,
pub sandboxed: bool,
}
fn group() -> ToolGroupInfo {
ToolGroupInfo { id: "shell_workspace".into(), title: "Shell execution".into(),
description: "Execute commands and supervise their sessions.".into(),
instructions: Some("Use shell for reading, searching, building and testing. Use the registered dedicated editing tool for file changes. If shell returns a running task_id, use shell_session to poll, stop or interact with it. A working directory is not a filesystem sandbox.".into()) }
}
pub type ShellCommandToolHook = DynToolHook<CommandArgs, CommandOutput>;
pub type ShellSessionToolHook = DynToolHook<SessionArgs, SessionOutput>;
#[derive(Clone)]
pub struct ShellCommandTool {
shell: ShellTool,
}
impl ShellCommandTool {
pub fn new(runtime: Arc<dyn Executor>, envs: Vec<CustomEnv>) -> Self {
Self {
shell: ShellTool::new_with_custom_envs(runtime, envs, None),
}
}
}
impl Tool<BaseCtx> for ShellCommandTool {
type Args = CommandArgs;
type Output = CommandOutput;
fn name(&self) -> String {
ShellTool::NAME.into()
}
fn description(&self) -> String {
format!(
"{}\nReturns a scoped session ID for commands still running after the wait. Runtime deadlines also apply in the background.",
self.shell.description
)
}
fn group(&self) -> Option<ToolGroupInfo> {
Some(group())
}
fn definition(&self) -> FunctionDefinition {
let mut definition = tool_definition::<CommandArgs>(self.name(), self.description());
definition.parameters["properties"]["env_keys"]["description"] =
self.shell.env_keys_parameter_description().into();
let caps = self.shell.runtime.capabilities();
for (name, enabled) in [("stdin", caps.stdin), ("tty", caps.tty)] {
if !enabled {
definition.parameters["properties"][name]["description"] =
"Disabled by this executor; leave false.".into();
}
}
definition
}
async fn call(
&self,
ctx: BaseCtx,
args: CommandArgs,
_: Vec<Resource>,
) -> Result<ToolOutput<CommandOutput>, BoxError> {
hooked_call(&ctx, args, |mut args| async {
let legacy = ctx.get_state::<ShellToolHook>();
if let Some(hook) = &legacy {
let gated = hook.before_tool_call(&ctx, args.legacy_args()).await?;
args.command = gated.command;
args.env_keys = gated.env_keys;
args.background = gated.background;
}
let env = self.shell.collect_shell_env_vars(&args.env_keys);
let output = self
.shell
.runtime
.execute_session(ctx.clone(), args, env)
.await?;
let is_error = matches!(output.state, CommandState::Failed | CommandState::TimedOut);
let output = ToolOutput {
is_error: is_error.then_some(true),
..ToolOutput::new(output)
};
match &legacy {
Some(hook) => legacy_after_tool_call(&ctx, hook, output).await,
None => Ok(output),
}
})
.await
}
}
async fn legacy_after_tool_call(
ctx: &BaseCtx,
hook: &ShellToolHook,
mut output: ToolOutput<CommandOutput>,
) -> Result<ToolOutput<CommandOutput>, BoxError> {
let legacy = hook
.after_tool_call(
ctx,
ToolOutput {
output: std::mem::take(&mut output.output.output),
model_output: output.model_output.take(),
is_error: output.is_error,
artifacts: std::mem::take(&mut output.artifacts),
usage: std::mem::take(&mut output.usage),
tools_usage: std::mem::take(&mut output.tools_usage),
},
)
.await?;
output.output.output = legacy.output;
output.model_output = legacy.model_output;
output.is_error = legacy.is_error;
output.artifacts = legacy.artifacts;
output.usage = legacy.usage;
output.tools_usage = legacy.tools_usage;
Ok(output)
}
#[derive(Clone)]
pub struct ShellSessionTool {
runtime: Arc<dyn Executor>,
}
impl ShellSessionTool {
pub fn new(runtime: Arc<dyn Executor>) -> Self {
Self { runtime }
}
}
impl Tool<BaseCtx> for ShellSessionTool {
type Args = SessionArgs;
type Output = SessionOutput;
fn name(&self) -> String {
"shell_session".into()
}
fn description(&self) -> String {
"Poll, list, stop, read logs, or send input to shell sessions owned by this conversation. Input and PTYs require host permission; polling does not extend a process deadline.".into()
}
fn group(&self) -> Option<ToolGroupInfo> {
Some(group())
}
fn definition(&self) -> FunctionDefinition {
tool_definition::<SessionArgs>(self.name(), self.description())
}
async fn call(
&self,
ctx: BaseCtx,
args: SessionArgs,
_: Vec<Resource>,
) -> Result<ToolOutput<SessionOutput>, BoxError> {
hooked_call(&ctx, args, |args| async {
let output = self.runtime.interact_session(ctx.clone(), args).await?;
let is_error = output.command.as_ref().is_some_and(|output| {
matches!(output.state, CommandState::Failed | CommandState::TimedOut)
});
Ok(ToolOutput {
is_error: is_error.then_some(true),
..ToolOutput::new(output)
})
})
.await
}
}
pub(super) fn budget(requested: Option<usize>, limits: &SessionLimits) -> usize {
requested
.unwrap_or(DEFAULT_OUTPUT_BYTES)
.clamp(128, limits.max_output_bytes.clamp(128, MAX_OUTPUT_BYTES))
}