feat(tui/auto-drive): route user prompts through coordinator

This commit is contained in:
James Peter
2025-10-23 13:33:54 +10:00
parent 652eb56c3a
commit 95dcbbe4de
7 changed files with 613 additions and 15 deletions
+5
View File
@@ -747,6 +747,10 @@ pub struct AutoDriveSettings {
#[serde(default = "default_true")]
pub observer_enabled: bool,
/// Enable coordinator routing of user prompts during Auto Drive turns.
#[serde(default = "default_true")]
pub coordinator_routing: bool,
#[serde(default)]
pub continue_mode: AutoDriveContinueMode,
}
@@ -759,6 +763,7 @@ impl Default for AutoDriveSettings {
qa_automation_enabled: true,
cross_check_enabled: true,
observer_enabled: true,
coordinator_routing: true,
continue_mode: AutoDriveContinueMode::TenSeconds,
}
}
+8
View File
@@ -1835,6 +1835,14 @@ impl App<'_> {
);
}
}
AppEvent::AutoCoordinatorUserReply {
user_response,
cli_command,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_user_reply(user_response, cli_command);
}
}
AppEvent::AutoCoordinatorThinking { delta, summary_index } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.auto_handle_thinking(delta, summary_index);
+4
View File
@@ -258,6 +258,10 @@ pub(crate) enum AppEvent {
agents: Vec<AutoTurnAgentsAction>,
transcript: Vec<ResponseItem>,
},
AutoCoordinatorUserReply {
user_response: Option<String>,
cli_command: Option<String>,
},
AutoCoordinatorThinking {
delta: String,
summary_index: Option<u32>,
+303 -6
View File
@@ -58,6 +58,8 @@ use code_protocol::num_format::format_with_separators;
pub(crate) mod auto_coordinator;
mod auto_drive_history;
mod auto_observer;
mod coordinator_router;
mod coordinator_user_schema;
mod qa_orchestrator;
#[cfg(feature = "dev-faults")]
mod faults;
@@ -95,6 +97,7 @@ use self::agent_install::{
wrap_command,
};
use self::auto_drive_history::AutoDriveHistory;
use self::coordinator_router::{CoordinatorContext, CoordinatorRouterResponse};
use self::auto_coordinator::{
start_auto_coordinator,
AutoCoordinatorCommand,
@@ -7452,6 +7455,45 @@ impl ChatWidget<'_> {
true
}
fn try_coordinator_route(
&mut self,
original_text: &str,
) -> Option<CoordinatorRouterResponse> {
let trimmed = original_text.trim();
if trimmed.is_empty() {
return None;
}
if !self.auto_state.active {
return None;
}
if !self.config.tui.auto_drive.coordinator_routing {
return None;
}
if trimmed.starts_with('/') {
return None;
}
let mut updates = Vec::new();
if let Some(summary) = self.auto_state.last_decision_summary.clone() {
if !summary.trim().is_empty() {
updates.push(summary);
}
}
if let Some(current) = self.auto_state.current_summary.clone() {
if !current.trim().is_empty() && updates.iter().all(|existing| existing != &current) {
updates.push(current);
}
}
let context = CoordinatorContext::new(self.auto_state.pending_agent_actions.len(), updates);
let response = coordinator_router::route_user_message(trimmed, &context);
if response.user_response.is_some() || response.cli_command.is_some() {
Some(response)
} else {
None
}
}
fn submit_user_message(&mut self, user_message: UserMessage) {
if self.layout.scroll_offset > 0 {
layout_scroll::to_bottom(self);
@@ -7503,6 +7545,53 @@ impl ChatWidget<'_> {
return;
}
let original_text = message.display_text.clone();
let mut submitted_cli = false;
if !message.suppress_persistence
&& !original_text.trim().starts_with('/')
&& self.auto_state.active
&& self.config.tui.auto_drive.coordinator_routing
{
let mut conversation = self.current_auto_history();
if let Some(user_item) = Self::auto_drive_make_user_message(original_text.clone()) {
conversation.push(user_item.clone());
if self.auto_send_user_prompt_to_coordinator(original_text.clone(), conversation) {
self.finalize_sent_user_message(message);
self.consume_pending_prompt_for_ui_only_turn();
self.auto_history.append_raw(std::slice::from_ref(&user_item));
return;
}
}
}
if !message.suppress_persistence {
if let Some(mut routed) = self.try_coordinator_route(&original_text) {
self.finalize_sent_user_message(message);
self.consume_pending_prompt_for_ui_only_turn();
let _ = self.rebuild_auto_history();
if let Some(notice_text) = routed.user_response.take() {
let mut lines = Vec::with_capacity(2);
lines.push("AUTO DRIVE RESPONSE".to_string());
lines.push(notice_text);
self.history_push_plain_paragraphs(PlainMessageKind::Notice, lines);
}
if let Some(cli_command) = routed.cli_command {
let mut synthetic: UserMessage = cli_command.into();
synthetic.suppress_persistence = true;
self.submit_user_message(synthetic);
submitted_cli = true;
}
if !submitted_cli {
self.auto_send_conversation_force();
}
return;
}
}
let only_text_items = message
.ordered_items
.iter()
@@ -13193,6 +13282,71 @@ fi\n\
}
}
fn auto_send_conversation_force(&mut self) {
if !self.auto_state.active {
return;
}
let conversation = self.current_auto_history();
self.observer_send_delta(&conversation);
let Some(handle) = self.auto_handle.as_ref() else {
return;
};
if handle
.send(AutoCoordinatorCommand::UpdateConversation(conversation))
.is_err()
{
self.auto_stop(Some("Coordinator stopped unexpectedly.".to_string()));
} else {
self.auto_state.waiting_for_response = true;
self.auto_state.coordinator_waiting = true;
self.auto_state.current_summary = None;
self.auto_state.current_progress_past = None;
self.auto_state.current_progress_current = None;
self.auto_state.current_cli_prompt = None;
self.auto_state.current_cli_context = None;
self.auto_state.last_broadcast_summary = None;
self.auto_state.current_summary_index = None;
self.auto_state.current_display_line = None;
self.auto_state.current_display_is_summary = false;
self.auto_state.current_reasoning_title = None;
self.auto_state.placeholder_phrase =
Some(auto_drive_strings::next_auto_drive_phrase().to_string());
self.auto_state.thinking_prefix_stripped = false;
self.auto_rebuild_live_ring();
self.request_redraw();
}
}
fn auto_send_user_prompt_to_coordinator(
&mut self,
prompt: String,
conversation: Vec<ResponseItem>,
) -> bool {
let Some(handle) = self.auto_handle.as_ref() else {
return false;
};
let command = AutoCoordinatorCommand::HandleUserPrompt {
_prompt: prompt,
conversation,
};
match handle.send(command) {
Ok(()) => {
self.auto_state.waiting_for_response = true;
self.auto_state.coordinator_waiting = true;
self.auto_state.awaiting_submission = false;
self.auto_state.placeholder_phrase =
Some(auto_drive_strings::next_auto_drive_phrase().to_string());
self.auto_rebuild_live_ring();
self.request_redraw();
true
}
Err(err) => {
tracing::warn!("failed to dispatch user prompt to coordinator: {err}");
false
}
}
}
fn observer_send_delta(&mut self, conversation: &[ResponseItem]) -> usize {
let current_len = conversation.len();
let last = self.observer_history.last_sent_index.min(current_len);
@@ -13477,6 +13631,41 @@ fi\n\
}
}
pub(crate) fn auto_handle_user_reply(
&mut self,
user_response: Option<String>,
cli_command: Option<String>,
) {
if let Some(text) = user_response.clone() {
if let Some(item) = Self::auto_drive_make_assistant_message(text.clone()) {
self.auto_history
.append_raw(std::slice::from_ref(&item));
}
let mut lines = Vec::with_capacity(2);
lines.push("AUTO DRIVE RESPONSE".to_string());
lines.push(text);
self.history_push_plain_paragraphs(PlainMessageKind::Notice, lines);
}
if let Some(command) = cli_command {
if command.trim_start().starts_with('/') {
self.app_event_tx
.send(AppEvent::DispatchCommand(SlashCommand::Auto, command));
} else {
let mut message: UserMessage = command.into();
message.suppress_persistence = true;
self.submit_user_message(message);
}
} else {
self.auto_state.waiting_for_response = false;
self.auto_state.coordinator_waiting = false;
self.auto_state.placeholder_phrase = None;
}
self.auto_rebuild_live_ring();
self.request_redraw();
}
fn schedule_auto_cli_prompt(&mut self, prompt_text: String) {
self.auto_state.current_cli_prompt = Some(prompt_text);
self.auto_state.awaiting_submission = true;
@@ -14309,7 +14498,7 @@ fi\n\
}
self.bottom_pane.update_status_text(String::new());
self.bottom_pane.set_task_running(false);
use crate::chatwidget::message::UserMessage;
use crate::chatwidget::message::UserMessage;
let mut message: UserMessage = full_prompt.into();
message.suppress_persistence = true;
self.submit_user_message(message);
@@ -14432,10 +14621,7 @@ fi\n\
if !self.auto_state.subagents_enabled {
return requested_write.unwrap_or(false);
}
match requested_write {
Some(flag) => flag,
None => true,
}
true
}
fn auto_stop(&mut self, message: Option<String>) {
@@ -21973,6 +22159,7 @@ mod tests {
CAPTURE_AUTO_TURN_COMMIT_STUB,
GIT_DIFF_NAME_ONLY_BETWEEN_STUB,
};
use crate::chatwidget::message::UserMessage;
use crate::chatwidget::smoke_helpers::ChatWidgetHarness;
use crate::history_cell::{self, ExploreAggregationCell, HistoryCellType};
use crate::chatwidget::auto_coordinator::{
@@ -22541,6 +22728,7 @@ mod tests {
chat.auto_state.active = true;
chat.auto_state.review_enabled = true;
chat.config.sandbox_policy = SandboxPolicy::DangerFullAccess;
chat.auto_handle_decision(
AutoCoordinatorStatus::Continue,
@@ -22589,6 +22777,112 @@ mod tests {
assert!(write_notice_present);
}
#[test]
fn coordinator_router_emits_notice_for_status_question() {
let mut harness = ChatWidgetHarness::new();
{
let chat = harness.chat();
chat.auto_state.active = true;
chat.config.tui.auto_drive.coordinator_routing = true;
chat.config.sandbox_policy = SandboxPolicy::DangerFullAccess;
}
let baseline_notice_count = {
let chat = harness.chat();
chat.history_cells
.iter()
.filter(|cell| matches!(cell.kind(), HistoryCellType::Notice))
.count()
};
{
let chat = harness.chat();
chat.auto_handle_user_reply(
Some("Two active agents reporting steady progress.".to_string()),
None,
);
}
let notice_count = {
let chat = harness.chat();
chat.history_cells
.iter()
.filter(|cell| matches!(cell.kind(), HistoryCellType::Notice))
.count()
};
assert!(notice_count > baseline_notice_count);
let header_span = {
let chat = harness.chat();
let notice_cell = chat
.history_cells
.iter()
.rev()
.find(|cell| matches!(cell.kind(), HistoryCellType::Notice))
.expect("notice cell");
let lines = notice_cell.display_lines_trimmed();
assert!(!lines.is_empty());
lines
.first()
.and_then(|line| line.spans.first())
.map(|span| span.content.to_string())
.unwrap_or_default()
};
assert_eq!(header_span, "AUTO DRIVE RESPONSE");
}
#[test]
fn coordinator_router_injects_cli_for_plan_requests() {
let mut harness = ChatWidgetHarness::new();
{
let chat = harness.chat();
chat.auto_state.active = true;
chat.config.tui.auto_drive.coordinator_routing = true;
chat.config.sandbox_policy = SandboxPolicy::DangerFullAccess;
}
harness.drain_events();
{
let chat = harness.chat();
chat.auto_handle_user_reply(None, Some("/plan".to_string()));
}
let events = harness.drain_events();
let (command, payload) = events
.iter()
.find_map(|event| match event {
AppEvent::DispatchCommand(cmd, payload) => Some((cmd, payload.clone())),
_ => None,
})
.expect("dispatch for /plan");
assert_eq!(*command, SlashCommand::Auto);
assert!(payload.contains("/plan"), "payload={payload}");
}
#[test]
fn coordinator_router_bypasses_slash_commands() {
let mut harness = ChatWidgetHarness::new();
{
let chat = harness.chat();
chat.auto_state.active = true;
chat.config.tui.auto_drive.coordinator_routing = true;
}
harness.drain_events();
{
let chat = harness.chat();
chat.submit_user_message(UserMessage::from("/status".to_string()));
}
let events = harness.drain_events();
assert!(
events.iter().any(|event| matches!(event, AppEvent::DispatchCommand(_, _))
|| matches!(event, AppEvent::CodexOp(_))),
"slash command should follow existing dispatch path"
);
}
#[test]
fn observer_history_filters_noise_cells() {
use crate::history::state::ExecStatus;
@@ -22664,7 +22958,7 @@ mod tests {
.history_cells
.iter()
.flat_map(|cell| cell.display_lines_trimmed())
.flat_map(|line| line.spans.iter())
.flat_map(|line| line.spans.clone())
.filter(|span| span.content.contains("Observer guidance: Tighten up the summary"))
.count();
assert_eq!(guidance_hits, 1);
@@ -22757,6 +23051,9 @@ mod tests {
chat.auto_on_assistant_final();
assert!(chat.auto_state.waiting_for_review);
let descriptor_snapshot = chat.pending_turn_descriptor.clone();
chat.auto_handle_post_turn_review(turn_config.clone(), descriptor_snapshot.as_ref());
chat.handle_code_event(Event {
id: "turn".to_string(),
event_seq: 42,
+82 -9
View File
@@ -20,6 +20,7 @@ use futures::StreamExt;
use reqwest::StatusCode;
use serde::Deserialize;
use serde_json::{self, json, Value};
use super::coordinator_user_schema::{user_turn_schema, parse_user_turn_reply};
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use uuid::Uuid;
@@ -63,7 +64,7 @@ const MAX_DECISION_RECOVERY_ATTEMPTS: u32 = 3;
struct AutoCoordinatorCancelled;
pub(super) const MODEL_SLUG: &str = "gpt-5";
const SCHEMA_NAME: &str = "auto_coordinator_flow";
const USER_TURN_SCHEMA_NAME: &str = "auto_coordinator_user_turn";
pub(super) const CROSS_CHECK_RESTART_BANNER: &str = "Cross-check restart with minimal context";
const COORDINATOR_PROMPT_PATH: &str = "code-rs/core/prompt_coordinator.md";
@@ -90,6 +91,10 @@ impl AutoCoordinatorHandle {
#[derive(Debug)]
pub(super) enum AutoCoordinatorCommand {
UpdateConversation(Vec<ResponseItem>),
HandleUserPrompt {
_prompt: String,
conversation: Vec<ResponseItem>,
},
ObserverResult(ObserverOutcome),
ObserverThinking {
mode: ObserverMode,
@@ -1020,6 +1025,40 @@ fn run_auto_loop(
}
match cmd_rx.recv() {
Ok(AutoCoordinatorCommand::HandleUserPrompt { _prompt, conversation }) => {
let developer_intro = compose_developer_intro(&base_developer_intro, &observer_guidance);
let mut updated_conversation = conversation.clone();
let schema = user_turn_schema();
match request_user_turn_decision(
&runtime,
client.as_ref(),
developer_intro.as_str(),
&primary_goal_message,
&schema,
updated_conversation.clone(),
auto_instructions.as_deref(),
&app_event_tx,
&cancel_token,
) {
Ok((user_response, cli_command)) => {
if let Some(response_text) = user_response.clone() {
updated_conversation.push(make_message("assistant", response_text.clone()));
}
pending_conversation = Some(updated_conversation);
app_event_tx.send(AppEvent::AutoCoordinatorUserReply {
user_response,
cli_command,
});
}
Err(err) => {
tracing::warn!("failed to handle coordinator user prompt: {err:#}");
app_event_tx.send(AppEvent::AutoCoordinatorUserReply {
user_response: Some(format!("Coordinator error: {err}")),
cli_command: None,
});
}
}
}
Ok(AutoCoordinatorCommand::UpdateConversation(conv)) => {
requests_completed = requests_completed.saturating_add(1);
consecutive_decision_failures = 0;
@@ -1598,6 +1637,32 @@ fn request_decision(
}
}
fn request_user_turn_decision(
runtime: &tokio::runtime::Runtime,
client: &ModelClient,
developer_intro: &str,
primary_goal: &str,
schema: &Value,
conversation: Vec<ResponseItem>,
auto_instructions: Option<&str>,
app_event_tx: &AppEventSender,
cancel_token: &CancellationToken,
) -> Result<(Option<String>, Option<String>)> {
let (raw, _response_items) = request_decision(
runtime,
client,
developer_intro,
primary_goal,
schema,
&conversation,
auto_instructions,
app_event_tx,
cancel_token,
)?;
let (user_response, cli_command) = parse_user_turn_reply(&raw)?;
Ok((user_response, cli_command))
}
fn request_decision_with_model(
runtime: &tokio::runtime::Runtime,
client: &ModelClient,
@@ -1624,7 +1689,7 @@ fn request_decision_with_model(
retry_with_backoff(
|| {
let instructions = auto_instructions.clone();
let prompt = build_prompt_for_model(
let prompt = build_user_turn_prompt(
&developer_intro,
&primary_goal,
&schema,
@@ -1643,16 +1708,20 @@ fn request_decision_with_model(
let mut out = String::new();
let mut response_items: Vec<ResponseItem> = Vec::new();
let mut reasoning_delta_accumulator = String::new();
let mut saw_output_text_delta = false;
while let Some(ev) = stream.next().await {
match ev {
Ok(ResponseEvent::OutputTextDelta { delta, .. }) => {
out.push_str(&delta);
saw_output_text_delta = true;
}
Ok(ResponseEvent::OutputItemDone { item, .. }) => {
if let ResponseItem::Message { content, .. } = &item {
for c in content {
if let ContentItem::OutputText { text } = c {
out.push_str(text);
if !saw_output_text_delta {
for c in content {
if let ContentItem::OutputText { text } = c {
out.push_str(text);
}
}
}
}
@@ -1660,6 +1729,7 @@ fn request_decision_with_model(
reasoning_delta_accumulator.clear();
}
response_items.push(item);
saw_output_text_delta = false;
}
Ok(ResponseEvent::ReasoningSummaryDelta {
delta,
@@ -1764,11 +1834,11 @@ fn request_decision_with_model(
}
}
fn build_prompt_for_model(
fn build_user_turn_prompt(
developer_intro: &str,
primary_goal: &str,
schema: &Value,
conversation: &[ResponseItem],
conversation: &Vec<ResponseItem>,
model_slug: &str,
auto_instructions: Option<&str>,
) -> Prompt {
@@ -1791,7 +1861,7 @@ fn build_prompt_for_model(
prompt.input.extend(conversation.iter().cloned());
prompt.text_format = Some(TextFormat {
r#type: "json_schema".to_string(),
name: Some(SCHEMA_NAME.to_string()),
name: Some(USER_TURN_SCHEMA_NAME.to_string()),
strict: Some(true),
schema: Some(schema.clone()),
});
@@ -2125,7 +2195,10 @@ fn push_unique_guidance(guidance: &mut Vec<String>, message: &str) {
if trimmed.is_empty() {
return;
}
if guidance.iter().any(|existing| existing == trimmed) {
if guidance
.iter()
.any(|existing| existing.eq_ignore_ascii_case(trimmed))
{
return;
}
guidance.push(trimmed.to_string());
@@ -0,0 +1,157 @@
//! Lightweight, keyword-based router for Auto Drive user prompts.
//!
//! This module offers a tiny heuristic bridge so the TUI can hand user
//! questions to the Auto Drive coordinator before they are sent to the CLI.
//! The real coordinator integration can later replace these heuristics
//! without touching call sites.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CoordinatorRouterResponse {
pub user_response: Option<String>,
pub cli_command: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CoordinatorContext {
pub active_agents: usize,
pub recent_updates: Vec<String>,
}
impl CoordinatorContext {
pub fn new(active_agents: usize, recent_updates: Vec<String>) -> Self {
Self {
active_agents,
recent_updates,
}
}
pub fn latest_update(&self) -> Option<&str> {
self.recent_updates.last().map(String::as_str)
}
}
pub fn route_user_message(msg: &str, ctx: &CoordinatorContext) -> CoordinatorRouterResponse {
let normalized = msg.trim().to_ascii_lowercase();
if normalized.is_empty() {
return CoordinatorRouterResponse::default();
}
if contains_any(&normalized, &STATUS_PHRASES) {
return status_response(ctx);
}
if contains_any(&normalized, &PLAN_PHRASES) {
return plan_response();
}
if contains_any(&normalized, &STOP_PHRASES) {
return stop_response();
}
CoordinatorRouterResponse::default()
}
const STATUS_PHRASES: [&str; 5] = [
"what work has been done",
"what have you done",
"status update",
"progress update",
"current status",
];
const PLAN_PHRASES: [&str; 4] = [
"start more agents",
"spin up more agents",
"launch more agents",
"create a plan",
];
const STOP_PHRASES: [&str; 3] = [
"stop all agents",
"halt agents",
"cancel the plan",
];
fn contains_any(message: &str, phrases: &[&str]) -> bool {
phrases.iter().any(|phrase| message.contains(phrase))
}
fn status_response(ctx: &CoordinatorContext) -> CoordinatorRouterResponse {
let mut summary = format!(
"We currently have {} active agent{}",
ctx.active_agents,
if ctx.active_agents == 1 { "" } else { "s" }
);
if let Some(update) = ctx.latest_update() {
summary.push_str("; most recently: ");
summary.push_str(update);
} else {
summary.push('.');
}
CoordinatorRouterResponse {
user_response: Some(summary),
cli_command: None,
}
}
fn plan_response() -> CoordinatorRouterResponse {
CoordinatorRouterResponse {
user_response: Some("Starting a fresh plan via the planner.".to_string()),
cli_command: Some("/plan".to_string()),
}
}
fn stop_response() -> CoordinatorRouterResponse {
CoordinatorRouterResponse {
user_response: Some("Stopping all active automation.".to_string()),
cli_command: Some("/stop".to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routes_status_queries() {
let ctx = CoordinatorContext::new(
2,
vec!["Finished lint pass on the CLI crate".to_string()],
);
let response = route_user_message(
"Can you tell me what work has been done so far?",
&ctx,
);
let user_response = response.user_response.expect("expected a status message");
assert!(user_response.contains("2 active agents"));
assert!(user_response.contains("Finished lint pass on the CLI crate"));
assert!(response.cli_command.is_none());
}
#[test]
fn routes_plan_requests() {
let ctx = CoordinatorContext::default();
let response = route_user_message("Please start more agents to handle this.", &ctx);
assert_eq!(response.cli_command.as_deref(), Some("/plan"));
assert!(response
.user_response
.as_deref()
.unwrap_or_default()
.contains("Starting a fresh plan"));
}
#[test]
fn returns_default_for_unmatched_input() {
let ctx = CoordinatorContext::default();
let response = route_user_message("Hello there!", &ctx);
assert!(response.user_response.is_none());
assert!(response.cli_command.is_none());
}
}
@@ -0,0 +1,54 @@
//! JSON schema helper for coordinator user-turn responses.
use anyhow::Context;
use serde_json::Value;
pub(crate) fn user_turn_schema() -> Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"user_response": {
"type": ["string", "null"],
"maxLength": 400,
"description": "Short message to respond the USER immediately."
},
"cli_command": {
"type": ["string", "null"],
"maxLength": 400,
"description": "Shell command to execute in the CLI this turn. Use null when no CLI action is required."
}
},
"required": ["user_response", "cli_command"]
})
}
pub(crate) fn parse_user_turn_reply(raw: &str) -> anyhow::Result<(Option<String>, Option<String>)> {
let value: Value = serde_json::from_str(raw)
.context("parsing coordinator user turn JSON")?;
let obj = value
.as_object()
.ok_or_else(|| anyhow::anyhow!("coordinator response was not a JSON object"))?;
let extract = |name: &str| -> anyhow::Result<Option<String>> {
let field = obj
.get(name)
.ok_or_else(|| anyhow::anyhow!("coordinator response missing required field '{name}'"))?;
if field.is_null() {
return Ok(None);
}
let Some(text) = field.as_str() else {
return Err(anyhow::anyhow!("coordinator field '{name}' must be string or null"));
};
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.chars().count() > 400 {
return Err(anyhow::anyhow!("coordinator field '{name}' exceeded 400 characters"));
}
Ok(Some(trimmed.to_string()))
};
Ok((extract("user_response")?, extract("cli_command")?))
}