Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/gall/daemon.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ fn git_blame_ffi(dir: String, path: String) -> String
@external(erlang, "gall_ffi", "git_show_file")
fn git_show_file_ffi(dir: String, ref: String, path: String) -> String

@external(erlang, "gall_ffi", "exec")
fn exec_ffi(dir: String, command: String) -> String

@external(erlang, "gall_ffi", "list_gestalt_sessions")
fn list_gestalt_sessions_ffi(gall_dir: String) -> String

Expand Down Expand Up @@ -383,6 +386,29 @@ fn handle_tool_call(
}
}

// Exec — shell command execution, witnessed as @exec
"exec" -> {
case json.get_string(args, "command") {
Error(_) -> #(
state,
Some(make_response(
id,
content_text(err_json("exec requires command")),
)),
None,
)
Ok(command) -> {
let out = exec_ffi(state.work_dir, command)
let #(next_state, exec_frag) = record_exec(state, command, out)
#(
next_state,
Some(make_response(id, content_text(json_string(out)))),
exec_frag,
)
}
}
}

_ -> #(
state,
Some(make_response(
Expand Down Expand Up @@ -592,6 +618,43 @@ fn record_read(
}
}

// ---------------------------------------------------------------------------
// @exec annotation
// ---------------------------------------------------------------------------

/// When the agent runs a shell command through gall, record it as an @exec
/// Fragment. The fragment data carries the command and a hash of the output
/// (not the full output, which could be huge).
fn record_exec(
state: State,
command: String,
output: String,
) -> #(State, Option(fragmentation.Fragment)) {
case state.sess {
Idle -> #(state, None)
Active(session: s, ..) as active -> {
let ts = int.to_string(now())
let author = case session.config(s) {
session.SessionConfig(author: a, ..) -> a
}
let fragmentation.Sha(self: output_hash) = fragmentation.hash(output)
let w =
fragmentation.witnessed(
fragmentation.Author(author),
fragmentation.Committer("gall"),
fragmentation.Timestamp(ts),
fragmentation.Message("@exec"),
)
let data = "command: " <> command <> "\noutput_sha: " <> output_hash
let r = fragmentation.ref(fragmentation.hash(ts <> data), "exec")
let frag = fragmentation.shard(r, w, data)
let #(s2, _) = session.act(s, "@exec", "command: " <> command)
let next_sess = Active(..active, session: s2)
#(State(..state, sess: next_sess), Some(frag))
}
}
}

fn path_visibility(path: String) -> String {
case string.starts_with(path, "visibility/private/") {
True -> ":private"
Expand Down
26 changes: 25 additions & 1 deletion src/gall/tools.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@

/// All tool names, in the order they appear in the daemon's tools/list.
pub fn tool_names() -> List(String) {
list_append(ado_tool_names(), git_tool_names())
list_append(
ado_tool_names(),
list_append(git_tool_names(), exec_tool_names()),
)
}

/// ADO witnessing tool names.
Expand All @@ -29,6 +32,11 @@ pub fn git_tool_names() -> List(String) {
["git_status", "git_diff", "git_log", "git_blame", "git_show_file"]
}

/// Exec tool names (daemon-only).
pub fn exec_tool_names() -> List(String) {
["exec"]
}

// ---------------------------------------------------------------------------
// Composed tool lists (for tools/list responses)
// ---------------------------------------------------------------------------
Expand All @@ -53,6 +61,8 @@ pub fn daemon_tools_json() -> String {
<> git_blame_schema()
<> ","
<> git_show_file_schema()
<> ","
<> exec_schema()
<> "]}"
}

Expand Down Expand Up @@ -183,6 +193,20 @@ pub fn git_show_file_schema() -> String {
<> "\"required\":[\"path\"]}}"
}

// ---------------------------------------------------------------------------
// Exec tool schema (daemon-only)
// ---------------------------------------------------------------------------

pub fn exec_schema() -> String {
"{\"name\":\"exec\","
<> "\"description\":\"Execute a shell command in the project directory. Witnessed as @exec in the session trace.\","
<> "\"inputSchema\":{\"type\":\"object\","
<> "\"properties\":{"
<> "\"command\":{\"type\":\"string\","
<> "\"description\":\"Shell command to execute.\"}},"
<> "\"required\":[\"command\"]}}"
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down
13 changes: 12 additions & 1 deletion src/gall_ffi.erl
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
git_show_file/3,
%% Gestalt resources
list_gestalt_sessions/1,
read_gestalt_session/2
read_gestalt_session/2,
%% Shell exec
exec/2
]).

%% ---------------------------------------------------------------------------
Expand Down Expand Up @@ -471,3 +473,12 @@ send_patch(RepoDir, Remote) ->
++ " push " ++ RemoteStr ++ " HEAD 2>&1")
end,
ok.

%% ---------------------------------------------------------------------------
%% Shell exec
%% ---------------------------------------------------------------------------

%% Execute a shell command in Dir, capturing stdout+stderr.
exec(Dir, Command) ->
Cmd = "cd " ++ binary_to_list(Dir) ++ " && " ++ binary_to_list(Command) ++ " 2>&1",
unicode:characters_to_binary(os:cmd(Cmd)).
36 changes: 36 additions & 0 deletions test/gall_exec_test.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import gall/tools
import gleam/list
import gleam/string
import gleeunit/should

// ---------------------------------------------------------------------------
// exec schema
// ---------------------------------------------------------------------------

pub fn exec_schema_has_name_test() {
let schema = tools.exec_schema()
schema |> string.contains("\"name\":\"exec\"") |> should.be_true()
}

pub fn exec_schema_has_required_command_test() {
let schema = tools.exec_schema()
schema |> string.contains("\"required\":[\"command\"]") |> should.be_true()
}

// ---------------------------------------------------------------------------
// tool names include exec
// ---------------------------------------------------------------------------

pub fn tool_names_include_exec_test() {
let names = tools.tool_names()
names |> list.contains("exec") |> should.be_true()
}

// ---------------------------------------------------------------------------
// daemon_tools_json includes exec
// ---------------------------------------------------------------------------

pub fn daemon_tools_json_contains_exec_test() {
let json = tools.daemon_tools_json()
json |> string.contains("\"exec\"") |> should.be_true()
}
2 changes: 1 addition & 1 deletion test/gall_tools_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pub fn tool_names_include_ado_tools_test() {
names
|> should.equal([
"observe", "decide", "act", "commit", "git_status", "git_diff", "git_log",
"git_blame", "git_show_file",
"git_blame", "git_show_file", "exec",
])
}

Expand Down
Loading