use crate::extension::{Extension, ExtensionError, RunContext, RunHooks};
use crate::types::*;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use yoagent_state::{
Goal, YoAgentModelCalled, YoAgentModelFinished, YoAgentRunFinished, YoAgentRunStarted,
YoAgentStateAdapter, YoAgentStateSink, YoAgentToolCalled, YoAgentToolFinished,
};
pub use yoagent_state::{
ActorRef, ArtifactRef, Decision, DecisionId, DecisionStatus, EvalId, EvalResult, EvalStatus,
Event, EventId, EventStore, ExpectedEffect, Frame, FrameId, GitEventStore, Goal as GaspGoal,
GoalId, GoalStatus, Hypothesis, HypothesisId, ModelCall, Node, NodeId, Observation,
ObservationId, PatchId, PatchStatus, Precondition, ProjectRef, ProjectSnapshot, RunId,
StateError, StateOp, StatePatch, Task, TaskId, TaskStatus, ToolCall as GaspToolCall,
YoAgentState,
};
#[derive(Debug, Clone)]
pub enum GoalRef {
Existing(GoalId),
New { title: String },
}
type Summarizer = std::sync::Arc<dyn Fn(&str) -> String + Send + Sync>;
pub struct GaspRecorder {
state: YoAgentState<GitEventStore>,
store: GitEventStore,
actor: ActorRef,
goal: GoalId,
summarize: Summarizer,
route: Route,
}
pub const PLUGIN_LOG_TARGET: &str = "yoagent_rutis::plugin";
type Route = Arc<Mutex<Option<OpenRoute>>>;
struct OpenRoute {
runs: HashSet<String>,
tx: mpsc::UnboundedSender<PluginLog>,
}
struct PluginLog {
level: tracing::Level,
message: String,
}
#[derive(Clone)]
pub struct GaspRunLink {
route: Route,
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl Extension for GaspRunLink {
fn name(&self) -> &str {
"gasp-run-link"
}
async fn start_run(&self, run: &RunContext<'_>) -> Result<Box<dyn RunHooks>, ExtensionError> {
if let Some(open) = self
.route
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
{
open.runs.insert(run.run_id.to_string());
}
Ok(Box::new(LinkedRun))
}
}
struct LinkedRun;
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl RunHooks for LinkedRun {}
#[derive(Clone)]
pub struct PluginLogLayer {
route: Route,
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for PluginLogLayer {
fn on_event(&self, event: &tracing::Event<'_>, _: tracing_subscriber::layer::Context<'_, S>) {
let meta = event.metadata();
if meta.target() != PLUGIN_LOG_TARGET {
return;
}
#[derive(Default)]
struct Fields {
run_id: String,
message: String,
}
impl tracing::field::Visit for Fields {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
match field.name() {
"run_id" => self.run_id = value.to_string(),
"message" => self.message = value.to_string(),
_ => {}
}
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
match field.name() {
"run_id" => self.run_id = format!("{value:?}"),
"message" => self.message = format!("{value:?}"),
_ => {}
}
}
}
let mut fields = Fields::default();
event.record(&mut fields);
let route = self.route.lock().unwrap_or_else(|e| e.into_inner());
if let Some(open) = route.as_ref() {
if open.runs.contains(&fields.run_id) {
let _ = open.tx.send(PluginLog {
level: *meta.level(),
message: fields.message,
});
}
}
}
}
impl GaspRecorder {
pub async fn init(
root: impl AsRef<std::path::Path>,
agent_id: &str,
worker_id: &str,
goal: GoalRef,
) -> Result<Self, StateError> {
let store = yoagent_state::init_agent_repo(root, agent_id, worker_id)?;
Self::with_store(store, agent_id, goal).await
}
pub async fn open(
root: impl Into<std::path::PathBuf>,
agent_id: &str,
worker_id: &str,
goal: GoalRef,
) -> Result<Self, StateError> {
let store = GitEventStore::open(root, worker_id)?;
Self::with_store(store, agent_id, goal).await
}
pub async fn with_store(
store: GitEventStore,
agent_id: &str,
goal: GoalRef,
) -> Result<Self, StateError> {
let actor = ActorRef::agent(agent_id);
let state = YoAgentState::load(store.clone()).await?;
if let Some(stale) = state.resume_open_run().await? {
tracing::warn!(run = %stale, "closing stale open run as interrupted");
state
.record_run_finished(actor.clone(), stale, "interrupted")
.await
.map_err(|e| {
StateError::Validation(format!(
"found an open run and could not close it ({e}); if another \
worker is live on this repo, do not share it — GASP repos \
are single-writer"
))
})?;
}
let goal = match goal {
GoalRef::Existing(id) => {
if state.get_node(NodeId::new(id.as_str())).await.is_none() {
return Err(StateError::Validation(format!(
"goal {id} does not exist in this repo's graph"
)));
}
id
}
GoalRef::New { title } => {
let id = GoalId::generate();
state
.record_goal(Goal::new(id.clone(), title.clone(), title, actor.clone()))
.await?;
id
}
};
commit_scaffolding(&store)?;
Ok(Self {
state,
store,
actor,
goal,
summarize: std::sync::Arc::new(|text: &str| summarize(text)),
route: Route::default(),
})
}
pub fn with_summarizer(
mut self,
summarize: impl Fn(&str) -> String + Send + Sync + 'static,
) -> Self {
self.summarize = std::sync::Arc::new(summarize);
self
}
pub fn state(&self) -> &YoAgentState<GitEventStore> {
&self.state
}
pub fn store(&self) -> &GitEventStore {
&self.store
}
pub fn actor(&self) -> &ActorRef {
&self.actor
}
pub fn goal(&self) -> &GoalId {
&self.goal
}
pub fn extension(&self) -> GaspRunLink {
GaspRunLink {
route: self.route.clone(),
}
}
pub fn plugin_log_layer(&self) -> PluginLogLayer {
PluginLogLayer {
route: self.route.clone(),
}
}
pub fn recording_sender(
&self,
task: impl Into<String>,
forward: Option<mpsc::UnboundedSender<AgentEvent>>,
) -> (
mpsc::UnboundedSender<AgentEvent>,
JoinHandle<Result<Option<RunId>, StateError>>,
) {
let (tx, rx) = mpsc::unbounded_channel();
let sink = YoAgentStateAdapter::new(self.state.clone(), self.actor.clone());
let store = self.store.clone();
let goal = self.goal.clone();
let task = task.into();
let summarize = self.summarize.clone();
let (log_tx, log_rx) = mpsc::unbounded_channel();
*self.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(OpenRoute {
runs: HashSet::new(),
tx: log_tx.clone(),
});
let logs = Logs {
state: self.state.clone(),
actor: self.actor.clone(),
rx: log_rx,
route: self.route.clone(),
own: log_tx,
};
let handle = tokio::spawn(consume(
sink, store, goal, task, summarize, rx, forward, logs,
));
(tx, handle)
}
}
struct Logs {
state: YoAgentState<GitEventStore>,
actor: ActorRef,
rx: mpsc::UnboundedReceiver<PluginLog>,
route: Route,
own: mpsc::UnboundedSender<PluginLog>,
}
impl Logs {
async fn record(
&self,
summarize: &Summarizer,
run_id: &RunId,
line: PluginLog,
) -> Result<(), StateError> {
let level = line.level.as_str().to_ascii_lowercase();
self.state
.record_observation(
self.actor.clone(),
Observation {
id: ObservationId::generate(),
title: format!("plugin {level}"),
summary: summarize(&line.message),
observed_in: Some(run_id.clone()),
metadata: serde_json::json!({ "source": "plugin", "level": level }),
},
)
.await
.map(|_| ())
}
}
struct RunTracking {
run_id: RunId,
task: String,
started: bool,
finished: bool,
turn: usize,
outcome: String,
}
#[allow(clippy::too_many_arguments)] async fn consume(
sink: YoAgentStateAdapter<GitEventStore>,
store: GitEventStore,
goal: GoalId,
task: String,
summarize: Summarizer,
mut rx: mpsc::UnboundedReceiver<AgentEvent>,
forward: Option<mpsc::UnboundedSender<AgentEvent>>,
mut logs: Logs,
) -> Result<Option<RunId>, StateError> {
let mut tracking = RunTracking {
run_id: RunId::generate(),
task,
started: false,
finished: false,
turn: 0,
outcome: "interrupted".to_string(),
};
let mut recording_error: Option<StateError> = None;
let mut held: Option<AgentEvent> = None;
let mut early: Vec<PluginLog> = Vec::new();
loop {
let event = tokio::select! {
event = rx.recv() => match event {
Some(event) => event,
None => break,
},
Some(line) = logs.rx.recv() => {
if recording_error.is_none() {
if tracking.started {
if let Err(e) = logs.record(&summarize, &tracking.run_id, line).await {
recording_error = Some(e);
}
} else {
early.push(line);
}
}
continue;
}
};
if let Some(fwd) = &forward {
let _ = fwd.send(event.clone());
}
if recording_error.is_some() {
continue; }
let mut due = Vec::with_capacity(2);
if let Some(previous) = held.take() {
if !matches!(event, AgentEvent::ProviderRetry { .. }) {
due.push(previous);
}
}
if is_error_message_end(&event) {
held = Some(event);
} else {
due.push(event);
}
let starts = due.iter().any(|e| matches!(e, AgentEvent::AgentStart));
if tracking.started
&& recording_error.is_none()
&& due.iter().any(|e| matches!(e, AgentEvent::AgentEnd { .. }))
{
while let Ok(line) = logs.rx.try_recv() {
if let Err(e) = logs.record(&summarize, &tracking.run_id, line).await {
recording_error = Some(e);
break;
}
}
}
for event in &due {
if let Err(e) = record_event(&sink, &summarize, &mut tracking, event).await {
tracing::error!(
run = %tracking.run_id,
error = %e,
"GASP recording failed; recording stops but event forwarding continues"
);
recording_error = Some(e);
break;
}
}
if starts && recording_error.is_none() {
for line in early.drain(..) {
if let Err(e) = logs.record(&summarize, &tracking.run_id, line).await {
recording_error = Some(e);
break;
}
}
}
}
{
let mut route = logs.route.lock().unwrap_or_else(|e| e.into_inner());
if route
.as_ref()
.is_some_and(|open| open.tx.same_channel(&logs.own))
{
*route = None;
}
}
while let Ok(line) = logs.rx.try_recv() {
if recording_error.is_some() || !tracking.started {
break;
}
if let Err(e) = logs.record(&summarize, &tracking.run_id, line).await {
recording_error = Some(e);
}
}
if let (Some(event), None) = (held.take(), &recording_error) {
if let Err(e) = record_event(&sink, &summarize, &mut tracking, &event).await {
recording_error = Some(e);
}
}
if let Some(e) = recording_error {
let _ = store.release_lease();
return Err(e);
}
if !tracking.started {
return Ok(None);
}
if !tracking.finished {
sink.on_run_finished(YoAgentRunFinished {
run_id: tracking.run_id.clone(),
outcome: tracking.outcome.clone(),
metadata: serde_json::json!({}),
})
.await?;
}
store.commit_run(&tracking.run_id, &goal, &tracking.outcome, &[])?;
let _ = store.release_lease();
Ok(Some(tracking.run_id))
}
fn is_error_message_end(event: &AgentEvent) -> bool {
matches!(
event,
AgentEvent::MessageEnd {
message: AgentMessage::Llm(Message::Assistant {
stop_reason: StopReason::Error,
..
}),
}
)
}
async fn record_event(
sink: &YoAgentStateAdapter<GitEventStore>,
summarize: &Summarizer,
tracking: &mut RunTracking,
event: &AgentEvent,
) -> Result<(), StateError> {
match event {
AgentEvent::AgentStart => {
sink.on_run_started(YoAgentRunStarted {
run_id: tracking.run_id.clone(),
task: tracking.task.clone(),
metadata: serde_json::json!({}),
})
.await?;
tracking.started = true;
}
AgentEvent::MessageEnd {
message:
AgentMessage::Llm(Message::Assistant {
content,
model,
stop_reason,
usage,
..
}),
} if tracking.started => {
tracking.turn += 1;
sink.on_model_called(YoAgentModelCalled {
run_id: tracking.run_id.clone(),
model: model.clone(),
prompt_summary: if tracking.turn == 1 {
summarize(&tracking.task)
} else {
format!("turn {}", tracking.turn)
},
})
.await?;
let text = content
.iter()
.find_map(|c| match c {
Content::Text { text } if !text.is_empty() => Some(text.as_str()),
_ => None,
})
.unwrap_or("(no text)");
sink.on_model_finished(YoAgentModelFinished {
run_id: tracking.run_id.clone(),
model: model.clone(),
output_summary: summarize(text),
metadata: serde_json::json!({
"usage": {
"input": usage.input,
"output": usage.output,
"cache_read": usage.cache_read,
"cache_write": usage.cache_write,
}
}),
})
.await?;
tracking.outcome = outcome_for(stop_reason).to_string();
}
AgentEvent::ToolExecutionStart {
tool_name, args, ..
} if tracking.started => {
sink.on_tool_called(YoAgentToolCalled {
run_id: tracking.run_id.clone(),
tool: tool_name.clone(),
input_summary: summarize(&args.to_string()),
metadata: serde_json::json!({
"args_fingerprint": args_fingerprint(tool_name, args),
}),
})
.await?;
}
AgentEvent::ToolExecutionEnd {
tool_name,
result,
is_error,
..
} if tracking.started => {
let text = result
.content
.iter()
.find_map(|c| match c {
Content::Text { text } => Some(text.as_str()),
_ => None,
})
.unwrap_or("(no output)");
sink.on_tool_finished(YoAgentToolFinished {
run_id: tracking.run_id.clone(),
tool: tool_name.clone(),
output_summary: summarize(text),
success: !is_error,
})
.await?;
}
AgentEvent::LoopDetected {
aborted: true,
tool_name,
..
} if tracking.started => {
tracking.outcome = format!("loop_aborted:{tool_name}");
}
AgentEvent::InputRejected { .. } if tracking.started => {
tracking.outcome = "rejected".to_string();
}
AgentEvent::AgentEnd { stats, .. } if tracking.started && !tracking.finished => {
sink.on_run_finished(YoAgentRunFinished {
run_id: tracking.run_id.clone(),
outcome: tracking.outcome.clone(),
metadata: {
let mut m = serde_json::to_value(stats).unwrap_or_else(
|_| serde_json::json!({ "error": "session stats not serializable" }),
);
if let Some(obj) = m.as_object_mut() {
obj.insert(
"cacheHitRate".into(),
serde_json::json!(stats.cache_hit_rate()),
);
}
m
},
})
.await?;
tracking.finished = true;
}
_ => {}
}
Ok(())
}
fn commit_scaffolding(store: &GitEventStore) -> Result<(), StateError> {
let events = store.events_path();
let root = events
.parent()
.and_then(|p| p.parent())
.ok_or_else(|| StateError::Store("events path has no repo root".into()))?
.to_path_buf();
let run = |args: &[&str]| -> Result<std::process::Output, StateError> {
std::process::Command::new("git")
.args(args)
.current_dir(&root)
.output()
.map_err(|e| StateError::Store(format!("git {}: {e}", args.join(" "))))
};
run(&[
"add",
"--",
"AGENT.md",
"identity",
".gitignore",
"state/events.jsonl",
])?;
let staged = run(&["diff", "--cached", "--quiet"])?;
if !staged.status.success() {
let out = run(&["commit", "-q", "-m", "gasp: agent scaffolding"])?;
if !out.status.success() {
return Err(StateError::Store(format!(
"scaffolding commit failed: {}",
String::from_utf8_lossy(&out.stderr)
)));
}
}
Ok(())
}
fn args_fingerprint(tool_name: &str, args: &serde_json::Value) -> String {
let identity = match tool_name {
"read_file" | "edit_file" | "write_file" => args
.get("path")
.and_then(|v| v.as_str())
.map(str::to_string),
"list_files" => args
.get("directory")
.or_else(|| args.get("path"))
.and_then(|v| v.as_str())
.map(str::to_string),
"bash" => args
.get("command")
.and_then(|v| v.as_str())
.map(str::to_string),
_ => None,
}
.unwrap_or_else(|| args.to_string());
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in identity.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("{tool_name}:{hash:016x}")
}
fn summarize(text: &str) -> String {
let one_line = text.split_whitespace().collect::<Vec<_>>().join(" ");
if one_line.chars().count() <= 200 {
one_line
} else {
let truncated: String = one_line.chars().take(200).collect();
format!("{truncated}…")
}
}
fn outcome_for(stop_reason: &StopReason) -> &'static str {
match stop_reason {
StopReason::Stop | StopReason::ToolUse => "completed",
StopReason::Length => "truncated",
StopReason::Error => "error",
StopReason::Aborted => "aborted",
StopReason::Refusal => "refused",
}
}
#[cfg(test)]
mod tests {
use super::{args_fingerprint, summarize};
#[test]
fn fingerprint_is_stable_and_tool_scoped() {
let args = serde_json::json!({"path": "src/main.rs"});
let a = args_fingerprint("read_file", &args);
let b = args_fingerprint("read_file", &args);
assert_eq!(a, b, "same call must fingerprint identically");
assert!(a.starts_with("read_file:"));
assert_ne!(a, args_fingerprint("edit_file", &args));
}
#[test]
fn fingerprint_ignores_read_paging() {
let full = serde_json::json!({"path": "src/big.rs"});
let page = serde_json::json!({"path": "src/big.rs", "offset": 501, "limit": 500});
assert_eq!(
args_fingerprint("read_file", &full),
args_fingerprint("read_file", &page)
);
let other = serde_json::json!({"path": "src/other.rs"});
assert_ne!(
args_fingerprint("read_file", &full),
args_fingerprint("read_file", &other)
);
}
#[test]
fn fingerprint_bash_keys_on_command_and_default_on_full_args() {
let a = serde_json::json!({"command": "cargo test"});
let b = serde_json::json!({"command": "cargo test", "timeout": 60});
assert_eq!(args_fingerprint("bash", &a), args_fingerprint("bash", &b));
let x = serde_json::json!({"q": "foo"});
let y = serde_json::json!({"q": "bar"});
assert_ne!(
args_fingerprint("web_search", &x),
args_fingerprint("web_search", &y)
);
let weird = serde_json::json!("just a string");
assert!(args_fingerprint("read_file", &weird).starts_with("read_file:"));
}
#[test]
fn summarize_collapses_and_truncates_on_char_boundaries() {
assert_eq!(summarize("a\nb\t c"), "a b c");
let long: String = "ö".repeat(300);
let s = summarize(&long);
assert_eq!(s.chars().count(), 201);
assert!(s.ends_with('…'));
let exact: String = "x".repeat(200);
assert_eq!(summarize(&exact), exact);
}
}