///|
/// A complete invocation of a JSONL-emitting agent CLI.
pub(all) struct Invocation {
command : String
arguments : Array[String]
environment : Map[String, String]
inherit_environment : Bool
input : String
}
///|
/// Construct an agent CLI invocation.
pub fn Invocation::new(
command~ : String,
arguments~ : Array[String],
input~ : String,
environment? : Map[String, String] = Map([]),
inherit_environment? : Bool = true,
) -> Invocation {
{ command, arguments, environment, inherit_environment, input }
}
///|
/// The process-level outcome of an agent CLI invocation.
pub(all) enum RunResult {
Completed
Stopped
Failed(code~ : Int, stderr~ : String)
} derive(Debug, Eq)
///|
/// Errors raised while reading or decoding the JSONL protocol.
pub(all) suberror AgentCliError {
InvalidJson(line~ : String, cause~ : String)
} derive(Debug, Eq)
///|
priv enum ChildExit {
Exited(Int)
CancelledForStop
}
///|
/// Run an agent CLI, write its stdin, and stream decoded JSONL events in order.
///
/// Returning `false` from `on_event` terminates the child and returns `Stopped`.
/// A nonzero child exit is returned as `Failed`; process and callback errors are
/// raised after all child resources have been cleaned up.
pub async fn[T : @json.FromJson] run(
invocation : Invocation,
on_event : async (T) -> Bool,
) -> RunResult {
let stop_requested = Ref(false)
@async.with_task_group(async fn(group) {
let (child_input, input_writer) = @process.write_to_process()
let (output_reader, child_output) = @process.read_from_process()
let (error_reader, child_error) = @process.read_from_process()
group.spawn_bg(async fn() {
defer input_writer.close()
input_writer.write(invocation.input)
})
let stderr_task = group.spawn(async fn() {
defer error_reader.close()
error_reader.read_all().text()
})
let child = @process.spawn(
group,
invocation.command,
invocation.arguments,
extra_env=invocation.environment,
inherit_env=invocation.inherit_environment,
stdin=child_input,
stdout=child_output,
stderr=child_error,
cancel_handler=@process.hard_cancel(),
)
let stdout_task = group.spawn(async fn() {
defer output_reader.close()
while output_reader.read_until("\n") is Some(line) {
let json = @json.parse(line) catch {
error => raise AgentCliError::InvalidJson(line~, cause="\{error}")
}
let event : T = @json.from_json(json) catch {
error => raise AgentCliError::InvalidJson(line~, cause="\{error}")
}
if !on_event(event) {
stop_requested.val = true
child.cancel()
break
}
}
})
let child_exit = try {
let code = child.wait()
if stop_requested.val {
CancelledForStop
} else {
Exited(code)
}
} catch {
error if stop_requested.val && @async.is_cancellation_error(error) =>
CancelledForStop
error => raise error
}
let exited_with_failure = match child_exit {
Exited(code) => code != 0
CancelledForStop => false
}
if exited_with_failure {
stdout_task.cancel()
stdout_task.wait() catch {
error => if !@async.is_cancellation_error(error) { raise error }
}
} else {
stdout_task.wait()
}
let stderr = if stop_requested.val {
stderr_task.cancel()
try stderr_task.wait() |> ignore catch {
error => if !@async.is_cancellation_error(error) { raise error }
}
""
} else {
stderr_task.wait()
}
match child_exit {
Exited(0) => Completed
Exited(code) => Failed(code~, stderr~)
CancelledForStop => Stopped
}
})
}