1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//! Saved scripts: shell scripts an agent owns and calls from its shell as
//! `tools scripts <name>` (Tools in Shell D8).
//!
//! Decision: the host binds the store to the session's agent before the turn
//! starts, so a script is always listed and saved for the agent whose turn it
//! is, and a tool never names an agent. The host also decides whether this
//! turn may save scripts (the capability's `manage_scripts` config), so the
//! shell does not read capability config.
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// One saved script.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SavedScript {
/// Lowercase name, unique per agent: `^[a-z][a-z0-9_-]{0,63}$`.
pub name: String,
/// One line on what it does, shown in `tools scripts`.
pub description: String,
/// JSON Schema for the input object, when the script declares one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_schema: Option<Value>,
/// The shell script.
pub body: String,
}
/// The current agent's saved scripts. Errors are messages a script can show.
#[async_trait]
pub trait SavedScriptStore: Send + Sync {
/// Every active script of the agent.
async fn list(&self) -> Result<Vec<SavedScript>, String>;
/// Create the script, or replace the description, schema and body of the
/// one with the same name.
async fn save(&self, script: SavedScript) -> Result<SavedScript, String>;
}
/// Tool context extension carrying the bound store.
#[derive(Clone)]
pub struct SavedScripts {
pub store: Arc<dyn SavedScriptStore>,
/// Whether this turn may save scripts.
pub can_save: bool,
}
impl std::fmt::Debug for SavedScripts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SavedScripts")
.field("can_save", &self.can_save)
.finish_non_exhaustive()
}
}
/// Message metadata key that marks a user message as a script run (D9): the
/// turn it starts runs the named saved script with no model call. Reserved:
/// client-supplied metadata loses it (`strip_reserved_message_metadata`), so
/// only the platform, for a trigger that targets a script, can set it.
pub const SCRIPT_RUN_METADATA_KEY: &str = "everruns_script_run";
/// A saved script a trigger runs instead of sending its message to the model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ScriptRun {
/// The saved script's name.
pub script: String,
/// The script's input object, passed on its stdin.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
pub input: Option<Value>,
/// When the run fails or stops, hand the result to the agent's model,
/// which then answers in the same turn. Off: the run only records it.
#[serde(default)]
pub wake_agent_on_failure: bool,
}
impl ScriptRun {
/// The script run a message's metadata asks for, if any.
pub fn from_metadata(
metadata: Option<&std::collections::HashMap<String, Value>>,
) -> Option<Self> {
serde_json::from_value(metadata?.get(SCRIPT_RUN_METADATA_KEY)?.clone()).ok()
}
/// The `bash` command that runs the script with its input on stdin.
///
/// The input travels in a quoted heredoc, so the shell expands nothing in
/// it; a delimiter that the JSON could contain is impossible because
/// serialized JSON never holds a raw newline.
pub fn command(&self) -> String {
let input = self
.input
.clone()
.unwrap_or_else(|| Value::Object(Default::default()));
format!(
"tools scripts {} <<'__EVERRUNS_SCRIPT_RUN__'\n{}\n__EVERRUNS_SCRIPT_RUN__",
self.script, input
)
}
}
/// Whether `name` is a valid script name.
pub fn is_valid_script_name(name: &str) -> bool {
let mut chars = name.chars();
matches!(chars.next(), Some('a'..='z'))
&& name.len() <= 64
&& chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_lowercase_words() {
for good in ["a", "triage-prs", "daily_report2"] {
assert!(is_valid_script_name(good), "{good}");
}
for bad in ["", "1abc", "Triage", "has space", "-x", &"a".repeat(65)] {
assert!(!is_valid_script_name(bad), "{bad}");
}
}
#[test]
fn a_script_run_reads_from_metadata_and_builds_its_command() {
let run = ScriptRun {
script: "triage-prs".into(),
input: Some(serde_json::json!({"repo": "a'b"})),
wake_agent_on_failure: false,
};
let metadata = std::collections::HashMap::from([(
SCRIPT_RUN_METADATA_KEY.to_string(),
serde_json::to_value(&run).unwrap(),
)]);
assert_eq!(ScriptRun::from_metadata(Some(&metadata)), Some(run.clone()));
assert_eq!(ScriptRun::from_metadata(None), None);
assert_eq!(
run.command(),
"tools scripts triage-prs <<'__EVERRUNS_SCRIPT_RUN__'\n{\"repo\":\"a'b\"}\n__EVERRUNS_SCRIPT_RUN__"
);
}
}