Files
Skald-Circle/crates/skald-core/src/loop_adapters/translate.rs
T
Daniele 505f2e95c1
Nightly Build / build (push) Successful in 7m51s
feat(chat): view context — tell the assistant what you're looking at
An eye next to the paperclip shares what the user has open with their next
message: the page, the folder being browsed, the file open in the viewer and
any highlighted passage (line numbers where a source view exists), plus which
entity a detail page is about. The bag is client-authored {label, value} pairs
in English — the backend only clamps (chars, never bytes), neutralizes the
harness tag and renders one <system-extra> block per message, deduped
consecutively so it appears exactly when the view changed. On by default,
per-device toggle, hover/tap to preview, a chip on every sent message;
docs/view-context.md for users, an updated harness.md clause for the model.
2026-08-23 20:53:30 +01:00

343 lines
15 KiB
Rust

//! The `LoopEvent → ServerEvent` translator (blueprint §10): ONE subscriber of
//! the loop manager's bus, forwarding to the session's WS channel with the
//! host enrichments the frontend expects (display meta, diff previews, file
//! changes). Byte-parity with the pre-kernel event sequence is the contract.
use std::sync::Arc;
use agent_loop::events::{DeltaKind, Event, LoopEvent};
use agent_loop::ids::ConversationId;
use agent_loop::store::{CallOutcome, HistoryStore};
use core_api::message_meta::MessageMetadata;
use serde_json::Value;
use tokio::sync::mpsc;
use crate::events::{ServerEvent, TokenDeltaKind};
use crate::mcp::McpProvider;
use crate::tools::{ToolRegistry, is_file_write_tool};
/// Forwards ONE conversation's loop events to that session's WS `tx`.
///
/// The bus is per **user** (one `LoopManager` per owner), so every session of
/// that user sees every other session's events: the `conv` filter is what keeps
/// them apart, not an accident of wiring.
pub struct EventTranslator {
tx: mpsc::Sender<ServerEvent>,
conv: ConversationId,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
store: Arc<dyn HistoryStore>,
shared: Arc<std::sync::Mutex<TranslateShared>>,
}
/// Turn state the wiring reads back after join (ChatEvent publication).
#[derive(Default)]
pub struct TranslateShared {
/// The user message id that opened the turn.
pub user_message_id: Option<i64>,
/// Accumulated tool calls of the turn (done/failed only — mirrors the old
/// `all_tool_calls` accumulate rules).
pub tool_calls: Vec<core_api::bus::ToolCallEvent>,
}
impl EventTranslator {
pub fn new(
tx: mpsc::Sender<ServerEvent>,
conv: ConversationId,
tools: Arc<ToolRegistry>,
mcp: Arc<dyn McpProvider>,
store: Arc<dyn HistoryStore>,
) -> (Self, Arc<std::sync::Mutex<TranslateShared>>) {
let shared = Arc::new(std::sync::Mutex::new(TranslateShared::default()));
(Self { tx, conv, tools, mcp, store, shared: shared.clone() }, shared)
}
/// Subscribe and forward until `stop` is cancelled — then **drain what is
/// already buffered** before exiting.
///
/// The caller cancels `stop` right after the turn joins, at which point the
/// kernel's last events (`Done`, the final `ToolDone`) are in the channel
/// but may not have been forwarded yet. Exiting on the token alone would
/// drop them, and the frontend treats `Done` as the turn's truth — the
/// pending bubble would hang forever. Hence: `recv` wins the select, and the
/// stop branch drains before breaking.
pub fn spawn(
self,
mut rx: tokio::sync::broadcast::Receiver<Event<LoopEvent>>,
stop: tokio_util::sync::CancellationToken,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
let ev = tokio::select! {
biased;
ev = rx.recv() => ev,
_ = stop.cancelled() => {
// Drain the tail, then done.
while let Ok(ev) = rx.try_recv() {
self.forward(ev).await;
}
break;
}
};
match ev {
Ok(ev) => self.forward(ev).await,
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "event translator lagged; some events were dropped");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
})
}
async fn emit(&self, ev: ServerEvent) {
self.tx.send(ev).await.ok();
}
pub async fn forward(&self, ev: Event<LoopEvent>) {
// Another session of the same user: not ours to report.
if ev.conversation != self.conv {
return;
}
let is_root = ev.parent_frame.is_none();
match ev.inner {
LoopEvent::TurnStarted | LoopEvent::RoundStarted { .. } | LoopEvent::AsyncResultReady { .. } => {}
LoopEvent::UserMessage { message_id, content, synthetic, metadata } => {
// The turn-opening user message (root, non-synthetic) is
// recorded for the wiring's ChatEvent publication.
if is_root && !synthetic {
let mut g = self.shared.lock().unwrap();
if g.user_message_id.is_none() {
g.user_message_id = Some(message_id.get());
}
}
if synthetic {
return;
}
let meta: Option<MessageMetadata> = metadata
.as_ref()
.and_then(|v| serde_json::from_value(v.clone()).ok());
let attachments = meta.as_ref().map(|m| m.attachments.clone()).unwrap_or_default();
let view_context = meta.as_ref().map(|m| m.view_context.clone()).unwrap_or_default();
// A custom slash command persists its expanded template (for
// LLM replay) but the bubble shows the typed command.
let echo = meta
.and_then(|m| m.command.map(|c| c.display))
.unwrap_or(content);
self.emit(ServerEvent::UserMessage {
message_id: message_id.get(),
content: echo,
attachments,
view_context,
}).await;
}
LoopEvent::TokenDelta { kind, text } => {
let kind = match kind {
DeltaKind::Content => TokenDeltaKind::Content,
DeltaKind::Reasoning => TokenDeltaKind::Reasoning,
};
self.emit(ServerEvent::TokenDelta { kind, delta: text }).await;
}
LoopEvent::Thinking { message_id, content, usage, reasoning } => {
self.emit(ServerEvent::Thinking {
message_id: message_id.get(),
content,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
reasoning_content: reasoning,
}).await;
}
LoopEvent::Done { message_id, content, usage, reasoning } => {
if !is_root {
return; // a child's completion rides AgentFinished
}
self.emit(ServerEvent::Done {
message_id: message_id.get(),
stack_id: ev.frame.get(),
content,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
reasoning_content: reasoning,
}).await;
}
LoopEvent::Truncated { output_tokens } => {
if is_root {
self.emit(ServerEvent::Truncated { output_tokens }).await;
}
}
LoopEvent::ToolCallStarted { id, message_id, name, args } => {
let (display_name, icon) = self.ui_meta(&name, &args);
let label_short = self.tools.describe_call(&name, &args, core_api::tool::ToolDescriptionLength::Short);
let label_full = self.tools.describe_call(&name, &args, core_api::tool::ToolDescriptionLength::Full);
let path = self.tools.target_path(&name, &args);
self.emit(ServerEvent::ToolStart {
tool_call_id: id.get(),
message_id: message_id.get(),
name,
arguments: args,
display_name,
icon,
label_short,
label_full,
path,
}).await;
}
LoopEvent::ToolCallFinished { id, outcome } => match outcome {
CallOutcome::Completed(out) => {
let stored = self.store.get_call(id).await.ok().flatten();
if let Some(c) = stored.as_ref() {
self.shared.lock().unwrap().tool_calls.push(core_api::bus::ToolCallEvent {
name: c.name.clone(),
arguments: Some(serde_json::to_string(&c.arguments).unwrap_or_default()),
result: Some(out.to_wire()),
status: "done".to_string(),
});
}
let (preview_old, preview_new) = stored
.as_ref()
.map(|c| (
c.extras["preview_old"].as_str().map(str::to_string),
c.extras["preview_new"].as_str().map(str::to_string),
))
.unwrap_or((None, None));
self.emit(ServerEvent::ToolDone {
tool_call_id: id.get(),
result: out.to_wire(),
result_type: out.kind().to_string(),
preview_old,
preview_new,
}).await;
// A successful file-write asks clients holding the file to reload.
if let Some(c) = stored
&& is_file_write_tool(&c.name)
&& let Some(p) = c.arguments["path"].as_str()
{
self.emit(ServerEvent::FileChanged { path: crate::approval::normalize_path(p) }).await;
}
}
CallOutcome::Failed(error) => {
let stored = self.store.get_call(id).await.ok().flatten();
if let Some(c) = stored.as_ref() {
self.shared.lock().unwrap().tool_calls.push(core_api::bus::ToolCallEvent {
name: c.name.clone(),
arguments: Some(serde_json::to_string(&c.arguments).unwrap_or_default()),
result: Some(error.clone()),
status: "failed".to_string(),
});
}
self.emit(ServerEvent::ToolError { tool_call_id: id.get(), error }).await;
}
CallOutcome::Cancelled => {
self.emit(ServerEvent::ToolCancelled { tool_call_id: id.get() }).await;
}
CallOutcome::Rejected { reason } => {
self.emit(ServerEvent::ToolRejected { tool_call_id: id.get(), reason }).await;
}
},
LoopEvent::ApprovalRequired { id, name, args, request_id } => {
self.emit(ServerEvent::ApprovalRequired {
request_id,
tool_call_id: id.get(),
tool_name: name,
arguments: args,
}).await;
}
LoopEvent::AgentSpawned { frame, agent, depth, prompt_preview, parent_call, parent_agent } => {
self.emit(ServerEvent::AgentStart {
stack_id: frame.get(),
parent_tool_call_id: parent_call.get(),
agent_id: agent,
parent_agent_id: parent_agent,
depth: depth as i64,
prompt_preview,
}).await;
}
LoopEvent::AgentFinished { frame, agent, result_preview, parent_agent } => {
self.emit(ServerEvent::AgentDone {
stack_id: frame.get(),
agent_id: agent,
parent_agent_id: parent_agent,
result_preview,
}).await;
}
LoopEvent::ModelFallback { from, to, reason } => {
self.emit(ServerEvent::ModelFallback { from, to, reason: first_line(&reason) }).await;
}
LoopEvent::LlmFailed { tried, last_error } => {
self.emit(ServerEvent::LlmFailed { tried, last_error }).await;
}
LoopEvent::Compacted { .. } => {}
LoopEvent::Error(message) => {
self.emit(ServerEvent::Error { message }).await;
}
LoopEvent::Cancelled => {
if is_root {
self.emit(ServerEvent::Error { message: "Cancelled by user.".to_string() }).await;
}
}
LoopEvent::Host(v) => self.forward_host(v).await,
}
}
/// Host-escaped events (blueprint §4.9): `pending_write` from the
/// approval gate, `agent_question` from the human channel.
async fn forward_host(&self, v: Value) {
match v["type"].as_str() {
Some("pending_write") => {
self.emit(ServerEvent::PendingWrite {
request_id: v["request_id"].as_i64().unwrap_or_default(),
tool_call_id: v["tool_call_id"].as_i64().unwrap_or_default(),
path: v["path"].as_str().unwrap_or_default().to_string(),
old_content: v["old_content"].as_str().map(str::to_string),
new_content: v["new_content"].as_str().unwrap_or_default().to_string(),
}).await;
}
Some("agent_question") => {
self.emit(ServerEvent::AgentQuestion {
request_id: v["request_id"].as_i64().unwrap_or_default(),
tool_call_id: v["tool_call_id"].as_i64().unwrap_or_default(),
title: v["title"].as_str().unwrap_or_default().to_string(),
question: v["question"].as_str().unwrap_or_default().to_string(),
suggested_answers: v["suggested_answers"]
.as_array()
.map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect())
.unwrap_or_default(),
}).await;
}
_ => {}
}
}
/// `(display_name, icon)` for a tool card, with the MCP friendly-name
/// override (mirrors `tool_ui_meta`).
fn ui_meta(&self, name: &str, args: &Value) -> (String, String) {
let mut meta = self.tools.display_meta(name, args);
if let Some((server, tool)) = crate::mcp::parse_mcp_tool_name(name)
&& let Some(friendly) = self.mcp.tool_display_name(server, tool)
{
meta.display_name = friendly;
}
(meta.display_name, meta.icon)
}
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or(s).to_string()
}