leviath_cli/commands/agent_client/mod.rs
1//! `lev agent-client` - serve a Leviath agent over the Agent **Client** Protocol.
2//!
3//! ## Which protocol this is
4//!
5//! This speaks the [Agent Client Protocol][acp]: JSON-RPC 2.0, newline-delimited,
6//! over **stdio**. It is the protocol Zed and Gas City use to drive a headless
7//! agent as a child process - `initialize` / `session/new` / `session/prompt` /
8//! `session/cancel`, with `session/update` notifications streaming output back.
9//!
10//! It is **not** the Agent *Communication* Protocol (a REST + SSE API from the
11//! BeeAI project). The two share the acronym "ACP" and nothing else. This command
12//! is the one that actually integrates Leviath with Gas City.
13//!
14//! ## How it works
15//!
16//! `lev agent-client` is a thin front end over the shared-world daemon, exactly
17//! like `lev run` / `lev serve` / `lev dash`: it owns no agent world of its own.
18//! A `session/prompt` spawns (or, on later prompts, messages) an agent in the
19//! daemon over the control socket, then translates the daemon's live
20//! [`WorldEvent`] stream and the run's per-stage output into `session/update`
21//! notifications until the run finishes or parks.
22//!
23//! [`WorldEvent`]: leviath_runtime::host::WorldEvent
24//!
25//! The protocol logic is [`serve_over`], which takes its reader/writer generically
26//! and erases them to trait objects internally, so the whole
27//! handshake→prompt→stream sequence is driven in tests over an in-memory duplex
28//! against a fake daemon - no process, no terminal, no real stdio.
29//!
30//! [acp]: https://agentclientprotocol.com
31
32mod session;
33mod translate;
34
35use std::path::PathBuf;
36
37use clap::Args;
38use leviath_agent_client::{
39 AgentCapabilities, AgentInfo, ContentBlock, InitializeParams, InitializeResult, JsonRpcMessage,
40 PROTOCOL_VERSION, PromptCapabilities, RequestPermissionResult, SessionCancelParams,
41 SessionNewParams, SessionNewResult, SessionPromptParams, SessionPromptResult, SessionUpdate,
42 SessionUpdateParams, StopReason, error_codes, flatten_prompt, is_permission_request,
43 parse_region_markers, permission_request,
44};
45use leviath_core::interaction::{ApprovalScope, InteractionRequest, InteractionResponse};
46use leviath_core::run_meta::RunStatus;
47use leviath_runtime::control_socket::{ControlClient, ControlRequest, ControlResponse};
48use leviath_runtime::host::WorldEvent;
49use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
50
51use self::mapping::{PermissionChoice, interpret_permission};
52use self::session::{ResolvedBlueprint, resolve_blueprint, spawn_args};
53use self::translate::{StageTail, split_chunks};
54
55/// How often, absent a daemon event, the loop flushes newly-written run output.
56const OUTPUT_POLL: std::time::Duration = std::time::Duration::from_millis(250);
57
58/// Arguments for `lev agent-client`.
59#[derive(Args, Debug, Clone, Default)]
60pub struct AgentClientArgs {
61 /// Blueprint to serve: an installed agent name, or a path to one. When
62 /// omitted, each session's working directory is searched for an
63 /// `agent.leviath`.
64 #[arg(long)]
65 pub agent: Option<String>,
66
67 /// Approve every tool call without prompting (recommended when the host does
68 /// not implement `session/request_permission`, e.g. Gas City).
69 #[arg(long)]
70 pub yolo: bool,
71
72 /// Allow a tool outright (repeatable).
73 #[arg(long)]
74 pub allow: Vec<String>,
75
76 /// Override the blueprint's max sub-agent tree depth.
77 #[arg(long)]
78 pub max_depth: Option<usize>,
79
80 /// Refuse the blueprint's `seed = { command = "..." }` regions. Those run a
81 /// shell command at spawn - before the first inference, and so before any
82 /// approval prompt.
83 #[arg(long)]
84 pub no_seed_commands: bool,
85
86 /// Ask the agent for its final output in this shape, overriding what the
87 /// blueprint declares. Any label works: it reaches the model, which produces
88 /// the bytes. The answer arrives as the turn's closing message.
89 #[arg(long, value_name = "LABEL")]
90 pub output_format: Option<String>,
91
92 /// Extra guidance about that shape, passed alongside `--output-format`.
93 #[arg(long, value_name = "TEXT")]
94 pub output_instructions: Option<String>,
95}
96
97/// The protocol server. Generic over transport at the boundary, then erased to
98/// trait objects internally (see the body).
99///
100/// Reads newline-delimited JSON-RPC messages from `reader`, drives the session
101/// state machine, and writes responses/notifications to `writer`. `control`
102/// reaches the shared-world daemon; `runs_dir` is the run-state root the output
103/// tailer reads from (injected so tests use a temp dir). Returns `Ok(())` when
104/// the client closes the input stream (EOF).
105pub async fn serve_over<R, W>(
106 reader: R,
107 writer: W,
108 control: ControlClient,
109 args: AgentClientArgs,
110 runs_dir: PathBuf,
111 default_cwd: String,
112) -> anyhow::Result<()>
113where
114 R: AsyncBufRead + Send + 'static,
115 W: AsyncWrite + Send + 'static,
116{
117 // Erase the reader/writer to trait objects so the (large, multi-branch)
118 // `Server` state machine has exactly ONE monomorphization regardless of the
119 // concrete transport. This is the same technique `leviath-cli`'s `serve`
120 // module uses on its shutdown future: without it, every distinct test
121 // reader/writer type spawns an unused `Server` instantiation whose
122 // never-called methods read as uncovered regions under the coverage gate.
123 let mut reader: BoxReader = Box::pin(reader);
124 let mut server = Server {
125 control,
126 args,
127 runs_dir,
128 writer: Box::pin(writer),
129 caps_present: false,
130 session: None,
131 next_request_id: 0,
132 io_alive: true,
133 default_cwd,
134 };
135 server.run(&mut reader).await;
136 Ok(())
137}
138
139/// The reader half, erased to a single trait-object type.
140type BoxReader = std::pin::Pin<Box<dyn AsyncBufRead + Send>>;
141/// The writer half, erased to a single trait-object type.
142type BoxWriter = std::pin::Pin<Box<dyn AsyncWrite + Send>>;
143
144/// The active session's mutable state. Gas City and Zed drive one session per
145/// process, so a single slot suffices; a fresh `session/new` replaces it.
146struct ActiveSession {
147 /// The protocol session id we minted.
148 session_id: String,
149 /// The blueprint this session runs.
150 blueprint: ResolvedBlueprint,
151 /// The session's working directory.
152 cwd: String,
153 /// The daemon run id, once the first prompt has spawned it.
154 run_id: Option<String>,
155}
156
157/// The protocol server: transport, daemon handle, and session state.
158struct Server {
159 control: ControlClient,
160 args: AgentClientArgs,
161 runs_dir: PathBuf,
162 writer: BoxWriter,
163 /// Whether the client advertised any capabilities at `initialize`. Hosts that
164 /// implement the client-side methods (and so can answer an agent-initiated
165 /// `session/request_permission`) send them; Gas City sends none, so tool
166 /// approvals are surfaced as output and parked instead of deadlocking on a
167 /// request the host will never answer.
168 caps_present: bool,
169 session: Option<ActiveSession>,
170 /// Monotonic id source for agent→client requests.
171 next_request_id: i64,
172 /// Working directory to use when a `session/new` omits (or empties) `cwd` -
173 /// the directory `lev agent-client` was launched from. Without this the
174 /// agent's workdir was an empty string, so it ran in the daemon's directory
175 /// rather than the caller's.
176 default_cwd: String,
177 /// Whether the output stream is still writable. Output is best-effort: a
178 /// failed write means the client is gone, so this flips to `false` and the
179 /// server winds down rather than propagating an error per write site (which
180 /// mirrors the WebSocket server's `send(...).is_err()` handling).
181 io_alive: bool,
182}
183
184/// The outcome of getting a run going for a prompt turn.
185enum RunStart {
186 /// The run is live under this id; stream it.
187 Ready(String),
188 /// A follow-up message could not be delivered (the agent already finished).
189 MessageUndeliverable,
190 /// The daemon refused to spawn the run, or was unreachable.
191 SpawnFailed,
192}
193
194/// What became of an interaction raised mid-turn.
195enum InteractionOutcome {
196 /// The interaction was resolved in-turn (via `session/request_permission`),
197 /// or the client cannot answer it and Leviath will handle it out of band -
198 /// either way the turn keeps streaming until the run reaches a done state.
199 Continue,
200 /// The interaction was surfaced as output and the turn ends now with this
201 /// reason, leaving the run parked for the client's next prompt. Used only for
202 /// clients that drive their own conversation (they advertised capabilities);
203 /// the client re-prompts with the answer.
204 Park(StopReason),
205}
206
207impl Server {
208 /// The top-level read/dispatch loop. Returns when the client closes stdin or
209 /// the output stream breaks.
210 async fn run(&mut self, reader: &mut BoxReader) {
211 while self.io_alive {
212 let Some(line) = read_line(reader).await else {
213 break; // EOF or a read error - the client is gone
214 };
215 let trimmed = line.trim();
216 if trimmed.is_empty() {
217 continue; // hosts may emit blank keep-alive lines
218 }
219 match serde_json::from_str::<JsonRpcMessage>(trimmed) {
220 Ok(msg) => self.dispatch(reader, msg).await,
221 Err(_) => {
222 self.write(&JsonRpcMessage::error_response(
223 serde_json::Value::Null,
224 error_codes::PARSE_ERROR,
225 "invalid JSON",
226 ))
227 .await;
228 }
229 }
230 }
231 }
232
233 /// Route one parsed message.
234 async fn dispatch(&mut self, reader: &mut BoxReader, msg: JsonRpcMessage) {
235 match (msg.method.as_deref(), msg.id.clone()) {
236 // ── Requests (have an id) ──
237 (Some("initialize"), Some(id)) => self.on_initialize(id, msg.params).await,
238 (Some("session/new"), Some(id)) => self.on_session_new(id, msg.params).await,
239 (Some("session/prompt"), Some(id)) => {
240 self.on_session_prompt(reader, id, msg.params).await
241 }
242 (Some(_other), Some(id)) => {
243 self.write(&JsonRpcMessage::error_response(
244 id,
245 error_codes::METHOD_NOT_FOUND,
246 "method not supported",
247 ))
248 .await
249 }
250 // ── Notifications (no id) ──
251 (Some("session/cancel"), None) => self.on_cancel_notification(msg.params).await,
252 // Any other notification (`initialized`, unknown) is ignored, as is a
253 // stray response with neither method nor id.
254 _ => {}
255 }
256 }
257
258 /// `initialize`: advertise this agent's identity and capabilities, and note
259 /// whether the client advertised its own.
260 async fn on_initialize(&mut self, id: serde_json::Value, params: Option<serde_json::Value>) {
261 let params: InitializeParams = params
262 .and_then(|p| serde_json::from_value(p).ok())
263 .unwrap_or_default();
264 self.caps_present = params.client_capabilities.is_some();
265 let result = InitializeResult {
266 protocol_version: PROTOCOL_VERSION,
267 agent_capabilities: AgentCapabilities {
268 load_session: false,
269 prompt_capabilities: PromptCapabilities {
270 image: false,
271 audio: false,
272 embedded_context: true,
273 },
274 },
275 agent_info: AgentInfo {
276 name: "leviath".to_string(),
277 version: env!("CARGO_PKG_VERSION").to_string(),
278 },
279 auth_methods: vec![],
280 };
281 self.write(&JsonRpcMessage::response(id, &result)).await;
282 }
283
284 /// `session/new`: resolve the blueprint and open a session. No run is spawned
285 /// yet - that waits for the first prompt.
286 async fn on_session_new(&mut self, id: serde_json::Value, params: Option<serde_json::Value>) {
287 let params: SessionNewParams = params
288 .and_then(|p| serde_json::from_value(p).ok())
289 .unwrap_or_default();
290 if !params.mcp_servers.is_empty() {
291 // Leviath blueprints declare their own MCP servers; client-supplied
292 // ones are captured for visibility but not injected (see module docs).
293 let ignored = params.mcp_servers.len();
294 tracing::info!(
295 mcp_server_count = ignored,
296 "session/new supplied MCP servers; ignoring in favour of the blueprint's own"
297 );
298 }
299 // An absent/empty `cwd` falls back to the directory `lev agent-client`
300 // was launched from, so the agent's tools operate there rather than in
301 // the daemon's working directory.
302 let cwd = if params.cwd.trim().is_empty() {
303 self.default_cwd.clone()
304 } else {
305 params.cwd
306 };
307 match resolve_blueprint(self.args.agent.as_deref(), &cwd) {
308 Ok(blueprint) => {
309 let session_id = new_session_id(&blueprint.agent_name);
310 self.session = Some(ActiveSession {
311 session_id: session_id.clone(),
312 blueprint,
313 cwd,
314 run_id: None,
315 });
316 self.write(&JsonRpcMessage::response(
317 id,
318 &SessionNewResult { session_id },
319 ))
320 .await;
321 }
322 Err(e) => {
323 self.write(&JsonRpcMessage::error_response(
324 id,
325 error_codes::INVALID_PARAMS,
326 format!("no blueprint for this session: {e}"),
327 ))
328 .await;
329 }
330 }
331 }
332
333 /// `session/prompt`: run one prompt turn end to end and report its stop reason.
334 async fn on_session_prompt(
335 &mut self,
336 reader: &mut BoxReader,
337 id: serde_json::Value,
338 params: Option<serde_json::Value>,
339 ) {
340 if self.session.is_none() {
341 self.write(&JsonRpcMessage::error_response(
342 id,
343 error_codes::INVALID_REQUEST,
344 "no active session; call session/new first",
345 ))
346 .await;
347 return;
348 }
349 let params: SessionPromptParams = params
350 .and_then(|p| serde_json::from_value(p).ok())
351 .unwrap_or_default();
352 let text = flatten_prompt(¶ms.prompt);
353 if text.is_empty() {
354 self.write(&JsonRpcMessage::error_response(
355 id,
356 error_codes::INVALID_PARAMS,
357 "prompt has no usable text content",
358 ))
359 .await;
360 return;
361 }
362 // Parse `---region:<name>---` markers; with none, the whole text is the
363 // `task` region (back-compat).
364 let regions = parse_region_markers(&text);
365 let task = regions.get("task").cloned().unwrap_or_default();
366 let stop_reason = self.run_turn(reader, task, regions).await;
367 self.write(&JsonRpcMessage::response(
368 id,
369 &SessionPromptResult { stop_reason },
370 ))
371 .await;
372 }
373
374 /// `session/cancel` notification arriving between turns: cancel the session's
375 /// run if one exists.
376 async fn on_cancel_notification(&mut self, params: Option<serde_json::Value>) {
377 let _: SessionCancelParams = params
378 .and_then(|p| serde_json::from_value(p).ok())
379 .unwrap_or_default();
380 if let Some(run_id) = self.session.as_ref().and_then(|s| s.run_id.clone()) {
381 let _ = self
382 .control
383 .request(&ControlRequest::Cancel { run_id })
384 .await;
385 }
386 }
387
388 /// Drive one prompt turn: spawn (or message) the agent, then translate the
389 /// daemon's events and the run's output until it goes terminal or parks.
390 async fn run_turn(
391 &mut self,
392 reader: &mut BoxReader,
393 task: String,
394 regions: std::collections::HashMap<String, String>,
395 ) -> StopReason {
396 // Subscribe before spawning so no event between spawn and subscribe is
397 // missed. An unreachable daemon ends the turn as a refusal.
398 let Ok(mut stream) = self.control.subscribe().await else {
399 return StopReason::Refusal;
400 };
401
402 let session_id = self
403 .session
404 .as_ref()
405 .expect("session present")
406 .session_id
407 .clone();
408 let run_id = match self.start_run(task, regions).await {
409 RunStart::Ready(run_id) => run_id,
410 // The agent already finished and won't take another message - the
411 // turn is simply over, not a failure.
412 RunStart::MessageUndeliverable => return StopReason::EndTurn,
413 // The daemon refused to create the run, or was unreachable.
414 RunStart::SpawnFailed => return StopReason::Refusal,
415 };
416
417 let mut tail = StageTail::new();
418 while self.io_alive {
419 tokio::select! {
420 biased;
421 event = stream.next() => {
422 let Some(event) = event else {
423 // The daemon closed the stream (restart); end the turn
424 // with whatever output we have.
425 self.flush_output(&session_id, &mut tail, &run_id).await;
426 return StopReason::EndTurn;
427 };
428 if event.run_id() != run_id {
429 continue; // another run in the shared world
430 }
431 self.flush_output(&session_id, &mut tail, &run_id).await;
432 match event {
433 WorldEvent::Completed { status, final_output, .. } => {
434 self.emit_final_output(&session_id, final_output.as_ref()).await;
435 return leviath_agent_client::stop_reason_for_label(&status);
436 }
437 WorldEvent::Context { total_tokens, max_tokens, .. } => {
438 self.emit_usage(&session_id, total_tokens, max_tokens).await;
439 }
440 WorldEvent::Interaction { request, .. } => {
441 match self.handle_interaction(reader, &session_id, &run_id, request).await {
442 InteractionOutcome::Continue => {}
443 InteractionOutcome::Park(reason) => return reason,
444 }
445 }
446 // Status / Tokens / Spawned: the output flush above is all
447 // that's needed.
448 _ => {}
449 }
450 // A run can reach a done state without a `Completed` event -
451 // `CompleteInteractive` stays live for follow-up, so it emits
452 // no terminal event. Consult the persisted run status after
453 // every event so the turn ends when (and only when) the run is
454 // genuinely finished, never while it is merely `WaitingInput`
455 // on an interaction Leviath is handling out of band.
456 if let Some(reason) = self.run_finished(&run_id) {
457 return reason;
458 }
459 }
460 _ = tokio::time::sleep(OUTPUT_POLL) => {
461 self.flush_output(&session_id, &mut tail, &run_id).await;
462 if let Some(reason) = self.run_finished(&run_id) {
463 return reason;
464 }
465 }
466 incoming = read_line(reader) => {
467 match incoming {
468 // Stdin closed mid-turn: the client is gone.
469 None => {
470 self.flush_output(&session_id, &mut tail, &run_id).await;
471 return StopReason::EndTurn;
472 }
473 Some(line) => self.handle_midturn_input(&run_id, &line).await,
474 }
475 }
476 }
477 }
478 // The output stream broke mid-turn; the client is gone.
479 StopReason::EndTurn
480 }
481
482 /// Whether the run has reached a state that should end the current turn,
483 /// read from its persisted `meta.json` status. Returns the stop reason to
484 /// report, or `None` while the run is still starting / running / blocked on
485 /// input (`WaitingInput`) - the latter must keep the turn in flight so a
486 /// non-interactive client is never told "done" while the agent is actually
487 /// waiting on an interaction Leviath is handling out of band.
488 fn run_finished(&self, run_id: &str) -> Option<StopReason> {
489 let status = read_run_status(&self.runs_dir, run_id)?;
490 leviath_agent_client::stop_reason_for(&status)
491 }
492
493 /// Spawn the agent on the first prompt, or deliver a message on later ones.
494 /// `regions` seeds named caller-input regions on the first (spawning) prompt;
495 /// on later prompts the text is delivered as a message and `regions` is unused.
496 async fn start_run(
497 &mut self,
498 task: String,
499 regions: std::collections::HashMap<String, String>,
500 ) -> RunStart {
501 let existing = self
502 .session
503 .as_ref()
504 .expect("session present")
505 .run_id
506 .clone();
507 match existing {
508 Some(run_id) => {
509 let delivered = matches!(
510 self.control
511 .request(&ControlRequest::Message {
512 agent_id: run_id.clone(),
513 content: task,
514 target_region: None,
515 })
516 .await,
517 Ok(ControlResponse::Ok { ok: true })
518 );
519 if delivered {
520 RunStart::Ready(run_id)
521 } else {
522 RunStart::MessageUndeliverable
523 }
524 }
525 None => {
526 let session = self.session.as_ref().expect("session present");
527 let spawn =
528 spawn_args(&session.blueprint, &task, &session.cwd, &self.args, regions);
529 match self.control.spawn(spawn).await {
530 Ok(ControlResponse::Spawned { run_id }) => {
531 self.session.as_mut().expect("session present").run_id =
532 Some(run_id.clone());
533 RunStart::Ready(run_id)
534 }
535 _ => RunStart::SpawnFailed,
536 }
537 }
538 }
539 }
540
541 /// Handle an interaction raised while a turn is streaming.
542 ///
543 /// The strategy depends on whether the client advertised capabilities at
544 /// `initialize` (i.e. whether it implements the client-side protocol methods
545 /// and can answer an agent-initiated request):
546 ///
547 /// - **Capable client + tool approval** → drive it over
548 /// `session/request_permission`, answered in-turn; the run continues.
549 /// - **Capable client + any other interaction** → surface the question as
550 /// output and end the turn (`Park`). The client owns the conversation and
551 /// re-prompts with the answer, which arrives as the next `session/prompt` -
552 /// the standard Agent Client Protocol turn boundary.
553 /// - **Client without capabilities (e.g. Gas City, which reports interaction
554 /// unsupported)** → surface the question as output and **keep the turn in
555 /// flight** (`Continue`). The run is genuinely blocked, so the turn must not
556 /// report "done"; the human resolves it through Leviath's own surfaces
557 /// (`lev dash` / `lev respond`) and the run then continues to completion.
558 async fn handle_interaction(
559 &mut self,
560 reader: &mut BoxReader,
561 session_id: &str,
562 run_id: &str,
563 request: InteractionRequest,
564 ) -> InteractionOutcome {
565 if self.caps_present {
566 if is_permission_request(&request) {
567 return self
568 .request_permission(reader, session_id, run_id, request)
569 .await;
570 }
571 // A capable client drives its own conversation: surface the question
572 // and hand control back so it can re-prompt with the answer.
573 self.emit_chunk(session_id, &format!("{}\n", request.prompt))
574 .await;
575 return InteractionOutcome::Park(StopReason::EndTurn);
576 }
577 // A client that cannot answer interactions: surface the question and keep
578 // the turn alive. Leviath handles the interaction out of band; the turn
579 // ends only when the run itself reaches a done state.
580 self.emit_chunk(session_id, &format!("{}\n", request.prompt))
581 .await;
582 InteractionOutcome::Continue
583 }
584
585 /// Ask the host to approve a tool call and relay the decision to the daemon.
586 async fn request_permission(
587 &mut self,
588 reader: &mut BoxReader,
589 session_id: &str,
590 run_id: &str,
591 request: InteractionRequest,
592 ) -> InteractionOutcome {
593 let request_id = serde_json::json!(self.next_id());
594 let params = permission_request(session_id, &request);
595 self.write(&JsonRpcMessage::request(
596 request_id.clone(),
597 "session/request_permission",
598 ¶ms,
599 ))
600 .await;
601
602 // Await the matching response. Other inbound messages during the wait are
603 // ignored except a cancel, which rejects the call and cancels the run.
604 loop {
605 let Some(line) = read_line(reader).await else {
606 return InteractionOutcome::Park(StopReason::EndTurn);
607 };
608 let Ok(msg) = serde_json::from_str::<JsonRpcMessage>(line.trim()) else {
609 continue;
610 };
611 if msg.method.as_deref() == Some("session/cancel") {
612 let _ = self
613 .control
614 .request(&ControlRequest::Cancel {
615 run_id: run_id.to_string(),
616 })
617 .await;
618 self.answer_interaction(&request.id, false, ApprovalScope::Once)
619 .await;
620 return InteractionOutcome::Continue;
621 }
622 if msg.id.as_ref() == Some(&request_id) {
623 let choice = msg
624 .result
625 .and_then(|r| serde_json::from_value::<RequestPermissionResult>(r).ok())
626 .map(|r| interpret_permission(&r.outcome))
627 .unwrap_or(PermissionChoice {
628 approved: false,
629 scope: ApprovalScope::Once,
630 });
631 self.answer_interaction(&request.id, choice.approved, choice.scope)
632 .await;
633 return InteractionOutcome::Continue;
634 }
635 // Unrelated message; keep waiting.
636 }
637 }
638
639 /// Relay an approval decision to the daemon's interaction hub.
640 ///
641 /// Takes `&mut self` (though it only reads `self.control`) so the future
642 /// stays `Send`: a shared `&Server` would require `Server: Sync`, which the
643 /// erased `dyn AsyncWrite` writer is not.
644 async fn answer_interaction(&mut self, request_id: &str, approved: bool, scope: ApprovalScope) {
645 let response = InteractionResponse {
646 request_id: request_id.to_string(),
647 value: None,
648 choice_index: None,
649 approved: Some(approved),
650 scope: Some(scope),
651 };
652 let _ = self
653 .control
654 .request(&ControlRequest::AnswerInteraction { response })
655 .await;
656 }
657
658 /// A message received while a turn is in flight. Only `session/cancel` (for
659 /// this run) is actionable; everything else is ignored.
660 async fn handle_midturn_input(&mut self, run_id: &str, line: &str) {
661 let Ok(msg) = serde_json::from_str::<JsonRpcMessage>(line.trim()) else {
662 return;
663 };
664 if msg.method.as_deref() == Some("session/cancel") {
665 let _ = self
666 .control
667 .request(&ControlRequest::Cancel {
668 run_id: run_id.to_string(),
669 })
670 .await;
671 }
672 }
673
674 /// Flush any newly-written run output as `agent_message_chunk` notifications,
675 /// split into host-safe frames.
676 async fn flush_output(&mut self, session_id: &str, tail: &mut StageTail, run_id: &str) {
677 let text = tail.pump(&self.runs_dir, run_id);
678 for chunk in split_chunks(&text) {
679 self.emit_chunk(session_id, chunk).await;
680 }
681 }
682
683 /// Emit one `agent_message_chunk` update.
684 async fn emit_chunk(&mut self, session_id: &str, text: &str) {
685 let params = SessionUpdateParams {
686 session_id: session_id.to_string(),
687 update: SessionUpdate::AgentMessageChunk {
688 content: ContentBlock::text(text),
689 },
690 };
691 self.write(&JsonRpcMessage::notification("session/update", ¶ms))
692 .await;
693 }
694
695 /// Emit the run's submitted answer as the agent's closing message.
696 ///
697 /// ACP has no result field: a turn returns a stop reason, and everything the
698 /// host shows the user arrived as `agent_message_chunk` updates. Those carry
699 /// the stage's streamed output, which is what the agent *did*. A submitted
700 /// answer never appears there - `submit_output` writes a component and a
701 /// context region, not the stage log - so without this an ACP host would
702 /// watch the whole run and never receive its conclusion.
703 ///
704 /// Prefixed and separated so the answer reads as a distinct closing message
705 /// rather than more streamed output. A run that submitted nothing emits
706 /// nothing, leaving the turn exactly as it was.
707 async fn emit_final_output(
708 &mut self,
709 session_id: &str,
710 output: Option<&leviath_core::output::FinalOutput>,
711 ) {
712 let Some(output) = output else { return };
713 let shape = output
714 .format
715 .as_deref()
716 .map(|f| format!(" ({f})"))
717 .unwrap_or_default();
718 let text = format!("\n\n--- final output{shape} ---\n{}", output.content);
719 for chunk in split_chunks(&text) {
720 self.emit_chunk(session_id, chunk).await;
721 }
722 }
723
724 /// Emit one `usage_update` update.
725 async fn emit_usage(&mut self, session_id: &str, used: usize, size: usize) {
726 let params = SessionUpdateParams {
727 session_id: session_id.to_string(),
728 update: SessionUpdate::UsageUpdate { used, size },
729 };
730 self.write(&JsonRpcMessage::notification("session/update", ¶ms))
731 .await;
732 }
733
734 /// Serialize one message as a single line and flush it. Output is
735 /// best-effort: on any write error the client is assumed gone and
736 /// [`Server::io_alive`] flips to `false`, winding the server down.
737 async fn write(&mut self, msg: &JsonRpcMessage) {
738 let mut line = serde_json::to_string(msg).expect("JsonRpcMessage always serializes");
739 line.push('\n');
740 let ok = self.writer.write_all(line.as_bytes()).await.is_ok()
741 && self.writer.flush().await.is_ok();
742 if !ok {
743 self.io_alive = false;
744 }
745 }
746
747 /// Next agent→client request id.
748 fn next_id(&mut self) -> i64 {
749 self.next_request_id += 1;
750 self.next_request_id
751 }
752}
753
754/// Read the persisted `RunStatus` for `run_id` from `<runs_dir>/<run_id>/meta.json`.
755///
756/// Deserializes into a minimal projection that reads only the `status` field, so
757/// it does not depend on the full [`RunMeta`](leviath_core::run_meta::RunMeta)
758/// shape and tolerates a partially-written or older metadata file. Returns
759/// `None` if the file is missing or unreadable (the run hasn't persisted yet).
760fn read_run_status(runs_dir: &std::path::Path, run_id: &str) -> Option<RunStatus> {
761 #[derive(serde::Deserialize)]
762 struct StatusOnly {
763 status: RunStatus,
764 }
765 let path = runs_dir.join(run_id).join("meta.json");
766 let json = std::fs::read_to_string(path).ok()?;
767 serde_json::from_str::<StatusOnly>(&json)
768 .ok()
769 .map(|s| s.status)
770}
771
772/// The stop reason to report for a run in `status`, or `None` if the run has not
773/// finished and the turn should keep streaming.
774///
775/// `CompleteInteractive` counts as finished: the agent completed its required
776/// work and is only idling for optional follow-up, so control returns to the
777/// client. `WaitingInput` does **not** - the agent is blocked on an interaction,
778/// which is exactly the state that must not be reported as "done".
779/// Mint a session id from the agent name - reuses the run-id generator's
780/// collision-resistant `<name>-<timestamp>-<suffix>` scheme.
781fn new_session_id(agent_name: &str) -> String {
782 crate::runstate::new_run_id(agent_name)
783}
784
785/// Read one newline-terminated line, or `None` at end of stream.
786///
787/// A read error is treated the same as EOF (`None`): either way the client is no
788/// longer sending, and there is nothing useful to do but wind down. Collapsing
789/// the error into `None` via `unwrap_or(0)` also keeps this branch-free for the
790/// coverage gate.
791async fn read_line(reader: &mut BoxReader) -> Option<String> {
792 let mut line = String::new();
793 match reader.read_line(&mut line).await.unwrap_or(0) {
794 0 => None,
795 _ => Some(line),
796 }
797}
798
799/// Small, pure translations for the prompt loop, kept apart so they unit-test in
800/// isolation from the async machinery.
801mod mapping {
802 use leviath_agent_client::PermissionOutcome;
803 use leviath_agent_client::mapping::{
804 OPTION_ALLOW_ALWAYS, OPTION_ALLOW_ONCE, OPTION_REJECT_ONCE,
805 };
806 use leviath_core::interaction::ApprovalScope;
807
808 /// A resolved permission decision to relay to the daemon.
809 pub(super) struct PermissionChoice {
810 /// Whether the tool call is approved.
811 pub(super) approved: bool,
812 /// The scope of the decision.
813 pub(super) scope: ApprovalScope,
814 }
815
816 /// Interpret a host's permission outcome into an approve/deny + scope.
817 ///
818 /// The three option ids we offer map to allow-once, allow-for-the-run, and
819 /// reject. A `cancelled` outcome, or any option id we did not offer, is
820 /// treated as a one-time rejection - the safe default.
821 ///
822 /// There is no stage-scoped option: the protocol has three permission
823 /// kinds, and an editor host that knows nothing about this run's stages has
824 /// nothing to say about them.
825 pub(super) fn interpret_permission(outcome: &PermissionOutcome) -> PermissionChoice {
826 match outcome {
827 PermissionOutcome::Selected { option_id } if option_id == OPTION_ALLOW_ONCE => {
828 PermissionChoice {
829 approved: true,
830 scope: ApprovalScope::Once,
831 }
832 }
833 PermissionOutcome::Selected { option_id } if option_id == OPTION_ALLOW_ALWAYS => {
834 PermissionChoice {
835 approved: true,
836 scope: ApprovalScope::Run,
837 }
838 }
839 PermissionOutcome::Selected { option_id } if option_id == OPTION_REJECT_ONCE => {
840 PermissionChoice {
841 approved: false,
842 scope: ApprovalScope::Once,
843 }
844 }
845 _ => PermissionChoice {
846 approved: false,
847 scope: ApprovalScope::Once,
848 },
849 }
850 }
851
852 /// The stop reason to report for a run whose `WorldEvent::Completed` carried
853 /// `status`. The host emits only the terminal statuses `complete`, `error`,
854 /// and `cancelled`.
855 #[cfg(test)]
856 mod tests {
857 use super::*;
858
859 #[test]
860 fn interpret_maps_every_offered_option() {
861 let allow_once = interpret_permission(&PermissionOutcome::Selected {
862 option_id: OPTION_ALLOW_ONCE.to_string(),
863 });
864 assert!(allow_once.approved);
865 assert_eq!(allow_once.scope, ApprovalScope::Once);
866
867 let allow_always = interpret_permission(&PermissionOutcome::Selected {
868 option_id: OPTION_ALLOW_ALWAYS.to_string(),
869 });
870 assert!(allow_always.approved);
871 assert_eq!(allow_always.scope, ApprovalScope::Run);
872
873 let reject = interpret_permission(&PermissionOutcome::Selected {
874 option_id: OPTION_REJECT_ONCE.to_string(),
875 });
876 assert!(!reject.approved);
877 assert_eq!(reject.scope, ApprovalScope::Once);
878 }
879
880 #[test]
881 fn interpret_denies_unknown_option_and_cancellation() {
882 let unknown = interpret_permission(&PermissionOutcome::Selected {
883 option_id: "made-up".to_string(),
884 });
885 assert!(!unknown.approved);
886 assert_eq!(unknown.scope, ApprovalScope::Once);
887
888 let cancelled = interpret_permission(&PermissionOutcome::Cancelled);
889 assert!(!cancelled.approved);
890 assert_eq!(cancelled.scope, ApprovalScope::Once);
891 }
892 }
893}
894
895#[cfg(test)]
896mod tests;