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