feat(agent_orchestration): background command-center run list (#3373) (#3497)

This commit is contained in:
oxoxDev
2026-06-08 07:26:25 -07:00
committed by GitHub
parent 670bfdd189
commit 9b85a64b58
7 changed files with 619 additions and 0 deletions
+8
View File
@@ -293,6 +293,9 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::devices::all_devices_registered_controllers());
// Durable agent session database — queryable index over transcripts, lineage, tool calls
controllers.extend(crate::openhuman::session_db::all_session_db_registered_controllers());
// Background agent command center — read-only grouped view over the run ledger
controllers
.extend(crate::openhuman::agent_orchestration::all_command_center_registered_controllers());
controllers
}
@@ -419,6 +422,8 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::devices::all_devices_controller_schemas());
// Durable agent session database
schemas.extend(crate::openhuman::session_db::all_session_db_controller_schemas());
// Background agent command center
schemas.extend(crate::openhuman::agent_orchestration::all_command_center_controller_schemas());
schemas
}
@@ -502,6 +507,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"run_ledger" => Some(
"Durable agent and workflow run state, child lineage, events, telemetry, and checkpoint references.",
),
"agent_work" => Some(
"Background agent command center — recent agent runs grouped by status (needs-input, working, completed, failed, stopped).",
),
"billing" => Some("Subscription plan, payment links, and credit top-up via the backend."),
"team" => Some("Team member management, invites, and role changes via the backend."),
"tool_registry" => Some(
@@ -0,0 +1,23 @@
//! Background agent command center (issue #3373).
//!
//! A read-only product surface over the durable run ledger
//! (`session_db::run_ledger`): it lists recent background agent runs grouped by
//! a normalized status model (needs-input / working / completed / failed /
//! stopped) so users can see what is in flight, what is blocked on them, and
//! what finished. Live run state already persists to the ledger via the spawn
//! tools + `channels::providers::web::progress_bridge`; this module only
//! projects and groups it for display.
//!
//! Control verbs (stop / retry / continue) are intentionally out of scope here
//! and tracked as follow-up work.
mod ops;
mod schemas;
pub mod types;
pub use ops::{bucket_for, build_view, list_agent_work};
pub use schemas::{
all_controller_schemas as all_command_center_controller_schemas,
all_registered_controllers as all_command_center_registered_controllers,
};
pub use types::{AgentWorkBucket, AgentWorkRow, CommandCenterGroup, CommandCenterView};
@@ -0,0 +1,260 @@
//! Read-only command-center projection over the durable run ledger.
//!
//! [`list_agent_work`] fetches recent background agent runs from
//! `session_db::run_ledger` and projects them into a [`CommandCenterView`]
//! grouped by normalized [`AgentWorkBucket`]. The projection is split so the
//! pure grouping logic ([`build_view`]) is unit-testable without a database,
//! while [`list_agent_work`] owns the one ledger read.
use anyhow::Result;
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
use crate::openhuman::config::Config;
use crate::openhuman::session_db::run_ledger::{
list_agent_runs, AgentRun, AgentRunListRequest, AgentRunStatus,
};
use super::types::{AgentWorkBucket, AgentWorkRow, CommandCenterGroup, CommandCenterView};
/// Default number of recent runs scanned for the command center.
const DEFAULT_LIMIT: u32 = 200;
/// Hard ceiling, mirroring the ledger's own `list_agent_runs` cap.
const MAX_LIMIT: u32 = 500;
/// Map a fine-grained ledger status to its command-center bucket.
///
/// Exhaustive on [`AgentRunStatus`] so a new ledger status variant fails to
/// compile here until its bucket is decided.
pub fn bucket_for(status: AgentRunStatus) -> AgentWorkBucket {
match status {
AgentRunStatus::AwaitingUser => AgentWorkBucket::NeedsInput,
AgentRunStatus::Pending | AgentRunStatus::Running | AgentRunStatus::Paused => {
AgentWorkBucket::Working
}
AgentRunStatus::Completed => AgentWorkBucket::Completed,
AgentRunStatus::Failed => AgentWorkBucket::Failed,
AgentRunStatus::Cancelled | AgentRunStatus::Interrupted => AgentWorkBucket::Stopped,
}
}
/// List recent background agent work, grouped by command-center bucket.
///
/// Reads at most `limit` (default 200, capped 500) most-recently-updated runs
/// across every parent thread and projects them. Read-only: no ledger writes.
pub fn list_agent_work(config: &Config, limit: Option<u32>) -> Result<CommandCenterView> {
let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
log::debug!(
target: "command_center",
"[command_center] list_agent_work.entry limit={limit}"
);
let request = AgentRunListRequest {
status: None,
kind: None,
parent_run_id: None,
parent_thread_id: None,
limit: Some(limit),
offset: None,
};
let response = list_agent_runs(config, &request)?;
let view = build_view(response.runs);
log::debug!(
target: "command_center",
"[command_center] list_agent_work.done total={}",
view.total
);
Ok(view)
}
/// Project + group a set of ledger runs into the command-center view.
///
/// Pure: input order is preserved within each bucket, so callers that pass
/// runs already ordered most-recently-updated-first (as `list_agent_runs`
/// does) get recent-first rows per group. All five buckets are always present.
pub fn build_view(runs: Vec<AgentRun>) -> CommandCenterView {
let rows: Vec<AgentWorkRow> = runs.into_iter().map(project_row).collect();
let total = rows.len();
let groups = AgentWorkBucket::ALL
.iter()
.map(|&bucket| {
let bucket_rows: Vec<AgentWorkRow> = rows
.iter()
.filter(|r| r.bucket == bucket)
.cloned()
.collect();
CommandCenterGroup {
bucket,
count: bucket_rows.len(),
rows: bucket_rows,
}
})
.collect();
CommandCenterView { groups, total }
}
/// Project one ledger run into a lean command-center row.
fn project_row(run: AgentRun) -> AgentWorkRow {
let display_name = run.agent_id.as_deref().and_then(resolve_display_name);
let telemetry = run.telemetry;
AgentWorkRow {
run_id: run.id,
kind: run.kind.as_str().to_string(),
agent_id: run.agent_id,
display_name,
bucket: bucket_for(run.status),
status: run.status.as_str().to_string(),
parent_thread_id: run.parent_thread_id,
worker_thread_id: run.worker_thread_id,
summary: run.summary,
error: run.error,
started_at: run.started_at.to_rfc3339(),
updated_at: run.updated_at.to_rfc3339(),
elapsed_ms: telemetry.as_ref().and_then(|t| t.elapsed_ms),
input_tokens: telemetry.as_ref().map(|t| t.input_tokens).unwrap_or(0),
output_tokens: telemetry.as_ref().map(|t| t.output_tokens).unwrap_or(0),
cost_usd: telemetry.as_ref().map(|t| t.cost_usd).unwrap_or(0.0),
tool_count: telemetry.as_ref().map(|t| t.tool_count).unwrap_or(0),
}
}
/// Resolve an agent id to its registry display name, if the registry is up and
/// the agent is known. Returns `None` otherwise (e.g. custom/removed agents).
fn resolve_display_name(agent_id: &str) -> Option<String> {
AgentDefinitionRegistry::global()
.and_then(|registry| registry.get(agent_id))
.map(|definition| definition.display_name().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use serde_json::json;
fn run_with(id: &str, status: AgentRunStatus, updated_secs: i64) -> AgentRun {
AgentRun {
id: id.to_string(),
kind: crate::openhuman::session_db::run_ledger::AgentRunKind::Subagent,
parent_run_id: None,
parent_thread_id: Some("thread-1".to_string()),
agent_id: Some("researcher".to_string()),
status,
prompt_ref: None,
worker_thread_id: None,
task_board_id: None,
task_card_id: None,
checkpoint_path: None,
checkpoint: None,
summary: None,
error: None,
metadata: json!({}),
telemetry: None,
started_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
updated_at: Utc.timestamp_opt(1_700_000_000 + updated_secs, 0).unwrap(),
completed_at: None,
}
}
#[test]
fn bucket_for_maps_every_status_to_its_group() {
assert_eq!(
bucket_for(AgentRunStatus::AwaitingUser),
AgentWorkBucket::NeedsInput
);
assert_eq!(
bucket_for(AgentRunStatus::Pending),
AgentWorkBucket::Working
);
assert_eq!(
bucket_for(AgentRunStatus::Running),
AgentWorkBucket::Working
);
assert_eq!(bucket_for(AgentRunStatus::Paused), AgentWorkBucket::Working);
assert_eq!(
bucket_for(AgentRunStatus::Completed),
AgentWorkBucket::Completed
);
assert_eq!(bucket_for(AgentRunStatus::Failed), AgentWorkBucket::Failed);
assert_eq!(
bucket_for(AgentRunStatus::Cancelled),
AgentWorkBucket::Stopped
);
assert_eq!(
bucket_for(AgentRunStatus::Interrupted),
AgentWorkBucket::Stopped
);
}
#[test]
fn build_view_always_emits_five_buckets_in_display_order() {
let view = build_view(vec![]);
assert_eq!(view.total, 0);
let order: Vec<AgentWorkBucket> = view.groups.iter().map(|g| g.bucket).collect();
assert_eq!(order, AgentWorkBucket::ALL.to_vec());
assert!(view
.groups
.iter()
.all(|g| g.rows.is_empty() && g.count == 0));
}
#[test]
fn build_view_groups_runs_into_correct_buckets() {
let runs = vec![
run_with("a", AgentRunStatus::Running, 1),
run_with("b", AgentRunStatus::AwaitingUser, 2),
run_with("c", AgentRunStatus::Completed, 3),
run_with("d", AgentRunStatus::Failed, 4),
run_with("e", AgentRunStatus::Cancelled, 5),
run_with("f", AgentRunStatus::Pending, 6),
];
let view = build_view(runs);
assert_eq!(view.total, 6);
let group = |bucket: AgentWorkBucket| {
view.groups
.iter()
.find(|g| g.bucket == bucket)
.expect("bucket present")
};
assert_eq!(group(AgentWorkBucket::NeedsInput).count, 1);
assert_eq!(group(AgentWorkBucket::Working).count, 2); // running + pending
assert_eq!(group(AgentWorkBucket::Completed).count, 1);
assert_eq!(group(AgentWorkBucket::Failed).count, 1);
assert_eq!(group(AgentWorkBucket::Stopped).count, 1);
}
#[test]
fn build_view_preserves_input_order_within_a_bucket() {
// Caller passes recent-first; projection must not reorder.
let runs = vec![
run_with("newest", AgentRunStatus::Running, 30),
run_with("middle", AgentRunStatus::Running, 20),
run_with("oldest", AgentRunStatus::Running, 10),
];
let view = build_view(runs);
let working = view
.groups
.iter()
.find(|g| g.bucket == AgentWorkBucket::Working)
.unwrap();
let ids: Vec<&str> = working.rows.iter().map(|r| r.run_id.as_str()).collect();
assert_eq!(ids, vec!["newest", "middle", "oldest"]);
}
#[test]
fn project_row_defaults_telemetry_to_zero_when_absent() {
let view = build_view(vec![run_with("x", AgentRunStatus::Completed, 1)]);
let row = view
.groups
.iter()
.flat_map(|g| &g.rows)
.find(|r| r.run_id == "x")
.unwrap();
assert_eq!(row.input_tokens, 0);
assert_eq!(row.output_tokens, 0);
assert_eq!(row.cost_usd, 0.0);
assert_eq!(row.tool_count, 0);
assert_eq!(row.elapsed_ms, None);
assert_eq!(row.status, "completed");
assert_eq!(row.kind, "subagent");
}
}
@@ -0,0 +1,111 @@
//! Controller schema + JSON-RPC dispatcher for the background agent command
//! center. Exposes `openhuman.agent_work_list` (read-only) over the durable
//! run ledger. Handlers delegate to [`super::ops`]; no business logic here.
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
/// Controller schemas exposed by the command center.
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![schema_for("agent_work_list")]
}
/// Registered controllers (schema + handler) for the command center.
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![RegisteredController {
schema: schema_for("agent_work_list"),
handler: handle_agent_work_list,
}]
}
fn schema_for(function: &str) -> ControllerSchema {
match function {
"agent_work_list" => ControllerSchema {
namespace: "agent_work",
function: "list",
description: "List recent background agent runs grouped by command-center status \
bucket (needs_input / working / completed / failed / stopped).",
inputs: vec![optional_u64(
"limit",
"Max recent runs to scan (default 200, max 500).",
)],
outputs: vec![json_output(
"result",
"CommandCenterView with five status groups and a total count.",
)],
},
_ => ControllerSchema {
namespace: "agent_work",
function: "unknown",
description: "unknown command center function",
inputs: vec![],
outputs: vec![],
},
}
}
fn handle_agent_work_list(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let cid = new_correlation_id();
log::debug!(target: "command_center_rpc", "[command_center_rpc][{cid}] list.entry");
let config = config_rpc::load_config_with_timeout()
.await
.inspect_err(|err| {
log::warn!(target: "command_center_rpc", "[command_center_rpc][{cid}] list.config_failed err={err}");
})?;
let limit = params
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let view = super::ops::list_agent_work(&config, limit).map_err(|e| {
let s = e.to_string();
log::warn!(target: "command_center_rpc", "[command_center_rpc][{cid}] list.error err={s}");
s
})?;
to_json(view)
})
}
fn to_json<T: serde::Serialize>(value: T) -> Result<Value, String> {
RpcOutcome::new(value, vec![]).into_cli_compatible_json()
}
fn new_correlation_id() -> String {
uuid::Uuid::new_v4().simple().to_string()[..8].to_string()
}
fn optional_u64(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment,
required: false,
}
}
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registered_controllers_match_schemas() {
let schemas = all_controller_schemas();
let registered = all_registered_controllers();
assert_eq!(schemas.len(), registered.len());
assert!(schemas.iter().all(|s| s.namespace == "agent_work"));
assert_eq!(schema_for("agent_work_list").function, "list");
}
}
@@ -0,0 +1,121 @@
//! Command-center view types for the background agent surface (issue #3373).
//!
//! The durable run ledger (`session_db::run_ledger`) stores fine-grained
//! `AgentRunStatus` values for every background agent run. The background
//! agent command center groups that work into five user-facing buckets so a
//! reviewer can see, at a glance, what needs input, what is still working, and
//! what finished, failed, or was stopped. These types are the read-only
//! projection the command center renders; nothing here mutates ledger state.
use serde::Serialize;
/// Normalized command-center status bucket.
///
/// Collapses the ledger's eight `AgentRunStatus` values into the five groups
/// the command center renders. The mapping lives in
/// [`super::ops::bucket_for`]; the order of [`AgentWorkBucket::ALL`] is the
/// display order (needs-input first so blocked work is most visible).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentWorkBucket {
/// A run paused waiting for user input (`awaiting_user`).
NeedsInput,
/// A run still executing or queued (`pending` / `running` / `paused`).
Working,
/// A run that finished successfully (`completed`).
Completed,
/// A run that ended in error (`failed`).
Failed,
/// A run cancelled or interrupted before completion.
Stopped,
}
impl AgentWorkBucket {
/// Display order: needs-input first, then in-flight, then terminal states.
pub const ALL: [AgentWorkBucket; 5] = [
AgentWorkBucket::NeedsInput,
AgentWorkBucket::Working,
AgentWorkBucket::Completed,
AgentWorkBucket::Failed,
AgentWorkBucket::Stopped,
];
/// Stable wire string for this bucket.
pub fn as_str(self) -> &'static str {
match self {
AgentWorkBucket::NeedsInput => "needs_input",
AgentWorkBucket::Working => "working",
AgentWorkBucket::Completed => "completed",
AgentWorkBucket::Failed => "failed",
AgentWorkBucket::Stopped => "stopped",
}
}
}
/// One command-center row, projected from a durable [`AgentRun`].
///
/// Kept deliberately lean — transcripts and checkpoints stay in the ledger /
/// thread stores and are fetched on demand when a user opens a row.
///
/// [`AgentRun`]: crate::openhuman::session_db::run_ledger::AgentRun
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentWorkRow {
/// Ledger run id (open/peek key).
pub run_id: String,
/// Run kind wire string (`subagent` / `worker_thread` / ...).
pub kind: String,
/// Agent definition id, when known.
pub agent_id: Option<String>,
/// Human-friendly name resolved from the agent registry, when available.
pub display_name: Option<String>,
/// Normalized command-center bucket.
pub bucket: AgentWorkBucket,
/// Raw ledger status wire string (preserved for detail views).
pub status: String,
/// Parent conversation thread, for deterministic "open thread".
pub parent_thread_id: Option<String>,
/// Linked worker thread, for "open worker transcript".
pub worker_thread_id: Option<String>,
/// Latest summary, when the run produced one.
pub summary: Option<String>,
/// Failure reason, when the run failed.
pub error: Option<String>,
/// RFC3339 start timestamp.
pub started_at: String,
/// RFC3339 last-activity timestamp.
pub updated_at: String,
/// Wall-clock elapsed milliseconds, when telemetry recorded it.
pub elapsed_ms: Option<u64>,
/// Input tokens spent (0 when no telemetry).
pub input_tokens: u64,
/// Output tokens spent (0 when no telemetry).
pub output_tokens: u64,
/// Cost in USD (0 when no telemetry).
pub cost_usd: f64,
/// Tool-call count (0 when no telemetry).
pub tool_count: u64,
}
/// One status group in the command-center view.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandCenterGroup {
/// The bucket this group represents.
pub bucket: AgentWorkBucket,
/// Number of rows in this group.
pub count: usize,
/// Rows, most-recently-updated first.
pub rows: Vec<AgentWorkRow>,
}
/// The full command-center view: all five buckets in display order plus a total.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommandCenterView {
/// Always exactly five groups, in [`AgentWorkBucket::ALL`] order (empty
/// groups included so the UI can render stable section headers).
pub groups: Vec<CommandCenterGroup>,
/// Total rows across all buckets.
pub total: usize,
}
+4
View File
@@ -5,6 +5,7 @@
//! [`crate::openhuman::agent::harness`] module remains responsible for prompt
//! construction, tool filtering, and the actual sub-agent run loop.
pub mod command_center;
mod ops;
pub mod tools;
pub mod types;
@@ -12,6 +13,9 @@ pub mod types;
#[cfg(test)]
mod ops_tests;
pub use command_center::{
all_command_center_controller_schemas, all_command_center_registered_controllers,
};
pub use ops::{AgentOrchestrationSession, OrchestrationError};
pub use types::{
AgentMessage, AgentOrchestrationEvent, AgentSnapshot, AgentStatus, CloseAgentRequest,
+92
View File
@@ -2998,6 +2998,98 @@ async fn json_rpc_run_ledger_lifecycle() {
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_agent_work_list_groups_runs_by_bucket() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_url_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
let (api_addr, api_join) = serve_on_ephemeral(mock_upstream_router()).await;
let api_origin = format!("http://{api_addr}");
write_min_config(openhuman_home.as_path(), &api_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{rpc_addr}");
let config = openhuman_core::openhuman::config::Config::load_or_init()
.await
.expect("load config");
use openhuman_core::openhuman::session_db::run_ledger::{
upsert_agent_run, AgentRunKind, AgentRunStatus, AgentRunUpsert,
};
let seed = |id: &str, status: AgentRunStatus| AgentRunUpsert {
id: id.to_string(),
kind: AgentRunKind::Subagent,
parent_run_id: None,
parent_thread_id: Some("thread-work-1".to_string()),
agent_id: Some("researcher".to_string()),
status,
prompt_ref: None,
worker_thread_id: None,
task_board_id: None,
task_card_id: None,
checkpoint_path: None,
checkpoint: None,
summary: None,
error: None,
metadata: json!({ "source": "json_rpc_e2e" }),
started_at: None,
completed_at: None,
};
// Two awaiting-user (needs_input), one running (working), one completed.
upsert_agent_run(&config, seed("work-a", AgentRunStatus::AwaitingUser)).expect("seed a");
upsert_agent_run(&config, seed("work-b", AgentRunStatus::AwaitingUser)).expect("seed b");
upsert_agent_run(&config, seed("work-c", AgentRunStatus::Running)).expect("seed c");
upsert_agent_run(&config, seed("work-d", AgentRunStatus::Completed)).expect("seed d");
let list = post_json_rpc(&rpc_base, 9131, "openhuman.agent_work_list", json!({})).await;
let outer = assert_no_jsonrpc_error(&list, "agent_work_list");
assert_eq!(
outer.get("total").and_then(serde_json::Value::as_u64),
Some(4)
);
let groups = outer
.get("groups")
.and_then(serde_json::Value::as_array)
.expect("groups array");
// Always five buckets, in display order.
let buckets: Vec<&str> = groups
.iter()
.filter_map(|g| g.get("bucket").and_then(serde_json::Value::as_str))
.collect();
assert_eq!(
buckets,
vec!["needs_input", "working", "completed", "failed", "stopped"]
);
let count_of = |bucket: &str| -> u64 {
groups
.iter()
.find(|g| g.get("bucket").and_then(serde_json::Value::as_str) == Some(bucket))
.and_then(|g| g.get("count"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
};
assert_eq!(count_of("needs_input"), 2);
assert_eq!(count_of("working"), 1);
assert_eq!(count_of("completed"), 1);
assert_eq!(count_of("failed"), 0);
assert_eq!(count_of("stopped"), 0);
api_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_task_board_brief_roundtrips_across_todos_and_threads_rpc() {
let _env_lock = json_rpc_e2e_env_lock();