From 832710b8d818eac485cd4c979de8fcbd41c2b14b Mon Sep 17 00:00:00 2001 From: yzxoi Date: Wed, 20 May 2026 14:01:43 +0000 Subject: [PATCH 01/12] feat(provider): table-driven provider/model resolver, harden Resources API Replace the hand-coded `accelerator::model::{gpt4_1, deepseek_*}` builders with a declarative provider table plus a `/` resolver. Adding a new provider is now a one-row table edit. Resource API panics on missing or unknown active models are replaced with Option/Result so a typo or a missing credential produces a clear error or hitch fragment instead of a silent 401 against the wrong endpoint. - Add `accelerator::provider` with PROVIDERS and MODEL_PRESETS const tables - Add `resolve_model(spec)` parsing `provider/model`, provider-only, and auto-detect (first env_var-set provider in priority order) - Preserve Cost/Limit/Modalities via a separate MODEL_PRESETS table joined at resolve time; unknown models still resolve, just without preset metadata - Stash the bare model name in `Model.extra["wire_name"]` so qualified registry keys (`sii/gpt-4.1`) do not leak onto the wire - Delete `accelerator::model` (gpt4_1 / deepseek_v4_flash / deepseek_v4_pro) - Drop hardcoded model registration from `State::default()`; CLI injects the resolved model via `with_model` after parsing `--model` - Add `--model /` CLI flag (optional; auto-detects when omitted) - Resources::active_model now returns Option<&Model>; Resources::use_model returns Result - completion::complete emits a hitch when no model is active, no panic - Machine::apply Action::Model translates use_model errors into a hitch pushed onto the inbox so Policy can recover instead of crashing - Tests cover all resolve branches (explicit, provider-only, auto-detect, bare name rejection, unknown provider, missing credential, no credential at all, unknown model under known provider, error message content) plus Resources active_model/use_model edge cases Refs: #27 Refs: #5, #16 --- crates/accelerator/src/lib.rs | 5 +- crates/accelerator/src/model.rs | 88 --------- crates/accelerator/src/provider.rs | 277 +++++++++++++++++++++++++++ crates/accelerator/src/state.rs | 3 - crates/accelerator/tests/provider.rs | 189 ++++++++++++++++++ crates/cli/src/args.rs | 7 + crates/cli/src/cmd/run.rs | 7 +- crates/machine/src/completion.rs | 29 ++- crates/machine/src/lib.rs | 2 +- crates/machine/src/machine.rs | 12 +- crates/machine/src/resources.rs | 52 +++-- crates/machine/tests/resources.rs | 54 ++++++ 12 files changed, 605 insertions(+), 120 deletions(-) delete mode 100644 crates/accelerator/src/model.rs create mode 100644 crates/accelerator/src/provider.rs create mode 100644 crates/accelerator/tests/provider.rs create mode 100644 crates/machine/tests/resources.rs diff --git a/crates/accelerator/src/lib.rs b/crates/accelerator/src/lib.rs index 42fee432..668f849a 100644 --- a/crates/accelerator/src/lib.rs +++ b/crates/accelerator/src/lib.rs @@ -8,8 +8,8 @@ pub mod graph; pub mod logging; pub mod lsp; pub mod mcp; -pub mod model; pub mod policy; +pub mod provider; pub mod state; pub mod tools; @@ -22,4 +22,7 @@ pub use condition::{ }; pub use flux::{ContextFlux, EnvFlux, FluxMode, FluxRef, PolicyFlux, PurposeFlux, ResFlux}; pub use graph::{BuildError, Graph}; +pub use provider::{ + MODEL_PRESETS, ModelPreset, PROVIDERS, Provider, ResolveError, resolve_model, +}; pub use state::State; diff --git a/crates/accelerator/src/model.rs b/crates/accelerator/src/model.rs deleted file mode 100644 index 40db32ac..00000000 --- a/crates/accelerator/src/model.rs +++ /dev/null @@ -1,88 +0,0 @@ -use machine::{Cost, Limit, Modalities, Modality, Model, Protocol}; - -/// GPT-4.1 — OpenAI model hosted on the SII platform. -/// -/// Supports text and image input. Credentials (`SII_API_KEY`) are read -/// from the environment at call time; `credentials` is `None` if unset. -pub fn gpt4_1() -> Model { - Model { - name: "gpt-4.1".into(), - protocol: Protocol::OpenAI, - endpoint: Some("https://apicz.boyuerichdata.com/v1".into()), - credentials: std::env::var("SII_API_KEY").ok(), - limit: Some(Limit { - context: 1047576, - input: None, - output: 32768, - }), - cost: Some(Cost { - input: 1.6, - output: 3.21, - cache_read: Some(0.5), - cache_write: None, - }), - modalities: Some(Modalities { - input: vec![Modality::Text, Modality::Image], - output: vec![Modality::Text], - }), - ..Default::default() - } -} - -/// DeepSeek V4 Flash — general-purpose model. -/// -/// Supports text input only. Credentials (`DEEPSEEK_API_KEY`) are read -/// from the environment at call time; `credentials` is `None` if unset. -pub fn deepseek_v4_flash() -> Model { - Model { - name: "deepseek-v4-flash".into(), - protocol: Protocol::OpenAI, - endpoint: Some("https://api.deepseek.com".into()), - credentials: std::env::var("DEEPSEEK_API_KEY").ok(), - limit: Some(Limit { - context: 1_048_576, - input: None, - output: 393_216, - }), - cost: Some(Cost { - input: 1.0, - output: 2.0, - cache_read: Some(0.02), - cache_write: None, - }), - modalities: Some(Modalities { - input: vec![Modality::Text], - output: vec![Modality::Text], - }), - ..Default::default() - } -} - -/// DeepSeek V4 Pro — premium reasoning model. -/// -/// Supports text input only. Credentials (`DEEPSEEK_API_KEY`) are read -/// from the environment at call time; `credentials` is `None` if unset. -pub fn deepseek_v4_pro() -> Model { - Model { - name: "deepseek-v4-pro".into(), - protocol: Protocol::OpenAI, - endpoint: Some("https://api.deepseek.com".into()), - credentials: std::env::var("DEEPSEEK_API_KEY").ok(), - limit: Some(Limit { - context: 1_048_576, - input: None, - output: 393_216, - }), - cost: Some(Cost { - input: 3.0, - output: 6.0, - cache_read: Some(0.025), - cache_write: None, - }), - modalities: Some(Modalities { - input: vec![Modality::Text], - output: vec![Modality::Text], - }), - ..Default::default() - } -} diff --git a/crates/accelerator/src/provider.rs b/crates/accelerator/src/provider.rs new file mode 100644 index 00000000..7909a4be --- /dev/null +++ b/crates/accelerator/src/provider.rs @@ -0,0 +1,277 @@ +//! Provider table — declarative registry of LLM providers and model presets. +//! +//! Adding a new provider means adding a row to [`PROVIDERS`]; adding a model +//! preset means adding a row to [`MODEL_PRESETS`]. No business logic changes. +//! +//! Use [`resolve_model`] to construct a [`Model`] from a `provider/model` +//! specifier (or via auto-detection from environment variables). + +use std::fmt; + +use machine::{Cost, Limit, Modalities, Modality, Model, Protocol}; + +/// A provider — wire protocol, endpoint, credential source, and a default +/// model name to use when only the provider is specified. +pub struct Provider { + pub key: &'static str, + pub protocol: Protocol, + pub endpoint: &'static str, + pub env_var: &'static str, + pub default_model: &'static str, +} + +/// Static metadata about a model — token limits, pricing, modalities. Joined +/// with provider info at resolve time. +pub struct ModelPreset { + pub name: &'static str, + pub limit: Option, + pub cost: Option, + pub modalities_input: &'static [Modality], + pub modalities_output: &'static [Modality], +} + +/// Built-in providers. Order defines auto-detection priority when no spec is +/// given: the first provider whose env var is set wins. +pub const PROVIDERS: &[Provider] = &[ + Provider { + key: "sii", + protocol: Protocol::OpenAI, + endpoint: "https://apicz.boyuerichdata.com/v1", + env_var: "SII_API_KEY", + default_model: "gpt-4.1", + }, + Provider { + key: "deepseek", + protocol: Protocol::OpenAI, + endpoint: "https://api.deepseek.com", + env_var: "DEEPSEEK_API_KEY", + default_model: "deepseek-v4-flash", + }, + Provider { + key: "openai", + protocol: Protocol::OpenAI, + endpoint: "https://api.openai.com/v1", + env_var: "OPENAI_API_KEY", + default_model: "gpt-4o", + }, + Provider { + key: "anthropic", + protocol: Protocol::Anthropic, + endpoint: "https://api.anthropic.com", + env_var: "ANTHROPIC_API_KEY", + default_model: "claude-sonnet-4-5", + }, + Provider { + key: "gemini", + protocol: Protocol::Gemini, + endpoint: "https://generativelanguage.googleapis.com/v1beta", + env_var: "GEMINI_API_KEY", + default_model: "gemini-2.5-flash", + }, +]; + +/// Built-in model metadata. Models without a preset still resolve — they just +/// carry no cost/limit/modality info. +pub const MODEL_PRESETS: &[ModelPreset] = &[ + ModelPreset { + name: "gpt-4.1", + limit: Some(Limit { + context: 1_047_576, + input: None, + output: 32_768, + }), + cost: Some(Cost { + input: 1.6, + output: 3.21, + cache_read: Some(0.5), + cache_write: None, + }), + modalities_input: &[Modality::Text, Modality::Image], + modalities_output: &[Modality::Text], + }, + ModelPreset { + name: "deepseek-v4-flash", + limit: Some(Limit { + context: 1_048_576, + input: None, + output: 393_216, + }), + cost: Some(Cost { + input: 1.0, + output: 2.0, + cache_read: Some(0.02), + cache_write: None, + }), + modalities_input: &[Modality::Text], + modalities_output: &[Modality::Text], + }, + ModelPreset { + name: "deepseek-v4-pro", + limit: Some(Limit { + context: 1_048_576, + input: None, + output: 393_216, + }), + cost: Some(Cost { + input: 3.0, + output: 6.0, + cache_read: Some(0.025), + cache_write: None, + }), + modalities_input: &[Modality::Text], + modalities_output: &[Modality::Text], + }, +]; + +/// Reasons [`resolve_model`] can fail. Each variant carries enough context for +/// a CLI to print an actionable message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveError { + BadFormat(String), + UnknownProvider(String), + NoCredential { + provider: &'static str, + env_var: &'static str, + }, + NoCredentialAtAll, +} + +impl fmt::Display for ResolveError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BadFormat(spec) => write!( + formatter, + "model spec '{spec}' is missing the provider prefix; try '/' (e.g. 'sii/gpt-4.1')" + ), + Self::UnknownProvider(name) => { + let known: Vec<&str> = PROVIDERS.iter().map(|p| p.key).collect(); + write!( + formatter, + "unknown provider '{name}'; known providers: {}", + known.join(", ") + ) + } + Self::NoCredential { provider, env_var } => write!( + formatter, + "provider '{provider}' has no credential; set {env_var} or pick another provider" + ), + Self::NoCredentialAtAll => { + let envs: Vec<&str> = PROVIDERS.iter().map(|p| p.env_var).collect(); + write!( + formatter, + "no provider credential found; set one of: {} — or pass --model /", + envs.join(", ") + ) + } + } + } +} + +impl std::error::Error for ResolveError {} + +/// Resolve a model specifier to a fully-populated [`Model`]. +/// +/// `spec` accepts: +/// - `Some("provider/model")` — explicit provider and model +/// - `Some("provider")` — provider only; uses the provider's `default_model` +/// - `None` — auto-detect: pick the first provider in [`PROVIDERS`] whose env +/// var is set +/// +/// A bare model name (e.g. `Some("gpt-4.1")`) is rejected as ambiguous — +/// the same model name may be served by multiple providers. +pub fn resolve_model(spec: Option<&str>) -> Result { + let (provider, model_name) = match spec { + Some(raw) => parse_spec(raw)?, + None => auto_detect()?, + }; + build_model(provider, model_name) +} + +fn parse_spec(spec: &str) -> Result<(&'static Provider, String), ResolveError> { + let trimmed = spec.trim(); + if trimmed.is_empty() { + return Err(ResolveError::BadFormat(spec.to_string())); + } + match trimmed.split_once('/') { + Some((provider_key, model_name)) => { + if model_name.is_empty() { + return Err(ResolveError::BadFormat(spec.to_string())); + } + let provider = lookup_provider(provider_key)?; + Ok((provider, model_name.to_string())) + } + None => { + // Provider-only form: use the provider's default_model. + // To distinguish from a bare model name, require the token to + // actually be a known provider key. + let provider = lookup_provider(trimmed)?; + Ok((provider, provider.default_model.to_string())) + } + } +} + +fn lookup_provider(key: &str) -> Result<&'static Provider, ResolveError> { + PROVIDERS + .iter() + .find(|provider| provider.key == key) + .ok_or_else(|| ResolveError::UnknownProvider(key.to_string())) +} + +fn auto_detect() -> Result<(&'static Provider, String), ResolveError> { + for provider in PROVIDERS { + if std::env::var(provider.env_var).is_ok() { + return Ok((provider, provider.default_model.to_string())); + } + } + Err(ResolveError::NoCredentialAtAll) +} + +fn build_model(provider: &'static Provider, model_name: String) -> Result { + let credentials = std::env::var(provider.env_var).map_err(|_| ResolveError::NoCredential { + provider: provider.key, + env_var: provider.env_var, + })?; + + let preset = MODEL_PRESETS + .iter() + .find(|preset| preset.name == model_name); + + let qualified = format!("{}/{}", provider.key, model_name); + + let mut model = Model { + name: qualified, + protocol: provider.protocol, + endpoint: Some(provider.endpoint.to_string()), + credentials: Some(credentials), + ..Default::default() + }; + + if let Some(preset) = preset { + model.limit = preset.limit.clone(); + model.cost = preset.cost.clone(); + model.modalities = Some(Modalities { + input: preset.modalities_input.to_vec(), + output: preset.modalities_output.to_vec(), + }); + } + + // The qualified name is what we send to the wire — but providers expect the + // bare model name. We store the bare name in `extra` for completion.rs to + // pick up, while keeping the qualified name as the registry key. + model + .extra + .insert("wire_name".to_string(), model_name.into()); + + Ok(model) +} + +/// The bare model name to send on the wire. Falls back to `model.name` if the +/// `wire_name` extra is missing (for hand-built `Model`s that did not go +/// through [`resolve_model`]). +pub fn wire_name(model: &Model) -> &str { + model + .extra + .get("wire_name") + .and_then(|value| value.as_str()) + .unwrap_or(&model.name) +} diff --git a/crates/accelerator/src/state.rs b/crates/accelerator/src/state.rs index 341285c3..32232e5e 100644 --- a/crates/accelerator/src/state.rs +++ b/crates/accelerator/src/state.rs @@ -50,8 +50,5 @@ pub fn kit() -> Resources { include_str!("prompts/captain.txt").to_string(), ); - resources = resources.with_model(crate::model::deepseek_v4_flash()); - resources = resources.with_model(crate::model::gpt4_1()); - resources } diff --git a/crates/accelerator/tests/provider.rs b/crates/accelerator/tests/provider.rs new file mode 100644 index 00000000..655a5212 --- /dev/null +++ b/crates/accelerator/tests/provider.rs @@ -0,0 +1,189 @@ +//! Tests for the provider table and `resolve_model`. + +use accelerator::provider::{self, ResolveError}; +use std::sync::Mutex; + +/// Tests in this file all mutate process-wide environment variables. Cargo +/// runs tests in a single binary on multiple threads by default, so without +/// serialization they would race. A module-level mutex held for the lifetime +/// of each test serializes them. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +/// Save / restore env vars so tests don't leak into one another. Acquires the +/// module-level [`ENV_LOCK`] for the lifetime of the guard. +struct EnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, + saved: Vec<(String, Option)>, +} + +impl EnvGuard { + fn new(keys: &[&'static str]) -> Self { + let lock = ENV_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let saved = keys + .iter() + .map(|key| (key.to_string(), std::env::var(key).ok())) + .collect(); + for key in keys { + unsafe { + std::env::remove_var(key); + } + } + Self { _lock: lock, saved } + } + + fn set(&self, key: &str, value: &str) { + unsafe { + std::env::set_var(key, value); + } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, value) in &self.saved { + unsafe { + match value { + Some(original) => std::env::set_var(key, original), + None => std::env::remove_var(key), + } + } + } + } +} + +const ALL_ENV_VARS: &[&str] = &[ + "SII_API_KEY", + "DEEPSEEK_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", +]; + +#[test] +fn resolves_explicit_provider_slash_model() { + let guard = EnvGuard::new(ALL_ENV_VARS); + guard.set("SII_API_KEY", "sk-test"); + + let model = provider::resolve_model(Some("sii/gpt-4.1")).expect("resolve"); + assert_eq!(model.name, "sii/gpt-4.1"); + assert_eq!(model.endpoint.as_deref(), Some("https://apicz.boyuerichdata.com/v1")); + assert_eq!(model.credentials.as_deref(), Some("sk-test")); + // Cost / Limit / Modalities preserved from MODEL_PRESETS. + assert!(model.cost.is_some()); + assert!(model.limit.is_some()); + assert!(model.modalities.is_some()); +} + +#[test] +fn resolves_provider_only_to_default_model() { + let guard = EnvGuard::new(ALL_ENV_VARS); + guard.set("DEEPSEEK_API_KEY", "sk-deep"); + + let model = provider::resolve_model(Some("deepseek")).expect("resolve"); + assert_eq!(model.name, "deepseek/deepseek-v4-flash"); + assert_eq!(model.endpoint.as_deref(), Some("https://api.deepseek.com")); +} + +#[test] +fn auto_detect_picks_first_credentialed_provider() { + let guard = EnvGuard::new(ALL_ENV_VARS); + guard.set("OPENAI_API_KEY", "sk-openai"); + + let model = provider::resolve_model(None).expect("resolve"); + assert_eq!(model.name, "openai/gpt-4o"); +} + +#[test] +fn auto_detect_respects_priority_order() { + let guard = EnvGuard::new(ALL_ENV_VARS); + // SII is listed before DEEPSEEK in PROVIDERS, so it should win. + guard.set("SII_API_KEY", "sk-sii"); + guard.set("DEEPSEEK_API_KEY", "sk-deep"); + + let model = provider::resolve_model(None).expect("resolve"); + assert_eq!(model.name, "sii/gpt-4.1"); +} + +#[test] +fn bare_model_name_is_rejected() { + let _guard = EnvGuard::new(ALL_ENV_VARS); + match provider::resolve_model(Some("gpt-4.1")) { + Err(ResolveError::UnknownProvider(name)) => assert_eq!(name, "gpt-4.1"), + other => panic!("expected UnknownProvider, got {other:?}"), + } +} + +#[test] +fn empty_model_after_slash_is_bad_format() { + let _guard = EnvGuard::new(ALL_ENV_VARS); + match provider::resolve_model(Some("sii/")) { + Err(ResolveError::BadFormat(spec)) => assert_eq!(spec, "sii/"), + other => panic!("expected BadFormat, got {other:?}"), + } +} + +#[test] +fn unknown_provider_is_reported() { + let _guard = EnvGuard::new(ALL_ENV_VARS); + match provider::resolve_model(Some("foo/bar")) { + Err(ResolveError::UnknownProvider(name)) => assert_eq!(name, "foo"), + other => panic!("expected UnknownProvider, got {other:?}"), + } +} + +#[test] +fn missing_credential_is_reported_with_env_var() { + let _guard = EnvGuard::new(ALL_ENV_VARS); + match provider::resolve_model(Some("sii/gpt-4.1")) { + Err(ResolveError::NoCredential { provider, env_var }) => { + assert_eq!(provider, "sii"); + assert_eq!(env_var, "SII_API_KEY"); + } + other => panic!("expected NoCredential, got {other:?}"), + } +} + +#[test] +fn no_credential_at_all_when_auto_detecting() { + let _guard = EnvGuard::new(ALL_ENV_VARS); + match provider::resolve_model(None) { + Err(ResolveError::NoCredentialAtAll) => {} + other => panic!("expected NoCredentialAtAll, got {other:?}"), + } +} + +#[test] +fn unknown_model_under_known_provider_resolves_without_preset() { + let guard = EnvGuard::new(ALL_ENV_VARS); + guard.set("SII_API_KEY", "sk-test"); + + // Provider known but model has no preset entry — should still resolve, just + // without cost/limit/modalities populated. + let model = provider::resolve_model(Some("sii/some-future-model")).expect("resolve"); + assert_eq!(model.name, "sii/some-future-model"); + assert!(model.cost.is_none()); + assert!(model.limit.is_none()); + assert!(model.modalities.is_none()); +} + +#[test] +fn error_messages_are_actionable() { + let bad = ResolveError::BadFormat("gpt-4.1".into()); + assert!(bad.to_string().contains("provider")); + + let unknown = ResolveError::UnknownProvider("foo".into()); + let msg = unknown.to_string(); + assert!(msg.contains("foo")); + assert!(msg.contains("sii")); + + let no_cred = ResolveError::NoCredential { + provider: "sii", + env_var: "SII_API_KEY", + }; + assert!(no_cred.to_string().contains("SII_API_KEY")); + + let none = ResolveError::NoCredentialAtAll; + let msg = none.to_string(); + assert!(msg.contains("SII_API_KEY")); + assert!(msg.contains("--model")); +} diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index 32306074..4072eb6b 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -39,6 +39,13 @@ pub struct RunArgs { /// Repeatable — each `--mcp-server` adds one server. #[arg(long = "mcp-server")] pub mcp_servers: Vec, + + /// Model selector in `/` form (e.g. `sii/gpt-4.1`). + /// + /// Provider-only (e.g. `--model sii`) uses the provider's default model. + /// Omit to auto-detect: the first provider whose env var is set wins. + #[arg(long, value_name = "PROVIDER/MODEL")] + pub model: Option, } impl RunArgs { diff --git a/crates/cli/src/cmd/run.rs b/crates/cli/src/cmd/run.rs index f47b81b1..db800480 100644 --- a/crates/cli/src/cmd/run.rs +++ b/crates/cli/src/cmd/run.rs @@ -4,7 +4,7 @@ use std::thread; use std::time::Instant; use accelerator::mcp::{McpRegistry, McpServerConfig}; -use accelerator::{Graph, State}; +use accelerator::{Graph, State, resolve_model}; use tracing_subscriber::prelude::*; use crate::args::{Format, RunArgs}; @@ -15,6 +15,10 @@ pub async fn run(args: RunArgs) -> anyhow::Result<()> { let (hook_tx, hook_rx) = mpsc::channel(); init_tracing(hook_tx); + // ── Model resolution (fail fast with a clear, actionable error) ── + let model = resolve_model(args.model.as_deref()) + .map_err(|error| anyhow::anyhow!("{}", error))?; + // ── MCP setup ── let configs: Vec = args .mcp_servers @@ -37,6 +41,7 @@ pub async fn run(args: RunArgs) -> anyhow::Result<()> { purpose: args.prompt_text(), ..State::default() }; + state.res = state.res.with_model(model); for tool in &mcp_tools { let name = tool.name().to_string(); diff --git a/crates/machine/src/completion.rs b/crates/machine/src/completion.rs index 0b85ab78..6b4d8e64 100644 --- a/crates/machine/src/completion.rs +++ b/crates/machine/src/completion.rs @@ -9,15 +9,33 @@ use crate::model::{Model, Protocol}; use crate::resources::Resources; use tracing::{debug, warn}; +/// The bare model name to send on the wire. When a `Model` is constructed via +/// the accelerator's provider table, `name` is `"/"` and the +/// bare model name is stashed in `extra["wire_name"]`. Hand-built `Model`s +/// without that extra fall back to `name` directly. +fn wire_name(model: &Model) -> &str { + model + .extra + .get("wire_name") + .and_then(|value| value.as_str()) + .unwrap_or(&model.name) +} + /// Call the active LLM and return the response fragments or an error. /// /// Dispatches by `Protocol` (3 arms) to the corresponding rig module. /// `endpoint` optionally overrides the provider's default base URL. /// /// On failure, returns `Content::Hitch` so the caller (Policy) can -/// decide to retry, switch model, or abort. +/// decide to retry, switch model, or abort. If no model is registered or +/// active, returns a hitch instead of panicking. pub async fn complete(ctx: &Context, resources: &Resources) -> Vec { - let model = resources.active_model(); + let Some(model) = resources.active_model() else { + warn!("no active model registered"); + return vec![Fragment::hitch( + "no active model registered; configure a provider via --model or set a provider env var", + )]; + }; let messages: Vec = ctx.fragments().iter().filter_map(encode).collect(); @@ -40,6 +58,7 @@ pub async fn complete(ctx: &Context, resources: &Resources) -> Vec { let api_key = model.credentials.as_deref().unwrap_or(""); let endpoint_url = model.endpoint.as_deref(); + let wire_name = wire_name(model); let result = match model.protocol { Protocol::OpenAI => { @@ -50,7 +69,7 @@ pub async fn complete(ctx: &Context, resources: &Resources) -> Vec { let endpoint = builder .build() .expect("failed to build openai client") - .completion_model(&model.name); + .completion_model(wire_name); send(&endpoint, model, &messages, &tools).await } Protocol::Anthropic => { @@ -61,7 +80,7 @@ pub async fn complete(ctx: &Context, resources: &Resources) -> Vec { let endpoint = builder .build() .expect("failed to build anthropic client") - .completion_model(&model.name); + .completion_model(wire_name); send(&endpoint, model, &messages, &tools).await } Protocol::Gemini => { @@ -72,7 +91,7 @@ pub async fn complete(ctx: &Context, resources: &Resources) -> Vec { let endpoint = builder .build() .expect("failed to build gemini client") - .completion_model(&model.name); + .completion_model(wire_name); send(&endpoint, model, &messages, &tools).await } }; diff --git a/crates/machine/src/lib.rs b/crates/machine/src/lib.rs index c4db2724..1262b529 100644 --- a/crates/machine/src/lib.rs +++ b/crates/machine/src/lib.rs @@ -36,5 +36,5 @@ pub use machine::Machine; pub use model::{Cost, Limit, Modalities, Modality, Model, Protocol}; pub use policy::{Action, Phase, PhaseOutcome, Policy}; pub use purpose::Purpose; -pub use resources::Resources; +pub use resources::{ModelNotRegistered, Resources}; pub use tool::Tool; diff --git a/crates/machine/src/machine.rs b/crates/machine/src/machine.rs index f080bd50..5354f290 100644 --- a/crates/machine/src/machine.rs +++ b/crates/machine/src/machine.rs @@ -1,6 +1,7 @@ use crate::context::Context; use crate::env::Environment; use crate::event::{content_kind, preview, role_name}; +use crate::fragment::Fragment; use crate::hook; use crate::inbox::Inbox; use crate::policy::{Action, Phase, PhaseOutcome, Policy}; @@ -103,10 +104,14 @@ impl Machine { } *round += 1; + let model_name = resources + .active_model() + .map(|model| model.name.as_str()) + .unwrap_or(""); hook!( event = "halt", round = *round, - model = %resources.active_model().name, + model = %model_name, messages = ctx.fragments().len(), tools = resources.active_tools.len(), ); @@ -170,7 +175,10 @@ impl Machine { } Action::Model(name) => { hook!(event = "model", name); - resources.use_model(name); + if let Err(error) = resources.use_model(&name) { + warn!(?error, "use_model failed"); + inbox.push(Fragment::hitch(error.to_string())); + } } Action::Activate(name) => { hook!(event = "activate", name); diff --git a/crates/machine/src/resources.rs b/crates/machine/src/resources.rs index 2c20d2aa..b7bc5758 100644 --- a/crates/machine/src/resources.rs +++ b/crates/machine/src/resources.rs @@ -84,29 +84,29 @@ impl Resources { self.active_tools.remove(&name.into()); } - /// Switch the active model. - /// - /// # Panics - /// - /// Panics if the model is not registered. - pub fn use_model(&mut self, name: impl Into) { + /// Switch the active model. Returns the previous active-model name on + /// success, or [`ModelNotRegistered`] if no model with that name has been + /// registered. + pub fn use_model(&mut self, name: impl Into) -> Result { let name = name.into(); - assert!( - self.models.contains_key(&name), - "model '{name}' not registered" - ); - self.active_model = name; + if !self.models.contains_key(&name) { + return Err(ModelNotRegistered(name)); + } + let previous = std::mem::replace(&mut self.active_model, name); + Ok(previous) } - /// The currently active model. - /// - /// # Panics + /// The currently active model, if any has been registered and selected. /// - /// Panics when no model has been registered. - pub fn active_model(&self) -> &Model { - self.models - .get(&self.active_model) - .expect("active model not found") + /// Returns `None` when no model has been registered, or when the active + /// name does not point at a registered model (which can only happen if + /// `active_model` was mutated through public field access — kept here for + /// safety). + pub fn active_model(&self) -> Option<&Model> { + if self.active_model.is_empty() { + return None; + } + self.models.get(&self.active_model) } /// All active tools. @@ -126,3 +126,17 @@ impl Resources { self.tools.get(name).map(|t| t.as_ref()) } } + +/// Returned by [`Resources::use_model`] when the requested model has not been +/// registered. Carries the offending name so the caller can build a useful +/// hitch / error message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelNotRegistered(pub String); + +impl std::fmt::Display for ModelNotRegistered { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "model '{}' not registered", self.0) + } +} + +impl std::error::Error for ModelNotRegistered {} diff --git a/crates/machine/tests/resources.rs b/crates/machine/tests/resources.rs new file mode 100644 index 00000000..0aa87165 --- /dev/null +++ b/crates/machine/tests/resources.rs @@ -0,0 +1,54 @@ +//! Tests for `Resources::active_model` and `Resources::use_model` after the +//! API hardening: they must return `Option` / `Result` rather than panic. + +#[allow(dead_code)] +mod common; + +use common::test_model; +use machine::{Model, ModelNotRegistered, Resources}; + +#[test] +fn active_model_is_none_when_empty() { + let resources = Resources::new(); + assert!(resources.active_model().is_none()); +} + +#[test] +fn first_with_model_becomes_active() { + let resources = Resources::new().with_model(test_model()); + let active = resources.active_model().expect("should have active"); + assert_eq!(active.name, "test"); +} + +#[test] +fn use_model_switches_active() { + let mut resources = Resources::new() + .with_model(test_model()) + .with_model(Model { + name: "other".into(), + ..Default::default() + }); + assert_eq!(resources.active_model().unwrap().name, "test"); + + let previous = resources.use_model("other").expect("registered"); + assert_eq!(previous, "test"); + assert_eq!(resources.active_model().unwrap().name, "other"); +} + +#[test] +fn use_model_unknown_returns_error_not_panic() { + let mut resources = Resources::new().with_model(test_model()); + let result = resources.use_model("nope"); + match result { + Err(ModelNotRegistered(name)) => assert_eq!(name, "nope"), + Ok(_) => panic!("expected error for unknown model"), + } + // Active model unchanged after the failed switch. + assert_eq!(resources.active_model().unwrap().name, "test"); +} + +#[test] +fn model_not_registered_error_includes_name() { + let error = ModelNotRegistered("missing-x".into()); + assert!(error.to_string().contains("missing-x")); +} From e769b5c970896c19f81b9987e17e8e8f3667bb6c Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:27:56 +0800 Subject: [PATCH 02/12] chore: migrate RCM governance and single-repository releases (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(machine): parallel tool execution in reactor via join_all Replace the serial for-await loop in reactor::react() with a partition-then-join_all pattern: Phase 1: Text/hitch fragments → inbox immediately ToolCall fragments → collect futures (without awaiting) Phase 2: join_all executes all tool calls concurrently Phase 3: Push tool_call + result pairs to inbox by index Before: for frag in fragments { tool.execute().await } After: join_all(tool_futures).await Behavioral guarantees preserved: - Text fragments always reach inbox before tool results - Each ToolResult is paired with its ToolCall by call_id - Tool not found → hitch pushed immediately (no spawning) - No Tool trait changes needed (join_all works with non-'static lifetimes) Performance: N tools × latency → max(latency) instead of sum(latency) Closes: #3 * fix(machine): isolate tool panics via tokio::spawn in reactor Each tool execution is now wrapped inside tokio::spawn. A panic inside any tool's async block becomes a JoinError instead of unwinding through join_all and crashing the entire process. Before: 1 tool panics (e.g. &command[..60] on UTF-8 boundary) → all tools killed, process exits. After: 1 tool panics → JoinError caught → hitch pushed to inbox → other 2 tools complete normally. This requires cloning tool_arc (already Arc) and env (into Arc) before spawning, both cheap operations. Two test additions: - one_tool_panic_kills_all_join_all_tools: sync #[should_panic] repro - spawn_isolates_tool_panic: tokio::spawn with PanicTool fails isolatedly while healthy tools succeed Fixes: #83 * fix(python-sdk): decouple destroy() from channel close RCMClient.destroy() was calling self._channel.close(), which made the entire client unusable after a single destroy(). Consecutive Open calls would crash with "Cannot invoke RPC on closed channel". Fix: destroy() only destroys the machine on the server. New close() method for explicit channel shutdown. * docs(examples,readme): add gRPC Python demo and SDK documentation Adds the Paper Digest gRPC demo (examples/research-assistant/grpc_demo.py) that mirrors the arxiv_pipeline.rcm graph lifecycle through Python gRPC: Open → Setup (Append/Model/Activate) → Policy Loop → Destroy SDK README (sdks/python/README.md) with setup instructions, demo usage, and API reference table. README.md updated with gRPC demo section + example table entries for Python SDK and demo files. * fix(machine): use first context message as initial prompt, fall back to system role placeholder send() used Message::user('.') as the initial prompt for rig's completion_request builder API. rig includes this prompt in the chat history sent to the LLM. When the LLM saw a bare '.' with user role after its own assistant reply (no tool call), it interpreted this as a user accidentally sending a period and started hallucinating responses like 'looks like you just sent a period', which entered context via Take and created a perpetual loop. Fix: use the first actual context message as the initial prompt (always the real first message in practice). For the degenerate empty case, fall back to Message::system('_') — system role carries no conversational intent and won't trigger spurious LLM responses. Rewrote the remaining-messages slicing from 5-line if/else to 1-line .get(1..).unwrap_or_default(). * fix(machine): restore .messages() call for context beyond first fragment PR #86 (commit b0a32fd) split messages into (initial_prompt, remaining) but the .messages(remaining_messages) call was lost during the merge. Every LLM call only received the first context fragment — all other fragments (system prompts, purpose, tool results, assistant replies) were silently dropped. This fix: a4e6189 - split_messages() extracted as helper function + 3 inline tests - .messages(remaining_messages) restored after completion_request() - Removes inline first()/get(1..) logic in favor of helper Refs: #87 * refactor(accelerator): remove illegal inline tests, move LSP client test to tests/ - Remove client_key_hashes_root_and_server (tested derive macro on private helper struct, not real crate API behaviour) - Move fake_lsp_server_reports_versioned_diagnostics from src/ to tests/ as lsp_client.rs (mock infrastructure + Python subprocess dependency, belongs in integration tests) - Export LspClient and ServerSpec from lsp module for test access - Make LspClient::start_with_command public for integration testing * fix(machine): construct CompletionRequest directly to eliminate prompt placeholder and message rotation `send()` had been using rig's `completion_request(prompt).messages(history)` builder, where `prompt` is appended to the END of `chat_history` at `build()` time. Picking which message is the "prompt" produced two historical regressions: - #43 family: `Message::user(".")` stub was interpreted by the LLM as a user reply, causing "looks like you sent a period" hallucination loops. - #86 / #88: switching the stub to `messages[0]` rotated the leading system instruction to the END of the request — `[Sys, User]` became `[User, Sys]` on the wire. Switch to direct `CompletionRequest` construction: - `chat_history: OneOrMany` is literally what gets serialized. No prompt slot, no rotation, no fabricated stub. - `OneOrMany::many(empty)` errors, so the degenerate empty-context case surfaces as a `Fragment::hitch(role=System)` Result — consistent with the existing `"no active model set"` path in `complete()`. - All `CompletionRequest` fields are spelled out explicitly; the absence of `Default` for that struct turns future field additions into a compile error rather than a silent behavior change. The helper `build_request()` is testable in isolation. New unit tests guard against the regressions above: `build_request_preserves_message_order`, `build_request_empty_messages_returns_hitch`, plus coverage for single-message and temperature/max_tokens passthrough. Replaces the `split_messages` helper and its three placeholder-centric tests. Co-Authored-By: Claude Opus 4.7 * fix(machine): preserve reasoning content from thinking-mode responses and share across parallel tool_calls DeepSeek and Kimi Coding return `reasoning_content` on assistant turns in thinking mode and require it to be echoed back on subsequent requests that include that turn as history. The previous code dropped the reasoning in `decode()` (the catch-all `_ => {}` arm) and emitted a `Reasoning::new(".")` stub in `encode()` when `thinking=true`. Kimi tolerated the stub; DeepSeek did not, producing HTTP 400: The `reasoning_content` in the thinking mode must be passed back to the API. End-to-end this caused the paper_search captain loop to die after the first tool-call turn, repeatedly hitching with status 400. Fix: - Add `ToolCall.reasoning: Option` (`#[serde(default)]`, so old persisted contexts keep deserializing). Captured by `decode`, re-emitted by `encode`. - `decode()` now buffers `AssistantContent::Reasoning` blocks and attaches them to the *next* `AssistantContent::ToolCall`. Critically, the buffer is **not** cleared on ToolCall — a single reasoning block can be emitted by the model alongside multiple parallel tool_calls (paper search produced exactly this shape against DeepSeek), and every fragment in that turn must carry the reasoning when re-encoded. The buffer is cleared only on Text or Image, which mark a new logical turn. - `encode()` Role::Assistant emits `tc.reasoning` verbatim when present. The `Reasoning::new(".")` stub is retained only as a fallback for `thinking=true` AND `reasoning.is_none()` (legacy Kimi path for fragments constructed without a captured reasoning string). - The `_ => {}` non-exhaustive arm in `decode()` is removed; rig's `AssistantContent` is now fully matched (Text/Reasoning/ToolCall/Image), so future variants become a compile error rather than silent drops. - `Fragment::with_reasoning()` is a no-op on non-ToolCall content; used by the decoder. New regression tests in `completion::tests`: - `decode_attaches_reasoning_to_following_tool_call` - `decode_concatenates_multi_block_reasoning_before_tool_call` - `decode_discards_reasoning_before_text_turn` - `decode_tool_call_without_reasoning_has_none` - `decode_parallel_tool_calls_share_one_reasoning` — direct regression for the paper_digest 400 loop observed at 2026-05-27T14:10:36. Plus in `tests/completion.rs`: - `encode_tool_call_emits_stored_reasoning_not_placeholder` — captured reasoning wins over the `.` stub regardless of `thinking` flag. - `encode_assistant_text_with_thinking_does_not_synthesize_reasoning` — the stub remains tool-call-only. Verified against the live demo: `paper_digest.rcm` against `deepseek-v4-flash` now completes 5 LLM calls (including a 2-parallel tool_call turn) with zero hitches. Co-Authored-By: Claude Opus 4.7 * style(machine): apply rustfmt to completion.rs CI's rustfmt check flagged four sites that exceeded the line-length budget: - `endpoint.completion(...)` timeout block reformatted to a multi-line match. - `Fragment::tool_call(...)` arguments collapsed onto one line. - `assert!(matches!(...))` in `build_request_empty_messages_returns_hitch` wrapped to a multi-line `assert!`. - Stray double-blank line in `tests/completion.rs`. Pure formatting; no behavior change. Co-Authored-By: Claude Opus 4.7 * refactor(machine): expose completion::{build_request, decode} and move tests to integration file AGENTS.md requires tests in `tests/`, not inline `#[cfg(test)] mod` modules. The 9 tests added in da0bf5f (build_request) and 1a2457b (decode reasoning) lived inline because they exercised private helpers. Promote `build_request` and `decode` to `pub`, then move the tests to `tests/completion.rs`. The two helpers are now part of the crate API, symmetric with the already-public `encode`. Test helper functions (`dummy_model`, `assistant_reasoning`, `assistant_tool_call`) move alongside. `tests/completion.rs` grows from 12 to 21 tests; `src/completion.rs` loses its `mod tests` block entirely. No behavior change; all 21 tests pass against the moved code. Co-Authored-By: Claude Opus 4.7 * fix(python-sdk): add termination gate to ReactPolicy to prevent infinite loop ReactPolicy had no mechanism to stop the agent loop. Halt was always prioritized over Done, so the agent would call the LLM indefinitely without ever terminating. Fix: mirror the Rust Captain's termination heuristic: - First call -> Halt (LLM hasn't spoken yet) - Last consumed fragment was tool_result -> Halt (LLM needs to read output) - Inbox empty + no tool result -> Done (natural termination) Code cleanup: - Loop variable 'a' -> 'action' (AGENTS.md compliance) - _last_was_tool_result avoids list() copy (uses len() + [-1] directly) Tests: 7 unit tests covering all termination branches * fix(machine): Resources::lookup returns LookupResult tri-state instead of Option lookup() previously returned Option<&dyn Tool>, conflating 'tool is registered but disabled' with 'tool is not registered at all'. The reactor treated both as 'not found' with the same hitch message. Fix: - New LookupResult enum: Active / Inactive / NotFound - lookup() returns LookupResult (no reference) - New get() method returns Option<&dyn Tool> for Active path only - reactor.rs match on LookupResult::Inactive emits a distinct message ('tool is disabled — activate it before use') - reactor.rs Active branch uses expect() with invariant annotation Tests (8 total, zero getter tests): - enable/disable transition tests (behavior) - lookup_active/Inactive/NotFound implies get returns Some/None (invariant) - hitch message text verification for Inactive and NotFound Closes: #16 * Add autoresearch survey scaffold * Add modular autoresearch survey pipeline * Constrain autoresearch survey context handoffs * Tighten autoresearch-survey handoffs and tool grants Make the survey pipeline's inter-node context smaller and more uniform, and give each node only the tools it needs. - Add schema/handoff.md: one minimal handoff envelope (run_dir first, then artifact/status, plus optional counts/ids/verdict/risks/next, ~15 lines max). All 19 prompts now reference it instead of each describing an ad-hoc final-message format. - Harden the run_dir invariant: standalone runs may fall back to the newest runs/* directory, but must surface that recovery as a risk rather than switching run_dir silently. handoff.md documents the fs-list lookup (list runs/ directly; its parent hides it via gitignore; timestamp names sort chronologically). - Minimize tools: drop shell from the 13 pure read/write nodes (scouts keep arxiv_search, mergers/judges/rank/map/brief are fs-only). shell remains only where genuinely needed: anchor (timestamp/env), query_plan (mint run_dir), reference_expander (pdftotext/strings). Verified with accelerate parse on all edited graphs and inventory on the whole project. Co-Authored-By: Claude Opus 4.8 * Fix handoff.md path in survey prompts (cwd-relative bug) The previous commit referenced the new handoff contract as the bare relative path `schema/handoff.md`. The CLI runs from the repo root, so the fs tool resolved that to `/schema/handoff.md`, which does not exist — every node hit `fs error: File not found: './schema/handoff.md'`. That fs error became an orphan tool fragment which `flux mode=last` forwarded downstream, so the next node opened its context with a role=tool message that had no preceding tool_calls, and DeepSeek rejected the request with HTTP 400. Use the repo-root path `examples/autoresearch-survey/schema/handoff.md`, matching how every other schema file is already referenced. Co-Authored-By: Claude Opus 4.8 * List available tools when a tool name is not found When a model calls a tool that does not exist (e.g. `write` instead of `fs` with action="write"), the reactor pushed a bare "tool 'X' not found" hitch with no code. The captain policy treats code-less hitches as transient and retries, but the terse message gave the model nothing to correct against, so it repeated the same bad call until the retry budget exhausted and the node halted with no output. Include the active tool names in the not-found message so the retry is actionable and the model can self-correct within budget. Observed in the autoresearch-survey survey_brief node, which is granted only `fs`: it intermittently called a hallucinated `write` tool and the run ended before 07_survey_brief.md was written. Co-Authored-By: Claude Opus 4.8 * Add survey_writer node for the final long-form survey The pipeline produced only the compact 07_survey_brief.md and stopped, so a run ended with no readable survey for the user — the brief is an audit summary by design ("do not write a long survey article"). Add survey_writer as a new terminal node after survey_brief. It reads the spec, research map, judge panel, and brief from run_dir and projects them into a section-structured narrative survey (08_survey.md): abstract, introduction, one section per method family, benchmarks, comparison, open problems, conclusion, references. It then prints the full survey as its final message so the user sees the report directly. The writer is evidence-constrained: it obeys the JudgePanel forbidden_overclaims, only makes comparisons the BenchmarkJudge marked ready, never invents citations, and scopes itself honestly on partial runs. Tools stay fs-only; it can be run standalone against an existing run_dir. Wiring: survey_brief.context -> brief_handoff -> survey_writer.context, survey_brief.done -> survey_writer.trigger, survey_writer -> output. Docs and the survey_brief prompt updated to reflect the map-then-narrative two-product design. Co-Authored-By: Claude Opus 4.8 * List valid actions when fs gets an unknown action The fs tool's catch-all returned a bare "unknown action 'find'" with no hint at the valid set. A model that hallucinated an action (e.g. `find`) got nothing to correct against, so the captain policy retried the same bad call until the budget exhausted. Include the valid actions (read, write, edit, list) in the error, same remedy as the tool-not-found message, so the retry is actionable. Observed in the survey_writer node when run standalone: it guessed an fs action `find` and the run halted with no output. Co-Authored-By: Claude Opus 4.8 * Make run_dir fallback list runs/ directly in all prompts When a node runs standalone with no run_dir in context, it must recover the newest run directory from disk. The prompts said only "fall back to the newest run directory", so the model listed the parent (examples/autoresearch-survey), where fs hides runs/ because it is gitignored — the model never saw the run dirs and flailed into an invalid fs action. Spell out the lookup in all 19 prompts: list examples/autoresearch-survey/runs *directly* (its parent hides it), take the last timestamped entry, and surface the recovery as a risk. This matches the rule already documented in schema/handoff.md. Latent in the 16 chain nodes (full runs always carry run_dir, so the fallback never fired) and first surfaced by running survey_writer alone. Co-Authored-By: Claude Opus 4.8 * Drop survey_brief from the end-to-end pipeline The brief sat between the judge panel and the writer producing a second, overlapping summary. Wire judge_panel straight into survey_writer and let the writer read the spec, research map, and judge panel directly. survey_brief.rcm and its prompt are kept as a standalone unit for runs that want a compact audit summary instead of the full article; the prompt no longer assumes a writer runs after it. Co-Authored-By: Claude Opus 4.8 * Widen retrieval budgets so the pool is larger Runs produced too few papers and admitted as much in their own scope limitations. Roughly double the discovery budget end to end: scouts go to 4-6 queries at topK 10, semantic expansion to 5-8 queries at topK 10, citation seeds to 6-10, and the query plan requires more queries per type. rank_pool now deduplicates without pruning for size, so the wider pool survives into the research map. Co-Authored-By: Claude Opus 4.8 * Rewrite survey_writer to produce a real narrative survey The survey read as a classified list of retrieved papers, leaked internal object names (BenchmarkJudge, RelationGraph, etc.), and was assembled from the now-removed brief. Rewrite the writer prompt and survey schema around three demands: - Self-contained document: written for an external reader, with zero references to the machinery (no judge names, matrices, graphs, run_dir, or "pipeline"). State substance, not provenance. - Narrative, not catalog: an explicit research arc (early approaches, bottlenecks, why each wave followed, current frontier), each method family explained by why it exists and how it trades off against the others, and cross-family synthesis with reasoning. The reader should finish with a mental model of the field. - Read from spec, research map, and judge panel directly; write via the fs tool with action="write"; print the full survey to the user. Docs updated for the brief-free pipeline and the 08_survey.md artifact. Co-Authored-By: Claude Opus 4.8 * fix(accelerator): restore purpose injection dropped in Captain refactor Captain setup step 2 was a no-op that claimed the purpose was "already injected from outside", but nothing injected it after the Phase abstraction was inlined (49a0b69). Agents therefore received only system scaffolding (captain prompt + AGENTS.md + env) and no task, so they fell into ask-mode and merely greeted instead of executing. Restore the behavior of the deleted InjectPurpose phase: during setup, append the purpose as a User fragment (tag="purpose"), guarded against re-injection. This fixes the project-maintainer pipelines, whose per-step tasks live entirely in `purpose`. Examples that carry their task in the captain system prompt (autoresearch-survey) never relied on this channel and are unaffected. Add regression tests for purpose injection and the empty-purpose skip. * Remove deprecated research-assistant example files * feat(accelerator): add ContextFlux::Fold mode and context reordering for last/append/digest Introduce three changes to the context delivery pipeline that together enable cleaner, more predictable context structures when an accelerator receives upstream handoff content. Fold mode (ContextFlux::Fold) extracts the last assistant text from each upstream slot and stores it in fold_payload on the output State. The downstream accelerator then merges fold_payload into its purpose before Captain injects purpose_initial, so upstream information is folded directly into the task description. No upstream assistant fragments appear in the context — the LLM sees a single coherent user message. Context reordering (last/append/digest modes): on the first Halt after Captain setup, fire() moves non-scaffolding upstream fragments to after the env fragment and injects a purpose_b fragment at the end. Final order: scaffolding → upstream content → purpose_b Captain purpose tag changed from "purpose" to "purpose_initial" to avoid tag collision with purpose_b injected later. - crates/accelerator/src/flux.rs: add Fold variant to ContextFlux, extract last assistant text from each slot during apply() - crates/accelerator/src/state.rs: add fold_payload: String field - crates/accelerator/src/graph.rs: propagate fold_payload through context channel - crates/accelerator/src/accelerator.rs: fire() reordering logic on first Halt - crates/accelerator/src/policy/captain.rs: tag "purpose" → "purpose_initial" - crates/accelerator/tests/graph_names.rs: 5 new tests (fold extraction, single assistant, ignores system, multi-slot, reordering) - crates/accelerator/tests/captain.rs: update existing tests for purpose_initial Refs: #97 * Add 5 single-agent RCM examples: folder-concierge, repo-snapshot, paper-scout, cook-tonight, city-halfday Each example is a one-shot, single-.rcm workflow that collects context autonomously from the runtime purpose. folder-concierge and repo-snapshot use only built-in tools (fs/find/git/shell). paper-scout, cook-tonight, and city-halfday use AnySearch MCP + Fetch MCP. Structure per example: README.md, prompts/*.txt, outputs/.gitkeep * Fix compiler warnings: remove unused trace import, allow dead_code on arxiv deser structs * Refactor Captain setup: extract agent/instruction/purpose injection into reusable modules - agent.rs: ensure_agent_prompt — inject system prompt (tag=agent) - instruction.rs: ensure_instructions — inject AGENTS.md/CLAUDE.md/CONTEXT.md (tag=instruction) - purpose.rs: ensure_purpose — inject runtime --purpose (tag=purpose), fixing the bug where purpose was silently skipped * Redesign Captain as typed phase machine with Step{Emit,Ready} - Phase: Agent → Instruction → Purpose → Resources → Respond → Running - agent::prepare: normalizes tag=agent to unique context[0] - instruction::load: injects AGENTS.md/CLAUDE.md/CONTEXT.md once - purpose::append: append-only, User role, allows multiple instances - env::refresh: updates env timestamp before each Halt - resources::activate: activates model + all tools - Step{Emit,Ready} replaces Action::Done as within-helper signal - Removed unused first_call, setup_action, halt_after_env * Refine Captain: clean purpose, separate setup from respond - purpose::append stores purpose.text directly (no ## Purpose wrapper) - Captain::setup only handles Agent/Instruction/Purpose/Resources - Respond and Running are handled directly in decide(), not via setup - enter(phase) replaces set_phase for cleaner naming * Extract reusable moves into policy::moves submodule - agent/env/instruction/purpose/resources moved under policy::moves/ - Captaine references updated to moves::agent/instruction/purpose/resources/env - policy::moves created from scratch (new mod.rs) * Fix MCP HTTP connection reuse: disable idle pool Disables reqwest HTTP/2 idle connection pooling for MCP HTTP transport via pool_max_idle_per_host(0). Previous behaviour reused the compile-stage idle connection for tools/call, but the server-side (AnySearch via CloudFront) sends GoAway before the tool call arrives, resulting in a silent 120s timeout. This ensures each MCP HTTP request opens a fresh connection, avoiding the stale-connection problem entirely. * Strip rig meta-crate dependency: use rig-core directly instead - Replace rig meta-crate (16 unused sub-crates) with rig-core + minimal features - Upgrade accelerator's reqwest 0.12 → 0.13 to eliminate dual-version build - Cargo.lock shrinks from 7323 to 914 lines (~87% reduction in resolved deps) * Remove fetch MCP from paper-scout, cook-tonight, city-halfday - Deleted mcp fetch block and mcps reference from all three .rcm files - Updated prompts to reference AnySearch only - Updated READMEs to remove Fetch MCP setup and description * Fix phase order and context display semantics - Insert Environment phase between Instruction and Purpose, ensuring system env fragment is injected before the user purpose fragment. Result: [system][system][system] then [user] then [assistant/tool_call]. - Update text.rs print_fragment to use content-type labels instead of role labels: ToolCall → [tool_call], ToolResult → [tool_result]. Context output now reads [tool_call] → arxiv_search {...} and [tool_result] (45 lines) instead of [assistant] and [tool]. * Add built-in webfetch tool: fetch URL → readable text via scraper - Single dependency: scraper 0.27 (html5ever-based HTML parser) - Fetches URL via existing reqwest client - Extracts readable text via scraper::Html text nodes (strips HTML chrome) - Extracts and prepends as heading - Respects max_length parameter (default 5K, max 100K) - 1MB body limit, 30s timeout - Graceful fallback for non-HTML content types * Move webfetch tests from inline to tests/webfetch.rs * Add dependency hygiene tooling and update development guide - Install cargo-nextest, cargo-machete, cargo-deny - Add deny.toml with license allowlist, advisory policy, banned TLS stacks - Remove unused tracing-subscriber from accelerator (library crate rule) - Add publish = false to all workspace crates - Add version fields to internal path dependencies - Fix nextest process-per-test isolation issue in compile.rs temp file naming - Document known machete false positives (prost, tonic-build) via metadata - Update AGENTS.md: add Dependency Hygiene section, add nextest rule, remove old auto-commit constraint in favor of atomic commits * Upgrade webfetch: browser-grade headers, rate limiting, HTTP/2 + Brotli - Browser-like User-Agent (Chrome 131 on macOS) - Full browser header set: Accept, Accept-Language, Accept-Encoding (br/gzip/deflate), Cache-Control, Sec-Fetch-{Dest,Mode,Site,User}, Upgrade-Insecure-Requests - Domain-based rate limiting (≥2s between requests to same host) - reqwest features: http2, brotli, gzip, deflate, cookies - Static LazyLock client built once with all default headers * fix: resolve clippy warnings across workspace - captain.rs: remove never_loop (all branches return), collapse nested if-lets - flux.rs: replace needless_range_loop with enums, collapse nested if-lets - All other warnings (result_large_err, option_map_unit_fn, type_complexity, collapsible_if, useless_format, unnecessary_filter_map) are pre-existing in the codebase and not addressed here clippy --workspace --all-targets: 0 errors * Fix clippy warnings, add rustfmt.toml, upgrade CI to nextest + deny + machete - Fix clippy error: remove dead 'loop' wrapper in captain.rs decide() - Add #[allow] annotations for intentionally accepted lints: result_large_err (tonic::Status, Fragment as error type), type_complexity (boxed future type), large_enum_variant (AcceleratorBody) - Add impl Default for MachineManager - Add rustfmt.toml with project formatting conventions - Set clippy -- -D warnings in CI - Switch CI test step to cargo-nextest + cargo test --doc (doctests) - Add CI dependency hygiene job: cargo-machete + cargo-deny * Add process-safe test rule and clippy allow discipline to AGENTS.md * Audit-driven rewrite: SSRF protection, streaming, safe truncation, script/style filtering - SSRF: validate_url rejects non-http/https schemes, private IP ranges (10.x, 127.x, 192.168.x, 169.254.x, etc.), loopback hostnames, and .local/.internal TLDs. Redirect policy re-validates every redirect target. - Streaming body read: bytes_stream() checks MAX_BYTES incrementally instead of buffering the entire response before checking size. - Safe Unicode truncation: char_indices() avoids byte-split panics on non-ASCII pages. - Script/style filtering: recursive ElementRef walk skips script/style/noscript/ iframe subtrees. Block elements (p, h1-h6, li, pre, etc.) insert newlines for readable paragraph structure. - Mutex: std::sync::Mutex replaced with tokio::sync::Mutex to avoid blocking async runtime. - Rate limiter eviction: map trimmed when it exceeds 1000 entries (oldest >60s entries removed). - Title selector static: LazyLock avoids unwrap() on every call. - Description corrected: no longer claims to strip navigation/sidebars. * Add general-purpose example with webfetch, arxiv, AnySearch - general.rcm with tools: fs, find, git, shell, webfetch, arxiv_search - MCP: AnySearch for web search - Prompt: one-shot, no follow-up questions, writes to outputs/ - README with run examples for different scenarios * Remove tool enumeration from general prompt — tools auto-activate * Simplify general prompt: remove output path and summary requirement * Reject known binary content types before downloading body - Added is_binary() check for image/, audio/, video/, application/pdf, application/zip, application/gzip, application/x-tar, etc. - Returns structured result 'binary content (image/png), not readable as text' instead of dumping raw binary bytes into model context * Fix clippy warnings: collapsible-if in webfetch, dead code in graph_names * Drive survey topic from purpose instead of an env var The anchor node read the topic from the AUTORESEARCH_TOPIC environment variable. Now that purpose flows through the graph as a first-class channel, the topic is just the graph's purpose. - Wire input.purpose -> anchor.purpose so the CLI --purpose flag (or the graph's declared purpose) reaches the entry node, which the captain injects into context as a purpose-tagged message. - anchor.rcm declares a default topic purpose; --purpose overrides it. - Rewrite the anchor prompt to take the topic from the injected purpose, falling back to topic.md only when no purpose is present. Drop the "do not rely on the RCM purpose field" instruction and the env lookup. - Remove AUTORESEARCH_TOPIC from rcm.toml; document --purpose in README. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add image_gen built-in tool (text-to-image via OpenAI images API) Generates an image from a prompt and writes it to disk, returning the saved path. Calls the OpenAI images API (gpt-image-2) using OPENAI_API_KEY from the environment, decodes the b64 result, and writes a PNG under the given filePath (typically inside run_dir). Modeled on the webfetch and arxiv_download tools; registered as a built-in so any .rcm node can grant it. Enables pipelines to produce a generated figure as a real artifact — e.g. a survey's opening global picture — rather than inline data. * Add image_planner node for the survey's opening global picture Insert image_planner between judge_panel and survey_writer: judge_panel -> image_planner -> survey_writer -> output. image_planner reads the research map and judge panel, composes a self-contained "research landscape" image prompt, and calls the image_gen tool to render run_dir/08_global_picture.png. It is granted only fs and image_gen. If image_gen fails (e.g. no OPENAI_API_KEY) it reports blocked so the run still completes. survey_writer now opens the article with that figure when present — embedded right after the abstract with an orienting sentence — and omits it cleanly when absent (no broken link). Schema and docs updated; README notes OPENAI_API_KEY is optional and the figure is skipped without it. * Drop the topic.md fallback; topic comes only from purpose The anchor node had three topic sources: the injected purpose, a topic.md file, and (earlier) an env var. With the topic now driven by the graph's purpose — set via --purpose or anchor.rcm's default — the topic.md layer is redundant. Remove the topic.md fallback from the anchor prompt, the README input section, and .gitignore, and delete the unused topic.md.example. The input.purpose -> anchor.purpose wire is kept: it is the only path for a top-level --purpose to reach the entry node in this composite graph. * Stop instruction search at the nearest AGENTS.md; add example guide The captain's instruction loader walked from cwd to the filesystem root and collected every AGENTS.md / CLAUDE.md / CONTEXT.md it found. Running the survey from the repo root injected the host repo's development guide (Rust conventions, git workflow, test rules) into every survey node — irrelevant context that pollutes a literature-research task. Stop at the nearest directory that has instruction files: a local guide overrides ancestors instead of stacking on top of them. Add an AGENTS.md under examples/autoresearch-survey so the survey nodes get research-task guidance and no longer inherit the repo's dev guide. Run the survey with cwd at examples/autoresearch-survey to pick up the local guide; user-global config (~/.synergy, ~/.claude) is unaffected. * Add fragment tag and action hook events for richer tape animation - Emit tag field on appended/inserted/replaced/taken hook events - Emit model/activate/deactivate hook events (were silent before) - Add after field to inserted event for insert-position tracking * Redesign tape animation: tag-aware colors, long-jump pointer, fixed-viewport layout - Map cell colors from kind + tag + role with dedicated scaffolding/purpose hues - Long-distance pointer jumps in at most 6 frames instead of per-cell walking - Insert/swap/remove/replace ops resolve by fragment id (survives intermediate inserts) - Fixed viewport with overflow hidden tape count instead of dynamic row expansion - Expose snapshot_events() for mock testing without a real terminal - Add cell tone and glyph to snapshot for assertion coverage * Use example-relative paths in prompts; run from the example dir Running from the example directory (to pick up the local AGENTS.md) broke every fs path: the prompts hardcoded repo-root paths like examples/autoresearch-survey/schema/X, which fs resolved against the new cwd as examples/autoresearch-survey/examples/autoresearch-survey/schema/X — not found. Each node had to fail, list ., and guess the real path. Make all prompt paths relative to the example directory: schema/X, runs/<ts>, etc. The .rcm `file "../prompts/..."` refs are compile-time and unaffected. handoff.md's run_dir example and the README run commands now use the example dir as cwd. Also drop the now-dead topic.md ignore. Run with `cd examples/autoresearch-survey` first. * Add a Chinese edition of the survey Add survey_writer_zh after survey_writer: it reads the finished English 08_survey.md and writes a faithful Chinese translation to 08_survey.zh.md. It is a downstream leaf — the graph's printed output stays the English survey; the Chinese edition lands on disk alongside it. The translator preserves structure, citations, identifiers, and the global-picture image link; it translates prose and headings into academic Chinese without re-researching or adding claims. Tools are fs-only. Wiring: survey_writer.context -> survey_handoff -> survey_writer_zh, triggered by survey_writer.done. Docs and the index manifest updated. * Carry run_dir in the handoff so the global picture lands in run_dir End-to-end runs generated the global picture but wrote it to a nested path (examples/autoresearch-survey/examples/autoresearch-survey/runs/.../ 08_global_picture.png), so survey_writer never found it and the survey had no figure. Root cause: nodes pass context with flux mode=last, which forwards only the last fragment. When a node's last message is a tool receipt or text without run_dir, the next node gets no run_dir and falls back to scanning runs/ — every node was falling back. image_planner then built the image path from a re-prefixed run_dir and wrote outside the run. Fix the contract so run_dir actually flows: handoff.md now requires the handoff to be the node's LAST message (after all writes, no trailing tool call), with run_dir as the first line, verbatim and un-prefixed. image_planner is told to write exactly <run_dir>/08_global_picture.png without re-prefixing. image_gen and the apicz endpoint themselves work — the generated file is a valid 1536x1024 PNG. * Point image_gen at the apicz endpoint; tidy formatting Switch the image generation endpoint to the apicz gateway used for end-to-end testing, drop the repo-root example from the filePath description (it invited the nested-path mistake; use a relative runs/<ts>/... example and say not to add prefixes), and apply rustfmt. The endpoint and model stay hard-coded for now; making the provider configurable remains follow-up work. * Tape animation: in-place scaffolding flash, wider tag palette, per-action verbs Three refinements on top of the tape redesign: - Scaffolding (env/agent/instruction/purpose) re-emits now flash in place via a new TapeOp::Flash that does not move the main pointer. The per-round env timestamp tick no longer drags the pointer back across long tapes. Classification is by CellKind::is_scaffolding(). - tag_color palette widened 8 -> 16 hues so distinct tags read as distinct, stable colors; non-special tags remain hash-mapped (Tagged). - Each action has its own intermediate state/verb: Take settles through a CellState::Taking "intake" (distinct from a plain append), Flash uses CellState::Flashing "refresh", and model/activate/deactivate surface a transient status-row badge (held BADGE_TICKS ticks). Swap keeps its ◆ hold. Adds a sticky `last_action` to TapeState/TapeSnapshotTape so the transient verbs are observable to tests after the animation drains. 4 new snapshot tests cover flash-without-pointer-move, distinct tag tones, intake, and the resource-action verb. All terminal-free via snapshot_events. * Tape animation polish: slower flash, solid head trail, red for errors only Minor tweaks on the existing crawling-head model (deeper redesign deferred): - Scaffolding flash now breathes for FLASH_TICKS ticks instead of a single one-frame blink, so the per-round env refresh is readable in place. - The head fills its trail: any pending cell behind the pointer is committed as it moves, so a long jump no longer leaves a "filled/hollow/filled" patchwork of skipped cells. - Red is reserved for errors (hitch). Removal now fades in dim grey rather than reusing the error colour. Tests: scaffolding flash holds the pointer across many env refreshes (not just one); no hollow cell remains behind the head after a long run; hitch is the only error-toned cell. 9 tape tests pass; fmt + clippy clean. Note: the underlying ping-pong/lag is inherent to the single traveling head and is not fixed here by design choice — a fuller redesign (separate scaffolding strip, reality-first cells) remains the longer-term option. * fix(maintainer): post comments via quoted heredoc + verify they land The PR-review / issue-triage / mention pipelines posted comments with inline `gh ... -b "<body>"`. The shell tool runs `sh -c`, so any backtick / $ / quote in a review body — code identifiers in markdown, i.e. almost always — was interpreted by the shell, mangling the comment or executing embedded commands. The respond nodes only carry the shell tool, so they had no body-file fallback. - Templates (pr_review, issue_triage, mention_handler): post every comment and the code_change PR body via a quoted heredoc piped to `--body-file -`. The quoted delimiter disables all expansion, so backticks/$/quotes go verbatim. Proven at the sh level: a body with `cmd`, $(...), $HOME and quotes is sent literally with nothing executed. - Templates: after posting, re-read the latest comment to confirm it landed, retry once on failure, and report status: ok+comment_url or status: blocked — never a silent fake success. - maintainer.yml: snapshot the bot-comment count before the run and fail the job if pr_review / issue_triage finishes without it rising (model skipped the post, or a fork PR's read-only GITHUB_TOKEN returned 403). Turns the previous silent green no-op into a visible failure. * refactor(maintainer): split pr_review into meta/diff/intent nodes with budgeted diff The PR review was one analyze node fed the entire `gh pr diff`. digest doesn't truncate tool results, so a large diff overflowed the model context, and a single node conflated "is the code sound?" with "does the PR meet its stated goal?". Restructure pr_review.rcm.tpl into a finer graph: meta -> diff_review -> intent_check -> respond - meta: pull PR metadata + per-file size inventory (the diff budget input). - diff_review (shell+fs): pull the diff only within a ~1500-line budget; over budget, fetch only the riskiest files' patches and read any file on demand via fs to ground each hunk. The diff no longer has to fit in context — the node retrieves detail agentically instead of being firehosed the whole thing. - intent_check (shell+fs): judge whether the PR's stated goal holds and is actually achieved; flag scope drift and missing companion changes. - respond: fold both into one comment, posted via the quoted-heredoc + self-verify path from the previous commit. maintainer.yml: after rendering (templates come from base), check out the PR head for pull_request events so diff_review/intent_check read the PR's code. Files are only read, never built or executed, so PR code on disk is not run; the rendered .rcm in the gitignored .rcm-cache survives the switch. Internal/branch PRs only. * feat(accelerator): add a `map` fan-out node (runtime sub-graph over the existing GraphRun) The graph engine could only express a static DAG (flux modes Append/Last/Digest/ Thread/Fold) — no way to fan a node out over a runtime-sized list (per-paper or per-section work). A `map` is a single node whose children are added dynamically: at run time it scatters its input into k items and **builds an inner graph — k seeded worker clones → a k-arity merge Flux → output — then runs it with the ordinary `GraphRun`**. Externally it is one node; internally it is `input → [(B1..Bk) → merge] → output`. This reuses the whole graph layer — `Graph`, `GraphRun` (incl. its per-frontier concurrency, so the k workers run in parallel for free), the `Flux` merge, wiring, validation — instead of a bespoke executor. Engine (crates/accelerator): - AcceleratorBody::Map(MapAccelerator { inner, scatter, gather }); run() builds the inner graph and calls Graph::run_seeded. - Graph::run_seeded(input, seeds): seed each dynamically-added worker's input by component id, then run via GraphRun (boundary wiring still honored). - ScatterSpec::Json: parse the input's last assistant message as a JSON array; each element becomes one worker, injected as an `item`-tagged fragment on a clone of the shared context. Non-array/empty → one worker (never hard-fails). - GatherSpec::Digest maps to a k-arity ContextFlux::Digest merge. - map_start / map_done hooks; per-worker progress shows via the inner graph's own component hooks. DSL (crates/cli/src/rcm): `map NAME { accelerator = Alias; scatter = json; gather = digest }` compiles to an accelerator-kind component, wired like any node. Tests: scatter units; a model-free runtime fan-out test (3 elements → a 3-worker inner graph → merge) via a Done policy; parser tests; and an examples/map-smoke demo whose stream shows the nested inner graph. * feat(autoresearch-survey): full-text, fan-out pipeline via the map primitive Rebuild the writing half of the survey pipeline so depth comes from reading full text (not abstracts) and from per-section expansion, using the new `map` fan-out primitive twice. New stages (rank_pool → … unchanged up to here): - card_plan: pick up to ~30 papers worth reading in full (prefers abstract-only core/method papers), emit a JSON work list. - paper_cards (map): per paper, download the PDF, read the full text *through the research anchor's lens*, and write a compact card to run_dir/cards/<id>.md — problem/method/results-with-caveats, anchor-relevance, and a cross-domain transfer note. Replaces abstract-only paper notes. - research_map now builds from those full-text cards. - survey_outline (was survey_writer): design the macro skeleton + an explicit through-line, emit a JSON section list. - section_expand (map): per section, write a detailed, card-grounded section to run_dir/sections/<n>_<slug>.md. - survey_assembler: stitch the sections, embed the figure, and build one deduped reference list → 08_survey.md + index.md. survey_writer_zh translates as before. Each map's upstream emits a JSON array (one element per item, carrying run_dir); scatter=json fans the inner accelerator out per element, gather=digest rolls the per-item handoffs up. Per-item artifacts live on disk, so no node holds the whole corpus or the whole survey. Adds schema/paper_card.md and schema/section.md; new prompts + rcm nodes; rewires autoresearch_survey.rcm; updates README/CONTEXT_FLOW and the example-compile test. survey_writer.{rcm,txt} are superseded by outline/expand/assembler. Requires the `map` engine primitive (separate PR) — and a new accelerate release before the example runs end-to-end off the published binary. * feat(autoresearch-survey): cross-domain transfer as a first-class lane Cross-domain transfer used to live only in the per-paper card's `transfer` note — an afterthought over papers that happened to be in-pool. Move it to retrieval time and carry it through the pipeline as a distinct lane (option B). Expansion gains a third parallel branch, `cross_domain_expander`: it abstracts the methods we already found into domain-agnostic patterns, searches OTHER fields for work that shares those patterns (neighbouring fields first), filters to genuine cross-domain candidates with a transfer hypothesis, and writes 03d_cross_domain.md with role: cross_domain. expansion_merge becomes arity 4. The role is carried through so it actually surfaces instead of being washed out by scope discipline: - rank_pool: a separate `transfer_set` ranked by `transfer_potential`; low topic_fit is expected and never a reason to drop, never mixed into in-domain sets. - scope_judge: treats the transfer lane as intentional inspiration, not leakage — ideas may be discussed as hypotheses, but a cross-domain result is never reported as in-field. - research_map: a `cross_domain_transfer` section (pattern + transfer hypothesis). - survey_outline: reserves a dedicated cross-domain section when the lane is non-empty. - card_plan: also reads a few transfer candidates in full, so the transfer hypothesis is grounded in full text, not abstracts. Schemas (expansion/ranked_pool/research_map), prompts, the expansion graph, and the README/CONTEXT_FLOW docs updated accordingly. Compiles (example-compile test + full-pipeline compile-check). * fix(autoresearch-survey): card-plan budget in schema (100) + assembler index timing - Add schema/card_plan.md defining the contract, with the budget as a visible constant max_fulltext_papers = 100 (raised from the prompt-buried 30). card_plan now reads the schema and references the constant instead of a hardcoded number, so the cap lives at the contract layer. - survey_assembler index.md no longer hard-links 08_survey.zh.md as if it exists: it runs before the downstream translator, so the manifest now lists the Chinese edition with a note that it is written by the downstream translation step and may not exist yet — no dead link presented as live. * add storage crate: import WAL engine from Axiom project Copy segment-based WAL with CRC32C integrity, sparse indexing, and crash-safe atomic writes into RCM as the foundation for optional session persistence. Rename crate from 'wal' to 'storage', register in workspace members. 93 tests passed, 0 skipped. * refactor(catalog): unified registration layer for tools, models, prompts, policies, environments, and MCP servers - Catalog fields are now private with typed register_*() APIs and duplicate name validation - tools/models/prompts/mcps all belong to ResourceCatalog; stale resources/ preset module removed - MCP server definition is decoupled from activation: register_mcp_server() only saves config, resources_for() starts selected MCPs on demand - .rcm and gRPC semantics unified: top-level definitions register into Catalog, accelerator selection picks by name only - Rust external extension: register_tool/register_policy/register_environment on Catalog, with_catalog() on RcmService, compile_file_with_catalog() - Proto OpenRequest split into model_definitions/mcp_definitions (define) and models/mcps (select) * cleanup(catalog): remove dead gRPC policy field, cache MCP tools, drop unstable external web tests - Remove OpenRequest.policy from proto and server — gRPC has no policy execution path; keep it in .rcm/CLI only - Rename Catalog::resources_for to build_runtime_resources to clarify the method starts MCP servers as a side effect - Cache selected MCP tools in Catalog via OnceCell so the same server is started at most once rather than per-accelerator - Make ResourceCatalog private (it had no public methods) - Rename ResourceSelection.prompts to prompt_texts and drop the implicit 'inject all registered prompts' behaviour — only explicitly selected prompts appear in Resources - Remove three unstable external-network tests (example.com, httpbin.org) that fail when offline * refactor(policy, sdk): extract Rust ReAct move, align Python SDK with definitions/selections proto, replace stale examples - Extract Captain's running-phase ReAct logic into policy/moves/react.rs; Captain now only handles setup phase and delegates running to react::decide() - Regenerate Python protobuf stubs from updated proto - Update RCMClient.open() to {model,mcp}_definitions + {models,mcps,tools} selection semantics, with keyword-only keyword arguments - Update Python ReactPolicy: support user/system→Halt, tool_result→Halt, hitch retry, idle→Done - Delete three stale examples (arxiv_research, weather, system_info) - Add echo_agent.py: minimal Python controller, prompt-only agent - Add math_mcp_server.py + mcp_math.py: local stdio MCP server with add/multiply tools, Python controller drives the loop - Update README and __init__.py for new SDK shape * refactor(machine, storage): serialize machine runtime, split ToolRuntime from Resources, implement Store with full action replay - Resources now holds only serializable state: ToolDefinition (name/desc/ params) instead of Arc<dyn Tool>, plus models/prompts/active_tools. All fields derive Serialize/Deserialize. - ToolRuntime added as separate non-serializable container for executable Arc<dyn Tool> instances, passed explicitly to Machine::apply(). - Action, Context, Inbox, Usage derive Serialize/Deserialize. - Machine::apply() returns ApplyResult { done, event } where event is a MachineEvent (action + outcome) that can be recorded to WAL. - Halt records its reactor output (fragments + usage) in the event, enabling deterministic replay without re-calling LLM/tool during restore. - storage::Store: high-level API on top of Wal that records/checkpoints/ restores MachineState (context + environment + resources + inbox + step + done). restore() loads latest checkpoint then replays subsequent events. - 5 new Store tests: empty restore, context actions, resource actions, halt output, checkpoint + incremental replay. - Remove unused SegmentReader::find and clean up dead with_tool/replace_tools methods. * cleanup(machine, storage): clarify tool definitions vs executors, record non-Halt inbox outcomes - Rename Resources.tools to tool_definitions so serializable tool schemas are not confused with executable tool handles - Rename ToolRuntime internal map to executors and remove unused with_tool() - Centralize Catalog's dual registration of ToolDefinition + ToolRuntime executor in add_tool_to_runtime_resources() - Replace ActionOutcome::StateOnly with ActionOutcome::State { inbox } so failed non-Halt actions can replay recorded inbox side effects - Remove unused MachineEvent::reactor(), Store state_from_parts(), inbox_from_fragments(), and Resources replace_* helpers - Add Store coverage for replaying failed-action hitch output Verification: cargo check && cargo nextest run (400 passed) * test(accelerator): adapt map tests to explicit ToolRuntime * fix(ci): group machine runtime args and satisfy clippy * feat(accelerator): fs read extracts PDF text via a safe pdftotext wrapper Previously `fs read` refused PDFs ("require external extraction"), so paper_card fell back to the raw `shell` tool running `pdftotext` — handing the model an arbitrary shell. Replace that with a safe, encapsulated path. - New tools/fs/pdf.rs: extract_pdf_text() invokes `pdftotext` with parameterized args via Command (never a shell, so the path is never interpreted), and as defense in depth rejects any path with a shell metacharacter, requires a .pdf extension, and bounds runtime (60s) and output (500 KB). - fs read now routes .pdf through that wrapper and returns the extracted text (offset/limit paginated like any text file); other document types still error. - paper_card.rcm drops `shell` (tools = arxiv_download, fs); paper_card.txt just does `fs read <pdf>` and falls back to abstract_only if extraction fails. The model no longer needs raw shell to read papers. Unit-tested the metacharacter guard; verified real extraction with poppler's pdftotext on a downloaded PDF. (reference_expander still uses shell for citation scraping — left for a separate change.) * refactor(autoresearch-survey): reference_expander reads PDFs via fs, not shell Migrate the second raw-shell PDF user to the safe path. reference_expander now reads each downloaded PDF with `fs read` (which extracts text via the safe pdftotext wrapper) and finds the References section in that text, instead of shelling out to `pdftotext`/`strings`. Drops `shell` from its tools (arxiv_download, arxiv_search, fs). Both PDF-reading nodes (paper_card, reference_expander) are now off raw shell. anchor / query_plan still use shell for run_dir/timestamp creation — a separate concern, left untouched. * feat(accelerator): add FluxMode::Bridge for cross-channel data transfer, remove fold_payload Replace ContextFlux::Fold (which used a side-channel fold_payload on State) with FluxMode::Bridge — a first-class cross-channel transfer mechanism that reads from one channel, transforms data, and writes to another. - Add BridgeKind::ContextLastTextToPurpose: extracts last assistant text from context slots and writes to purpose - Split channel() into input_channel() / output_channel() so Bridge can have different input and output channels - Delete State::fold_payload — no more side-channel - merge_input() now concatenates wired + base purpose when both are present - Update compile.rs, ast.rs, parser.rs for dual-channel ComponentTag and bridge syntax (channel=bridge, from=context, to=purpose, mode=last_text) - Rewrite 4 Fold tests as Bridge tests 180 tests pass, clippy clean. * refactor(accelerator): make Bridge purely mechanical, move extraction to upstream Flux BridgeKind::ContextToPurpose replaces ContextLastTextToPurpose. Bridge no longer applies any extraction/filtering logic — it purely flattens all text fragments from context into a purpose string. Upstream ContextFlux (Last/Append/Digest/Thread) controls what fragments reach the Bridge. RCM syntax: channel=bridge from=context to=purpose mode=flatten Tests now demonstrate composition: Flux(Last|Append|Digest) → Bridge rather than Bridge acting standalone. * chore(accelerator): remove redundant doc comments on flux variants and apply helpers Variant names are self-explanatory (Append, Last, Digest, Thread, Bridge). Helper functions are clear from their signatures. No behavioral changes. * fix(map): file-based scatter so fan-out no longer depends on a chat JSON message A full run fanned both maps out to a single item: the planners (card_plan, survey_outline) ended with a handoff, not a bare JSON array, and ContextFlux::Last forwards only the final fragment — so scatter saw no array and fell back to 1. Result: sections/ and cards/ each had one file and the survey was a stub. Models write files reliably (the outline was perfect) but don't reliably end with bare JSON in chat, so move the work list to disk. Engine: - ScatterSpec::File(name): recover run_dir from the incoming handoff, read <run_dir>/<name>, parse the JSON array; any failure falls back to one item. - ScatterSpec is now Clone (not Copy); scatter takes &ScatterSpec. - run_dir_from_context() parses the last `run_dir:` line from the handoff. - Tests: run_dir recovery, file-scatter fallbacks, and an end-to-end (no-model) test that a 3-element JSON file fans out to 3 workers. DSL: `map { scatter = file "<name>" }` (ast scatter_file + parser + compile + parse test). Pipeline: - card_plan writes run_dir/00_card_plan.json + handoff; paper_cards scatter=file. - survey_outline writes run_dir/00_sections.json + handoff; section_expand scatter=file. Schemas updated to the file contract. - survey_assembler: write 08_survey.md + index.md then STOP (it was looping on re-list/re-read); use fs list (not the nonexistent `stat`) for the figure check. Pipeline review: every node now takes run_dir via the handoff and reads its inputs from disk; the maps fan out via a disk file. No node depends on parsing structured data out of a chat message anymore. * refactor(machine,storage): eliminate apply_event dual-logic via single apply with replay mode Replace the separate apply_event implementation in Store with a single Machine::apply that supports both live execution and replay. This eliminates the dual state-transition logic that was already diverging (Take missing fragment_ids, Halt missing usages in replay). Key changes: - Move usages/counts from Machine into MachineState for lossless resume - Introduce ApplyContext struct (ctx, env, resources, inbox, usages, counts) - Introduce ApplyMode enum: Live { tool_runtime } | Replay { cached_outcome } - Machine::apply takes ApplyContext + ApplyMode instead of scattered params - Machine::apply_state wraps apply for convenient MachineState usage - Delete apply_event and recover_state_outcome from store.rs entirely - Store::restore now calls Machine::apply_state in ApplyMode::Replay - Store::restore is now async (calls Machine::apply which is async) - Add Action::is_done() helper for replay validation - Update all callers: accelerator, server, tests - Add tokio to storage dev-dependencies for async store tests - Add halt_output_replays_through_inbox_and_take test verifying usages survive * fix(ci): disable cancel-in-progress for maintainer workflow Long-running review runs (e.g. kimi-k2-6 diff_review taking 15+ minutes with 17 tool-call rounds) were being killed whenever a new push or comment landed on the same PR, because cancel-in-progress: true immediately terminates the in-flight run. Switching to false makes runs queue instead, so a review that already started always finishes. Refs: run 72341150628 on PR #106 * fix: cap frontier concurrency + per-model timeout (map fan-out + long zh) A 100-paper run exposed two issues a full run hit: 1. paper_cards fanned out ~100 workers in one frontier with no bound — each doing arxiv_download + full-text read + LLM. Under that load most workers never finished, so only 1 card was written (the survey fell back to abstracts). Fix: GraphRun runs each frontier in bounded waves (FRONTIER_CONCURRENCY = 6). Sequential pipelines and the small parallel scout/judge frontiers are under the cap and unaffected; only wide `map` fan-outs are throttled. Worker rendezvous is not a thing here (workers are independent), so no deadlock. 2. survey_writer_zh timed out (6× at the 180s default) translating the full ~650-line survey in one generation, then exhausted retries — no zh output. Fix: add a per-model `timeout` field to the model DSL (ast + parser + compile) and set the zh model to 600s. The field is general — any node can tune it. Verified: full accelerator + cli suites, fmt, clippy. Concurrency cap covered by the existing barrier-based graph tests (they rendezvous within the cap and still pass); timeout parse/compile covered by the example-compile test (zh uses it). * fix(cli): validate bridge from/to channels at compile time flux_mode_from_def accepted any from/to channel pair but always produced BridgeKind::ContextToPurpose, silently ignoring mismatched directions (e.g. from=purpose to=environment would compile but produce wrong output at runtime). Now only context→purpose is accepted; other combinations are rejected with a clear error message. * feat(autoresearch-survey): raise fan-out concurrency + framework-first zh translation - Raise FRONTIER_CONCURRENCY 6 -> 16: paper_cards (and any map) read in parallel with healthy width, each paper still its own worker (dynamic fan-out), without the 100-wide overload that starved workers. - Replace the single-shot zh translator with a framework-first, per-section chain (the map again): - zh_frame: translate title/abstract and build a glossary — a "keep in English" list (Transformer, ImageNet, Forward-Forward, BP, arXiv ids, code idents…) and agreed Chinese renderings for translatable terms. - zh_sections (map over 00_sections.json): translate each section in parallel, obeying the glossary so terminology doesn't drift, into sections_zh/<n>_<slug>.md. - zh_assemble: copy-stitch the Chinese sections into 08_survey.zh.md (no re-translation -> a fast, bounded step that won't time out). This fixes both the one-shot 180s timeout (sections are bounded) and the "over-Chinese" drift (shared glossary keeps proper nouns in English, reads more fluently). survey_writer_zh.rcm stays as a standalone single-shot unit. Docs + example-compile test updated; full suites / fmt / clippy green. * feat(autoresearch-survey): prefer arXiv HTML over pdftotext for paper cards pdftotext extracts text from PDFs with known issues: double-column text reordered, formulas lost, no section structure preserved. arXiv renders most papers as HTML (arxiv.org/html/<id>) which preserves headings, paragraphs, and tables — far more readable for LLM consumption. Changes: - paper_card.rcm: add webfetch to the tool list - paper_card.txt: try webfetch of the HTML version first (max_length 200K), fall back to arxiv_download + fs read (pdftotext) only when HTML is unavailable; add note about LaTeX residue in formulas - paper_card.md: add html evidence level, update rules to cover both HTML and PDF fallback paths * fix(accelerator): sanitize scatter_file path to prevent traversal scatter_file constructed the file path by naively joining cwd + run_dir + name without canonicalizing. Since run_dir comes from model-generated handoff text and name from .rcm config, a value like ../../etc could escape the working directory. Use resolve_path (shared with the fs tool) to normalize the path, then verify the result stays under cwd. On escape, fall back to a single item — consistent with all other scatter failure modes. * feat(tools): add SpawnTool — LLM-driven fan-out via tool calls Replace the implicit MapAccelerator (ScatterSpec/File/Gather) with a SpawnTool that planner LLMs call explicitly. This eliminates the engine-level fan-out infrastructure (MapAccelerator, scatter_file, run_dir recovery, ScatterSpec, GatherSpec, map DSL node) and replaces it with a clean Tool boundary — the fan-out list arrives as a structured tool parameter, workers run concurrently with bounded parallelism, and the LLM receives a summary it can use to retry failed items. Changes: - SpawnTool (crates/accelerator/src/tools/spawn.rs): new Tool that takes items[] + max_parallel, runs one worker accelerator per item, returns per-item outcome summary. Uses FuturesUnordered + Semaphore for bounded concurrency. - Accelerator::inject_tool(): inject a tool into a primitive accelerator's tool_runtime + tool_definitions. - DSL: add spawns = [...] field to PrimitiveDef (parser, AST, compiler). inject_spawns() creates a SpawnTool per alias. - DELETE MapAccelerator, ScatterSpec, GatherSpec, AcceleratorBody::Map, map_named(), scatter_file(), scatter_json(), run_dir_from_context(), items_from_elements(), merge_mode(), last_assistant_text(). - DELETE map DSL node (parser map_node(), ast MapDef/GraphDef.maps). - DELETE accelerator/tests/map.rs, cli/tests/map_dsl.rs. * refactor(autoresearch-survey): replace map nodes with spawn tools in pipeline CardPlan, SurveyOutline, and ZhFrame now use SpawnTool instead of the implicit MapAccelerator. Each planner calls spawn_PaperCard / spawn_SectionExpander / spawn_SectionTranslator as a tool, passes the work-list as a structured tool parameter, and receives a per-item outcome summary it can use to retry failed items. Changes: - autoresearch_survey.rcm: remove map blocks and all the flux/handoff wiring that existed solely for map context routing. Planners connect directly to downstream nodes. Remove 20 flux + 3 map entries and ~40 wire lines. - card_plan.rcm / survey_outline.rcm / zh_frame.rcm: standalone files no longer declare spawns (they're injected by the composite graph). - card_plan.txt: instruct to call spawn_PaperCard(items) instead of writing 00_card_plan.json. Add retry logic. - survey_outline.txt: instruct to call spawn_SectionExpander(items) instead of writing 00_sections.json. Add retry logic. - zh_frame.txt: instruct to call spawn_SectionTranslator(items) for per-section translation dispatch. Add retry logic. - compile.rs: inject_spawns on Import nodes (where imports exist). Silently skip unknown aliases (standalone compile has no imports). * fix(accelerator): box PrimitiveAccelerator to avoid large_enum_variant lint Rust 1.96.0 promotes clippy::large_enum_variant to deny-by-default. AcceleratorBody::Primitive (648 bytes) vs Composite (96 bytes) triggers it. Box PrimitiveAccelerator to match CI's clippy policy. * fix(spawn): review fixes — safe index tracking, env propagation, tests, clippy Address all review comments on PR #111: 1. Panic-safe index tracking: replaced tokio::spawn with bounded waves via join_all + chunks, capturing each worker's global index before spawning so failures always appear in the report. 2. extract_status: switched from substring contains() to exact line matching to prevent false positives (e.g. 'blocked' matching 'unblocked'). Added purpose fallback for halt-only workers. 3. env propagation: workers now inherit env (cwd, platform) from the parent, not State::default(). 4. inject_spawns: errors at compile time on unknown aliases instead of silently skipping. 5. 8 new SpawnTool tests (spawn.rs): empty, single, concurrency cap, failed, blocked, abstract_only, mixed, missing items. 6. Deleted legacy examples/map-smoke (uses removed map DSL). 7. Box PrimitiveAccelerator in AcceleratorBody to satisfy Rust 1.96 large_enum_variant lint. 8. Clippy clean, all tests pass. * perf(captain): skip env::refresh on respond path to preserve KV cache env::refresh generates a timestamp each call, causing Action::Replace on every tool→LLM transition. This breaks prefix-based KV cache on every round. respond() now directly halts without refreshing env — the env fragment is already set during setup(). * refactor(machine): unify state, replace ActionOutcome replay with Effect-based events - Replace flat MachineState with RunState + MachineFrame composition - Add TokenUsage, CompletionRecord, Telemetry; remove Usage.fragment_ids - Inbox stores InboxItem{ fragment, source_completion } for attribution - Policy::decide accepts PolicyView instead of 5 individual parameters - StoredEvent + Effect replace MachineEvent/ActionOutcome/ActionOutcome - Context supports append_with_id / insert_with_id for deterministic replay * refactor(accelerator): replace accelerator::State with machine::RunState, delete old state module - Remove accelerator::State, all callers use machine::RunState directly - Condition, Flux, Condition predicates access RunState fields directly - Accelerator::{merge_input,fire,run_with} consume RunState instead of State - Policy test helpers migrate from State to RunState constructor * refactor(server,storage,cli): adapt to new machine state and event types - server Run wraps MachineState directly; Run.ctx/env/usages/counts removed - server build_state reads RunState + MachineFrame fields - server apply uses Machine::apply with MachineState + ExecutionMode - storage restore replays Effect[] instead of re-interpreting Action - cli compile/build_state returns RunState instead of accelerator::State - cli run uses machine::RunState/Purpose to construct input state * chore: remove captain integration test that requires real LLM * chore: remove unused imports, migrate spawn tests from State to RunState - accelerator.rs: drop unused Channel/Endpoint/Port imports - spawn.rs: replace accelerator::State with machine::RunState - spawn tests: migrate State constructor to RunState, update Policy impl to PolicyView * fix(storage): suppress hook emission during replay * fix(spawn): count partial as completed, parse status leniently SpawnTool::extract_status matched only an exact `status: ok` line and treated everything else as a failure — including the schema's valid `status: partial` and any case/marker/annotation drift in the handoff. Successful workers were then reported as failed and needlessly retried. Classify the status case-insensitively, tolerate leading list markers and a trailing note (take the first word after `status:`), recognize `partial`, and count ok+partial as completed. Only blocked/failed/unknown are surfaced for retry. Adds tests for partial and annotated-status lines. * fix(cli): attribute sub-graph events to the innermost component The hook tracing layer derived component identity from the span stack with `scope.from_root()` (outer→inner) + `merge_missing` (keep-first), so the OUTERMOST span won. For nested spans — a sub-graph node running inside a composite (e.g. the discovery scouts inside the `discovery` composite) — every completion/tool/fragment event was attributed to the parent composite, not the child. Child tapes rendered with 0 cells while the parent showed the lumped total, so N parallel sub-nodes looked like one sequential node (and the graph looked stuck). Walk innermost→root so the nearest component identity wins. Component start/done events were already correct (explicit fields), so only the per-step cell attribution changes. * new file: MASA_Survey_2026-06-03.html new file: MASA_Survey_2026-06-03.pdf new file: build_log.txt new file: check_log.txt new file: core_log.txt new file: core_multi_round_log.txt new file: core_round2_log.txt new file: core_round3_log.txt modified: crates/accelerator/src/tools/shell.rs new file: examples/MASA v2.0/README.md new file: examples/MASA v2.0/changelog/000_architecture_overview.md new file: examples/MASA v2.0/export_pdf.js new file: examples/MASA v2.0/prompts/core/generator.txt new file: examples/MASA v2.0/prompts/core/judge_accuracy.txt new file: examples/MASA v2.0/prompts/core/judge_content.txt new file: examples/MASA v2.0/prompts/core/judge_depth.txt new file: examples/MASA v2.0/prompts/core/judge_synthesizer.txt new file: examples/MASA v2.0/prompts/core/researcher.txt new file: examples/MASA v2.0/prompts/core/supervisor.txt new file: examples/MASA v2.0/prompts/finish/polisher.txt new file: examples/MASA v2.0/prompts/phase0/anchor.txt new file: examples/MASA v2.0/prompts/phase0/benchmark_scout.txt new file: examples/MASA v2.0/prompts/phase0/citation_seed_selector.txt new file: examples/MASA v2.0/prompts/phase0/discovery_merger.txt new file: examples/MASA v2.0/prompts/phase0/evolution_mapper.txt new file: examples/MASA v2.0/prompts/phase0/expansion_merger.txt new file: examples/MASA v2.0/prompts/phase0/extended_benchmark_scout.txt new file: examples/MASA v2.0/prompts/phase0/extended_discovery_merger.txt new file: examples/MASA v2.0/prompts/phase0/extended_frontier_scout.txt new file: examples/MASA v2.0/prompts/phase0/extended_method_scout.txt new file: examples/MASA v2.0/prompts/phase0/extended_query_plan.txt new file: examples/MASA v2.0/prompts/phase0/extended_survey_scout.txt new file: examples/MASA v2.0/prompts/phase0/frontier_scout.txt new file: examples/MASA v2.0/prompts/phase0/method_scout.txt new file: examples/MASA v2.0/prompts/phase0/paper_fetcher.txt new file: examples/MASA v2.0/prompts/phase0/paper_profiler.txt new file: examples/MASA v2.0/prompts/phase0/query_plan.txt new file: examples/MASA v2.0/prompts/phase0/reference_expander.txt new file: examples/MASA v2.0/prompts/phase0/semantic_expander.txt new file: examples/MASA v2.0/prompts/phase0/survey_scout.txt new file: examples/MASA v2.0/prompts/phase0/taxonomy_builder.txt new file: examples/MASA v2.0/rcm.toml new file: examples/MASA v2.0/rcm/core/generator.rcm new file: examples/MASA v2.0/rcm/core/judge_accuracy.rcm new file: examples/MASA v2.0/rcm/core/judge_content.rcm new file: examples/MASA v2.0/rcm/core/judge_depth.rcm new file: examples/MASA v2.0/rcm/core/judge_synthesizer.rcm new file: examples/MASA v2.0/rcm/core/researcher.rcm new file: examples/MASA v2.0/rcm/core/supervisor.rcm new file: examples/MASA v2.0/rcm/finish/polisher.rcm new file: examples/MASA v2.0/rcm/masa_core.rcm new file: examples/MASA v2.0/rcm/masa_finish.rcm new file: examples/MASA v2.0/rcm/masa_phase0.rcm new file: examples/MASA v2.0/rcm/phase0/anchor.rcm new file: examples/MASA v2.0/rcm/phase0/discovery.rcm new file: examples/MASA v2.0/rcm/phase0/evolution_mapper.rcm new file: examples/MASA v2.0/rcm/phase0/expansion.rcm new file: examples/MASA v2.0/rcm/phase0/extended_discovery.rcm new file: examples/MASA v2.0/rcm/phase0/extended_query_plan.rcm new file: examples/MASA v2.0/rcm/phase0/paper_fetcher.rcm new file: examples/MASA v2.0/rcm/phase0/paper_profiler.rcm new file: examples/MASA v2.0/rcm/phase0/query_plan.rcm new file: examples/MASA v2.0/rcm/phase0/taxonomy_builder.rcm new file: examples/MASA v2.0/run_masa.ps1 new file: examples/MASA v2.0/runs/2026-06-03T1208Z/05_draft.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/06_review.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/07_survey.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/handoff.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/memory/agent_generator.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/memory/agent_polisher.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/memory/agent_researcher.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/memory/iteration_state.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/memory/outline.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/memory/outline_history.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/memory/section_summaries.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/memory/supervisor_notes.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/phase0/02_candidate_pool.md new file: examples/MASA v2.0/runs/2026-06-03T1208Z/phase0/03a_seed_papers.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/02a_handoff.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/05_draft.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/06_review.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/07_survey.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/_merge_extended.py new file: examples/MASA v2.0/runs/2026-06-06T1233Z/_run_merge.bat new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/agent_generator.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/agent_polisher.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/agent_researcher.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/iteration_state.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_citation_balance.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_citation_relevance.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_coverage.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_factual_consistency.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_redundancy.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_report_round2.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_report_round3.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_report_round4.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_section_balance.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/judge_suggestions.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/outline.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/outline_history.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/section_summaries.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/memory/supervisor_notes.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2305.14251.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2305.15186.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2310.11511.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2401.10917.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2402.01788.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2406.10252.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2407.01796.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2503.04629.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2509.18661.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/pdfs/2510.03120.pdf new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/00_survey_spec.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/01_query_plan.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/01b_query_plan_extended.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/01b_query_plan_extended_analysis.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02_candidate_pool.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02a_method_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02b_benchmark_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02b_candidate_pool_extended.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02c_survey_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02d_frontier_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02e_extended_method_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02f_extended_benchmark_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02g_extended_survey_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/02h_extended_frontier_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/03_expansion.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/03a_seed_papers.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/03b_citation_expansion.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/phase0/03c_semantic_expansion.md new file: examples/MASA v2.0/runs/2026-06-06T1233Z/schema new file: examples/MASA v2.0/runs/2026-06-06T1406Z/05_draft.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/06_review.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/07_survey.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/07_survey.pdf new file: examples/MASA v2.0/runs/2026-06-06T1406Z/handoff_extended_benchmark.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/handoff_extended_merger.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/handoff_frontier.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/handoff_seed.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/agent_generator.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/agent_polisher.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/agent_researcher.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/iteration_state.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_citation_balance.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_citation_relevance.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_coverage.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_factual_consistency.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_redundancy.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_report_round1.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_report_round2.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_report_round3.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_section_balance.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/judge_suggestions.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/outline.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/outline_history.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/section_summaries.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/memory/supervisor_notes.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/pdfs/.gitkeep new file: examples/MASA v2.0/runs/2026-06-06T1406Z/pdfs/2312.07559v2.pdf new file: examples/MASA v2.0/runs/2026-06-06T1406Z/pdfs/2406.10252v2.pdf new file: examples/MASA v2.0/runs/2026-06-06T1406Z/pdfs/2409.12177v1.pdf new file: examples/MASA v2.0/runs/2026-06-06T1406Z/pdfs/2411.14199v1.pdf new file: examples/MASA v2.0/runs/2026-06-06T1406Z/pdfs/2503.04629v1.pdf new file: examples/MASA v2.0/runs/2026-06-06T1406Z/pdfs/2509.18661v1.pdf new file: examples/MASA v2.0/runs/2026-06-06T1406Z/pdfs/2510.03120.pdf new file: examples/MASA v2.0/runs/2026-06-06T1406Z/pdfs/2510.07733.pdf new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/00_survey_spec.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/01_query_plan.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/01b_query_plan_extended.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/01b_query_plan_extended_analysis.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02_candidate_pool.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02a_method_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02b_benchmark_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02b_candidate_pool_extended.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02c_survey_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02d_frontier_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02e_extended_method_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02f_extended_benchmark_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02g_extended_survey_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/02h_extended_frontier_candidates.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/03_expansion.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/03a_seed_papers.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/03b_citation_expansion.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/phase0/03c_semantic_expansion.md new file: examples/MASA v2.0/runs/2026-06-06T1406Z/schema new file: examples/MASA v2.0/schema/candidate_pool.md new file: examples/MASA v2.0/schema/expansion.md new file: examples/MASA v2.0/schema/handoff.md new file: examples/MASA v2.0/schema/query_plan.md new file: examples/MASA v2.0/schema/survey_spec.md new file: examples/MASA v2.0/topic.md modified: examples/autoresearch-survey/prompts/reference_expander.txt modified: examples/autoresearch-survey/rcm/expansion.rcm new file: finish_log.txt new file: masa_test_log.txt new file: phase0_log.txt * fix(ci): raise timeout to 30min, remove unreliable comment-count check timeout-minutes: 15 was too short for kimi-k2-6 diff_review (was hitting timeout mid-execution). Raised to 30. The post-run comment-count verification was unreliable — a run that rebases or closes a PR can legitimately have no new bot comment, and the count-based heuristic produced false negatives. Removed both the pre-run snapshot (precount) and the post-run verify step. * fix(ci): restore cancel-in-progress true with 30min timeout cancel-in-progress: true ensures review always runs against the latest code. The 30min timeout gives kimi-k2-6 enough room; previously the combination of 15min timeout + cancel-in-progress was the issue. Also refresh comment to reflect the current intent. * MASA 2.0架构 * MASA2.0架构 * benchmark evaluations * phase0 upgrade * MASA 2.1 upgrade * feat: autoresearch demo * feat: add survey evaluation benchmarks framework Implementation of three paper-aligned benchmarks: - SurveyBench (arXiv:2510.03120): content + quiz evaluation - DeepSurvey-Bench (arXiv:2601.15307): surface + academic quality - DeepScholar-Bench (arXiv:2508.20033): knowledge/retrieval/verifiability Co-authored-by: shiloh <shilohsols@outlook.com> * feat:error remove * remove runs * remove apikeys * feat:upgrade existing errors * feat:update error round 2 * feat:upgrade error round 3 * revert: remove api_model field from 5 Rust files Suggestion from code review: provider-internal model names should be handled at the gateway layer, not pushed into config syntax. The 5-file patch (ast/parser/compile/model/completion) is removed from this PR. * refactor: redesign survey benchmark framework with official protocol alignment Replace the toy survey-benchmarks with a professional benchmark framework under benchmarks/survey/. ## Why this refactor PR #116 introduced a survey evaluation toolkit that was well-intentioned but had critical issues: all LLM-based evaluators fell back to mock mode (missing accelerator binary), quiz evaluation crashed on predefined questions, the DeepScholar geometric mean formula was mathematically wrong, and there were no official benchmark datasets or topic sources. ## What changed ### Directory structure - Moved survey-benchmarks/ → benchmarks/survey/ to allow future benchmarks - Deleted PowerShell scripts (Windows-only, not aligned with project) - Deleted dead .rcm configs (never connected to any pipeline) - Clean separation: evaluators/ configs/ prompts/ schemas/ benchmarks/ ### Core improvements - LLM judge: direct OpenAI-compatible API calls (no accelerator dependency) - Retry with exponential backoff, mock fallback with clear warnings - SurveyBench: content eval (6-dimension rubric) + quiz eval (BM25 retrieval, predefined quiz support fixes critical TypeError crash) - DeepSurvey-Bench: surface (40%) + academic (60%) LLM judge - DeepScholar-Bench: all 6 of 7 metrics implemented (RQ1 documented as future work), geometric mean overall score per arXiv:2508.20033v2 ### Broken heuristics → real data - Nugget matching: word-overlap → LLM-based semantic matching - Document importance: keyword heuristics → Semantic Scholar API citation counts - Citation verification: LLM hallucination → Semantic Scholar abstract lookup with explicit no-hallucination guard - Quiz passage retrieval: keyword → BM25 (with keyword-overlap fallback) ### Official benchmark integration - SurveyBench: git submodule support for OpenDataBox/SurveyBench (20 topics) - DeepScholar-Bench: HuggingFace datasets download (63 papers, 1630 citations) - Pipeline configs: masav2.toml, autoresearch.toml, custom.toml template ### Unified CLI (run.py) - 5 commands: list, setup, generate, evaluate, report - Batch support with concurrent.futures for generation and evaluation - Structured reports with per-topic scores and comparison tables ### Fixes applied (from PR #116 review) - Prompt encoding: 脳→×, 鈥→— fully resolved - DeepScholar geometric mean formula: broken exp(ln(...)) → correct weighted geometric mean - Preserved prompt rubrics (already correct) for SurveyBench and DeepSurvey ### Limitations documented - DeepSurvey-Bench: no public test set; evaluation is LLM-as-judge only - DeepScholar RQ1 (Relevance Rate): not yet implemented - Mock mode clearly labeled when no API key configured * Add public binary distribution pipeline - Rewrite release-accelerate.yml: multi-platform matrix build (linux x86_64 + aarch64, macOS arm64 + x86_64, Windows x86_64) publish to public rcm-dist repo with SHA256 checksums auto-update Homebrew formula on each release - Add install instructions to README (brew, curl|sh, manual download) - Add rcm-dist to .gitignore (separate public repo) * feat(tools): add rdc_read and rdc_write accelerator tools for RDC integration - rdc_read: HTTP GET tool for reading research entities from RDC REST API - Supports entity types: research, ideas, claims, experiments, lit_papers - URL construction with entity pluralization + query parameters - LLM-friendly response formatting - Reads RDC_URL and RDC_RESEARCH_ID from environment variables - rdc_write: HTTP POST/PATCH tool for writing to RDC - Supports entity types: ideas, claims, papers, reviews, positionings, etc. - Action-based routing: create (POST) vs update (PATCH) with URL generation - Phase ownership validation for write safety - mod.rs: register both tools in builtin_tools() vec - Add rdc_read.txt + rdc_write.txt prompt files for LLM usage guidance * docs(prompts): add rdc_read and rdc_write tool usage guides for LLM agents - rdc_read.txt: guidance on reading research context from RDC - Available entity types and their meanings - Usage examples for research, ideas, lit_search - Best practices: prefer specific queries, respect endpoint limits - rdc_write.txt: guidance on writing research outputs to RDC - Available entity types and actions - Phase ownership rules (which phase writes what) - Allowed write patterns and examples * feat(pipeline): wire rdc_read/write into 5 RCM pipeline files Strategy: internal processing nodes keep 'fs' tools; entry nodes gain rdc_read; exit nodes gain rdc_write. Changes per pipeline: - explore.rcm: anchor node +rdc_read, gate node +rdc_write - ground.rcm: load +rdc_read, position/grounded/gate +rdc_write - compose_report.rcm: load +rdc_read, report/gate +rdc_write - compose_paper.rcm: load +rdc_read, claims/spine/assemble/gate +rdc_write - review.rcm: load +rdc_read, synthesis/gate +rdc_write All nodes verified: internal scout/merge/ideation/reviewer nodes keep existing tools (fs, arxiv_search, etc.) for maximum isolation. * feat(prompts): add RDC write instructions to all 5 gate prompts Each gate prompt now includes an === RDC Integration (rdc_write tool) === section: - explore_gate: write ideas, story_spines, gate_records - ground_gate: write positionings, update story_spines, gate_records - report_gate: write tech_reports, gate_records - paper_gate: write claims, papers, paper_spines, gate_records - review_gate: write reviews, gate_records with routing Also added 37 unit tests for rdc_read/rdc_write: - pluralize(), url_encode(), build_endpoint() — pure function tests - All 86 accelerator tests passing, 0 fail * fix(review-r2): add missing kebab-case pluralize entries to rdc_read Add paper-spines, tech-reports, story-spines, gates to pluralize() for consistency with rdc_write. Also add 4 corresponding tests. * style: rustfmt rdc_read.rs and rdc_write.rs Fix formatting: chained method calls, long tuples split across lines. cargo fmt --check passes. * feat(M0): add --run-dir CLI flag for isolated run directories - CLI arg --run-dir <path> follows --purpose pattern - Injects into RunState → Environment → env context fragment - Exports RCM_RUN_DIR env var for shell subprocess tools - Anchor prompt prefers injected run_dir (mkdir -p), falls back to timestamp - Backward compatible: existing usage without --run-dir unchanged - 413 tests pass, zero regressions * test: add E2E tests for --run-dir CLI flag 4 tests covering: env var export (RCM_RUN_DIR), default state (None), unicode paths, and relative path preservation. All pass without regression. * chore: document stream JSON line format as external frozen contract * fix: set environment.cwd to run_dir so fs tools write inside temp dir * fix: propagate run_dir and cwd to all graph sub-components Root cause: GraphRun::new() initializes every sub-component's input with RunState::default() (cwd='.'), discarding the user-specified --run-dir. apply_boundary_input only passes State channel data, not environment. Fix: after apply_boundary_input, propagate run_dir from the graph's input to every sub-component's RunState (run_dir, environment.cwd, environment.run_dir, RCM_RUN_DIR var) and to the boundary output. Verified: files now appear incrementally inside the --run-dir after each component completes (00_survey_spec.md, 01_query_plan.md, etc.) * fix: handoff uses '.' when --run-dir is set, eliminating path nesting When --run-dir is set, cwd IS the run directory. Previously anchor's LLM still generated 'runs/<timestamp>' in the handoff, causing child components to nest paths: <runDir>/runs/<ts>/file.md instead of <runDir>/file.md. WorkerPool's walkRunDir still found them (recursive) but the semantic path was wrong. Changes: - anchor.txt: tell LLM to use '.' as run_dir when env fragment has one - handoff.md: document the absolute-run_dir → '.' exception - graph.rs: clean up redundant run_dir field copies (cwd propagation suffices) * fix nested graph run directory propagation * fix(rdc): authenticate rdc_write/rdc_read + map gate_records to /gates rdc_write and rdc_read sent no Authorization header, so against an RDC running RDC_AUTH_ENFORCE=true every call was rejected with 401. Read RDC_TOKEN from the environment and send it as a bearer token when present; when unset, requests go out unauthenticated exactly as before (trusted/local RDC keeps working). Also add the gate_records|gate_record => gates arm to rdc_write::pluralize so gate-record writes reach RDC's /gates route instead of 404ing — rdc_read::pluralize already had this mapping, rdc_write did not. * fix(rdc): share pluralize, encode URL paths, harden graph propagation Three portal-related hardening fixes on top of PR #126 (which already fixed the RDC Authorization header): 1. Extract pluralize() to tools/mod.rs as a single shared function. Previously rdc_read and rdc_write each had their own copy, and they had drifted: rdc_write knew papers/reviews but not story_spines/ gate_records; rdc_read had the opposite gap. A missing mapping falls through to the catch-all arm, producing a wrong URL and a silent 404. The merged version includes all entity types from both copies, plus restores the gate_records=>gates mapping (deleted in our earlier draft) so RCM is self-sufficient without depending on rdc's /gate_records alias route. 2. Add validate_path_id() + url_encode() (P0-13): research_id and entity_id are validated against [A-Za-z0-9_-] and percent-encoded before interpolation into URL path segments, preventing path traversal (../), query injection (?), and fragment injection (#). 16 new tests. 3. Replace expect() in graph.rs source_state with warn + fallback (P1): a missing output during propagation now logs a warning and forwards an empty RunState instead of panicking the whole pipeline. * fix: use dedicated image generation credential * chore: update vulnerable transitive dependencies * fix: refresh release formula checksums * fix: build portable Linux release assets * fix: validate ARM64 Linux release builds * feat: sandbox run directories and accept purpose stdin * fix: align accelerate version with release (#136) * fix: keep nested graph events in the root stream * fix: propagate graph component failures to the CLI * chore: bump accelerate to 0.2.7 * fix: allow long model and tool requests * chore: bump accelerate to 0.2.8 * fix: route image generation through Yuzhu Send the gpt-image-2 payload expected by the new image gateway.\nCover the request contract and decoded PNG output with a local server test. * fix: keep image gateway payload minimal Avoid hardcoding image quality and compression options.\nSend only the model, prompt, size, and requested image count. * fix: harden long-running spawn batches * fix: bound spawn inputs and prefer final status * chore: bump accelerate to 0.2.9 * fix: normalize adjacent system messages * chore: bump accelerate to 0.2.10 * fix: replay transient completions without corrupting context (#142) * fix: replay transient completions without corrupting context * style: satisfy current stable clippy * chore: bump RCM to 0.2.11 * fix: stabilize OpenAI-compatible gateway transport (#143) * fix: stabilize OpenAI-compatible gateway transport * test: keep transport regression at module end * chore: bump accelerate to 0.2.12 * fix(accelerator): guard short file edits (#144) * fix: harden image generation transport (#145) * fix: harden image generation transport Accept verified base64 or trusted HTTPS image results with one bounded transient retry. Return sanitized stable failures and bump the immutable accelerate release to 0.2.14. * feat: add image provider canary Expose a fixed non-sensitive image canary without involving a model completion. Keep its diagnostics limited to the sanitized image transport error contract. * fix: bound image response reads * fix: expose sanitized image canary reasons (#146) * chore(research): init MoEH project state, wiki, and idea_001 - research_init: .research/ skeleton, state.yaml, timeline, AGENTS.md dynamic state - idea_001: MoEH 1M-param MoE advisor with step-level action priors (imported from plan_004 proposal) - literature: 23 papers ingested (harness automation, context compression, advisor models, MoE for agents, benchmarks), gap G01 registered, 4 knowledge edges - import_papers.py: arXiv API metadata workaround for wiki ingest bug - AGENTS.md: append original RCM Development Guide (recovered from git HEAD after research_init regenerated the file) Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * feat: expose sanitized completion failure telemetry (#147) * fix: preserve reasoning across mixed tool turns (#148) DeepSeek may return reasoning, visible text, and a tool call in one assistant\nturn. Keep the reasoning attached until every tool call in that completion has\nbeen decoded so replay includes the required reasoning_content field.\n\nAdd a regression test for the mixed response and bump the CLI to v0.2.17. * fix: reconstruct mixed assistant tool turns (#149) * fix: replay failed tool results in assistant turns (#150) * feat(machine): obs channel and overlay projection with all wiring sites State-layer primitives plus every construction site they touch, so the workspace compiles at this commit: - machine/obs.rs: controller observation derived fresh by measure() — budget gauge (context limit, chars/4 estimate, 0.85 soft threshold, saturating headroom, last API-measured calibration), ledger digest slot, overlay status counts; ledger_transitions_in lifts ledger tool migrations out of step effects (consumed by the trajectory wiring). - machine/overlay.rs: Policy::overlay() default declaration consumed only at Halt; empty overlay is byte-identical to encode_context (golden-tested), so non-opting policies are unchanged. - ExecutionMode::Live carries the overlay through machine→reactor→ completion at all four construction sites; PolicyView gains obs at both sites (accelerator loop, captain test). - proto: Budget/Obs messages, State.obs=19, OpenRequest.run_dir=9; server build_state populates obs. Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * feat(storage,accelerator,server): wire trajectory recording into both run paths The storage crate existed but no runtime path ever wrote to it; server runs died with the process. Now every decision point lands in a WAL: - storage: TrajectoryEvent {step, obs, ledger_transitions, event} is the sole recording format (old Store::record removed — no dual format); overlay declarations persist as counts inside obs.overlay_status only, so WAL content never carries projected text; checkpoint_retaining_events + replay_from_origin keep every event for offline training instead of trimming the audit trail. - accelerator: TrajectoryRecorder owns a per-machine WAL under <run_dir>/trajectory/<name>-<id>/ so concurrent graph components never share a segment; fire() records the envelope after each apply and checkpoints at exit. Opt-in: no run_dir means zero writes. - server: Run carries an optional Store; an explicit run_dir is honored as the run directory with the WAL under run_dir/trajectory/<machine_id> — the same layout the CLI writes — and RunState.run_dir is the provided dir itself; without one the WAL lands under RCM_SERVER_TRAJECTORY_DIR/<machine_id> (collection on by default); Step derives obs before apply and records after; Destroy writes the final checkpoint. Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * feat(accelerator): ledger tool with guarded state machine Durable structured working state on the synergy pattern — the model writes structure, code guards the invariants: - pending→running→completed|failed only; anything else is rejected with an explanatory error, so a stuck planner cannot skip the gate; - completing an entry auto-promotes pending entries whose deps are all completed; - memory is authoritative, write-through to <run_dir>/ledger.json; without a run_dir the ledger is process-ephemeral and leaves no file behind; - results carry "tool":"ledger" + transitions JSON so machine::ledger_transitions_in lifts migrations into trajectory envelopes (future per-node credit-assignment anchor); - registered in the catalog: .rcm tools=[..., "ledger"] opts in; obs.ledger_digest feeds the observation channel. Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * docs(state-layer): mark design implemented; ignore trajectory artifacts - state-layer-v1.md: status → Implemented on feat/state-layer, links the Blueprint note, records P3 (LLM manager kill test) deferred by user decision; - .gitignore: rcm-trajectories/ (server default-on recording root); - research timeline: session insights and decisions behind the state-layer ontology (obs naming, projection vs materialization). Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * chore(sdk): declare grpcio-tools codegen extra Declare the codegen extra per the Blueprint risk table so stub regeneration is reproducible: `pip install -e ".[codegen]"` then `bash generate.sh`. Runtime dependencies remain grpcio/protobuf. The _pb2.py/_pb2_grpc.py regeneration itself could not run in this sandbox — protoc invocation was denied in every form (script, module, absolute and relative binary paths). Run the codegen command manually before merging and verify Budget/Obs (State field 19) and OpenRequest.run_dir (field 9) appear in _pb2.py. Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * chore(deps): bump h2 to 0.4.19 for RUSTSEC-2026-0258 cargo-deny advisories flagged h2 0.4.14 (unbounded empty DATA frames, RUSTSEC-2026-0258, patched in 0.4.16). The branch base predated the fix that main already carries; cargo update -p h2 aligns the lockfile so the hygiene gate passes on this branch too. Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * docs(state-layer): point implementation status at PR #154 The header still referenced the superseded feat/state-layer branch; update it to the replacement PR #154 on feat/state-layer-v4. Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * fix(machine): waive result_large_err on send for clippy 1.98 CI stable clippy (1.98) flags the Result<_, Fragment> return of send; local 1.95 did not. Same intentional-error-type rationale as build_request — Fragment is the unified hitch carrier, boxing would lose the ergonomics of returning hitch fragments directly. Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * fix(sdk,storage): regenerate stubs; satisfy clippy 1.98 chunks lint Regenerate Python stubs from the proto with Obs/Budget (State field 19) and OpenRequest.run_dir (field 9); the grpc stub import is rewritten to the package-relative form as generate.sh does. Replace chunks_exact with as_chunks in the sparse index loader — same iteration, satisfies the chunks_exact_to_as_chunks lint that clippy 1.98 (CI stable) adds. Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> * remove internal RDC integration * chore: remove desktop frontend * chore: remove research artifacts and legacy docs * chore(governance): establish RCM repository controls * ci(maintainer): skip dispatch before first release * ci(maintainer): require current release archive --------- Co-authored-by: Zhengxi YU <y@yzxoi.top> Co-authored-by: yzxoi <yzxoi@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Limonier <reiger2007@163.com> Co-authored-by: shiloh <shilohsols@outlook.com> Co-authored-by: synergy-agent <299070056+synergy-agent@users.noreply.github.com> --- .agents/skills/repo-decisions/SKILL.md | 46 + .agents/skills/repo-governance/SKILL.md | 51 + .agents/skills/repo-review/SKILL.md | 72 + .editorconfig | 6 + .gitattributes | 4 + .github/ISSUE_TEMPLATE/bug.md | 15 + .github/ISSUE_TEMPLATE/feature.md | 13 + .github/dependabot.yml | 17 + .github/pull_request_template.md | 9 + .github/workflows/ci.yml | 133 + .github/workflows/maintainer.yml | 132 + .github/workflows/release-accelerate.yml | 247 + .github/workflows/release-promotion.yml | 105 + .gitignore | 14 +- .repo-seed/manifest.json | 310 + .repo-seed/update-strategy.md | 31 + AGENTS.md | 163 +- CLAUDE.md | 3 + CODEOWNERS | 10 + CODE_OF_CONDUCT.md | 45 + CONTRIBUTING.md | 26 + Cargo.lock | 7464 +++-------------- Cargo.toml | 3 +- LICENSE | 21 + README.md | 97 + SECURITY.md | 17 + crates/AGENTS.md | 25 + crates/accelerator/Cargo.toml | 25 +- crates/accelerator/src/accelerator.rs | 429 +- crates/accelerator/src/assembly.rs | 365 - crates/accelerator/src/catalog.rs | 282 + crates/accelerator/src/condition.rs | 77 +- crates/accelerator/src/environment/mod.rs | 18 + crates/accelerator/src/flux.rs | 258 +- crates/accelerator/src/graph.rs | 933 ++- crates/accelerator/src/lib.rs | 28 +- crates/accelerator/src/logging.rs | 70 - crates/accelerator/src/lsp/client.rs | 90 +- crates/accelerator/src/lsp/diagnostics.rs | 8 +- crates/accelerator/src/lsp/mod.rs | 2 + crates/accelerator/src/lsp/pool.rs | 18 - crates/accelerator/src/mcp/http.rs | 145 +- crates/accelerator/src/mcp/mod.rs | 3 +- crates/accelerator/src/mcp/registry.rs | 198 +- crates/accelerator/src/mcp/sse.rs | 239 + crates/accelerator/src/mcp/stdio.rs | 44 +- crates/accelerator/src/mcp/tool.rs | 134 +- crates/accelerator/src/mcp/transport.rs | 64 +- crates/accelerator/src/policy/captain.rs | 166 +- crates/accelerator/src/policy/mod.rs | 18 +- crates/accelerator/src/policy/moves/agent.rs | 45 + crates/accelerator/src/policy/moves/env.rs | 36 + .../src/policy/moves/instruction.rs | 101 + crates/accelerator/src/policy/moves/mod.rs | 6 + .../accelerator/src/policy/moves/purpose.rs | 23 + crates/accelerator/src/policy/moves/react.rs | 47 + .../accelerator/src/policy/moves/resources.rs | 21 + .../src/policy/phases/bootstrap.rs | 58 - crates/accelerator/src/policy/phases/env.rs | 50 - .../accelerator/src/policy/phases/instruct.rs | 119 - crates/accelerator/src/policy/phases/mod.rs | 9 - .../accelerator/src/policy/phases/purpose.rs | 41 - crates/accelerator/src/policy/retry.rs | 90 + crates/accelerator/src/prompts/mod.rs | 7 + crates/accelerator/src/state.rs | 54 - crates/accelerator/src/tools/arxiv.rs | 383 + crates/accelerator/src/tools/arxiv.txt | 23 + crates/accelerator/src/tools/find/ast.rs | 29 +- crates/accelerator/src/tools/find/files.rs | 4 +- crates/accelerator/src/tools/find/text.rs | 4 +- crates/accelerator/src/tools/fs/edit.rs | 25 +- crates/accelerator/src/tools/fs/list.rs | 2 +- crates/accelerator/src/tools/fs/mod.rs | 11 +- crates/accelerator/src/tools/fs/mod.txt | 8 +- crates/accelerator/src/tools/fs/pdf.rs | 145 + crates/accelerator/src/tools/fs/read.rs | 59 +- crates/accelerator/src/tools/fs/stat.rs | 87 - crates/accelerator/src/tools/fs/write.rs | 2 +- crates/accelerator/src/tools/git.rs | 358 + crates/accelerator/src/tools/git.txt | 34 + crates/accelerator/src/tools/image_gen.rs | 748 ++ crates/accelerator/src/tools/ledger.rs | 385 + crates/accelerator/src/tools/lsp.rs | 8 +- crates/accelerator/src/tools/mod.rs | 186 +- crates/accelerator/src/tools/shell.rs | 39 +- crates/accelerator/src/tools/spawn.rs | 271 + crates/accelerator/src/tools/webfetch.rs | 433 + crates/accelerator/src/trajectory.rs | 66 + crates/accelerator/src/wire.rs | 164 + crates/accelerator/tests/captain.rs | 181 + crates/accelerator/tests/common/mod.rs | 33 + crates/accelerator/tests/condition.rs | 221 +- crates/accelerator/tests/fs_tool.rs | 58 + crates/accelerator/tests/git_tool.rs | 192 + crates/accelerator/tests/graph_failures.rs | 36 + crates/accelerator/tests/graph_names.rs | 716 +- crates/accelerator/tests/image_gen.rs | 308 + crates/accelerator/tests/ledger_tool.rs | 334 + crates/accelerator/tests/lsp_client.rs | 78 + crates/accelerator/tests/mcp.rs | 122 +- crates/accelerator/tests/provider.rs | 9 +- crates/accelerator/tests/shell_tool.rs | 81 + crates/accelerator/tests/spawn.rs | 343 + crates/accelerator/tests/trajectory.rs | 137 + crates/accelerator/tests/webfetch.rs | 11 + crates/cli/Cargo.toml | 16 +- crates/cli/src/args.rs | 150 +- crates/cli/src/cmd/dispatch.rs | 211 + crates/cli/src/cmd/image_canary.rs | 65 + crates/cli/src/cmd/inventory.rs | 154 + crates/cli/src/cmd/mod.rs | 22 +- crates/cli/src/cmd/parse.rs | 11 + crates/cli/src/cmd/run.rs | 359 +- crates/cli/src/hook/mod.rs | 285 +- crates/cli/src/lib.rs | 13 + crates/cli/src/main.rs | 7 +- crates/cli/src/output/json.rs | 7 +- crates/cli/src/output/tape.rs | 1406 +++- crates/cli/src/output/text.rs | 25 +- crates/cli/src/rcm/ast.rs | 187 + crates/cli/src/rcm/compile.rs | 748 ++ crates/cli/src/rcm/lexer.rs | 135 + crates/cli/src/rcm/mod.rs | 16 + crates/cli/src/rcm/parser.rs | 641 ++ crates/cli/tests/autoresearch_examples.rs | 42 + crates/cli/tests/compile.rs | 374 + crates/cli/tests/dispatch.rs | 221 + crates/cli/tests/parser.rs | 338 + crates/cli/tests/run_dir.rs | 86 + crates/cli/tests/run_stream.rs | 172 + crates/cli/tests/tape_animation.rs | 247 + crates/cli/tests/templates.rs | 241 + crates/machine/Cargo.toml | 23 +- crates/machine/src/completion.rs | 577 +- crates/machine/src/context.rs | 146 +- crates/machine/src/env.rs | 34 + crates/machine/src/event.rs | 82 +- crates/machine/src/fragment.rs | 115 +- crates/machine/src/inbox.rs | 66 +- crates/machine/src/lib.rs | 33 +- crates/machine/src/machine.rs | 550 +- crates/machine/src/model.rs | 24 +- crates/machine/src/obs.rs | 228 + crates/machine/src/overlay.rs | 25 + crates/machine/src/policy.rs | 114 +- crates/machine/src/reactor.rs | 284 +- crates/machine/src/record.rs | 79 + crates/machine/src/resources.rs | 127 +- crates/machine/src/tool.rs | 76 +- crates/machine/src/usage.rs | 60 + crates/machine/tests/common.rs | 54 +- crates/machine/tests/completion.rs | 646 ++ crates/machine/tests/completion_telemetry.rs | 69 + crates/machine/tests/context.rs | 54 +- crates/machine/tests/env.rs | 88 + crates/machine/tests/fragment.rs | 28 +- crates/machine/tests/inbox.rs | 8 +- crates/machine/tests/machine.rs | 302 +- crates/machine/tests/multi_modal.rs | 136 + crates/machine/tests/obs_budget.rs | 186 + crates/machine/tests/overlay.rs | 117 + crates/machine/tests/reactor.rs | 327 + crates/machine/tests/resources.rs | 180 +- crates/server/Cargo.toml | 30 + crates/server/DESIGN.md | 415 + crates/server/build.rs | 4 + crates/server/src/action_space.rs | 184 + crates/server/src/decode.rs | 229 + crates/server/src/lib.rs | 15 + crates/server/src/main.rs | 26 + crates/server/src/manager.rs | 46 + crates/server/src/mcp.rs | 61 + crates/server/src/service.rs | 199 + crates/server/src/state.rs | 178 + crates/server/tests/integration.rs | 226 + crates/server/tests/layer_consistency.rs | 478 ++ crates/server/tests/trajectory.rs | 112 + crates/storage/Cargo.toml | 19 + crates/storage/src/error.rs | 225 + crates/storage/src/index.rs | 183 + crates/storage/src/io.rs | 11 + crates/storage/src/lib.rs | 585 ++ crates/storage/src/manifest.rs | 246 + crates/storage/src/segment/mod.rs | 11 + crates/storage/src/segment/reader.rs | 155 + crates/storage/src/segment/writer.rs | 494 ++ crates/storage/src/snapshot.rs | 200 + crates/storage/src/store.rs | 103 + crates/storage/tests/index.rs | 330 + crates/storage/tests/manifest.rs | 49 + crates/storage/tests/segment.rs | 401 + crates/storage/tests/snapshot.rs | 261 + crates/storage/tests/store.rs | 348 + crates/storage/tests/wal.rs | 617 ++ crates/utils/Cargo.toml | 1 + crates/utils/src/id.rs | 3 +- crates/utils/src/lib.rs | 4 +- deny.toml | 56 + doc/mcp.md | 208 - docs/AGENTS.md | 56 + docs/architecture.md | 22 + ...markdown-architectural-decision-records.md | 48 + ...001-repo-seed-is-a-skill-not-a-template.md | 49 + .../0002-self-governing-repository-design.md | 59 + ...03-repo-review-instantiated-per-project.md | 48 + .../0004-release-from-primary-repository.md | 59 + docs/decisions/README.md | 70 + docs/development.md | 47 + docs/postmortems/README.md | 25 + docs/release-policy.md | 43 + ...00-primary-repository-release-migration.md | 48 + docs/specs/README.md | 46 + docs/testing.md | 40 + examples/AGENTS.md | 11 + examples/MASA v2.0/README.md | 159 + .../changelog/000_architecture_overview.md | 248 + examples/MASA v2.0/export_pdf.js | 133 + examples/MASA v2.0/prompts/core/generator.txt | 160 + .../MASA v2.0/prompts/core/judge_accuracy.txt | 58 + .../MASA v2.0/prompts/core/judge_content.txt | 61 + .../MASA v2.0/prompts/core/judge_depth.txt | 55 + .../prompts/core/judge_insightfulness.txt | 61 + .../prompts/core/judge_synthesizer.txt | 97 + .../MASA v2.0/prompts/core/researcher.txt | 177 + .../MASA v2.0/prompts/core/supervisor.txt | 71 + .../MASA v2.0/prompts/finish/polisher.txt | 94 + .../prompts/finish/reference_compiler.txt | 195 + examples/MASA v2.0/prompts/phase0/anchor.txt | 41 + .../prompts/phase0/benchmark_scout.txt | 16 + .../prompts/phase0/citation_seed_selector.txt | 15 + .../prompts/phase0/coverage_auditor.txt | 105 + .../prompts/phase0/discovery_merger.txt | 17 + .../prompts/phase0/evolution_mapper.txt | 147 + .../prompts/phase0/expansion_merger.txt | 41 + .../prompts/phase0/frontier_scout.txt | 16 + .../MASA v2.0/prompts/phase0/method_scout.txt | 16 + .../prompts/phase0/paper_fetcher.txt | 109 + .../prompts/phase0/paper_profiler.txt | 172 + .../MASA v2.0/prompts/phase0/query_plan.txt | 32 + .../prompts/phase0/reference_expander.txt | 18 + .../prompts/phase0/semantic_expander.txt | 17 + .../MASA v2.0/prompts/phase0/survey_scout.txt | 16 + .../prompts/phase0/taxonomy_builder.txt | 106 + .../MASA v2.0/prompts/phase0/topic_miner.txt | 143 + examples/MASA v2.0/rcm.toml | 7 + examples/MASA v2.0/rcm/core/generator.rcm | 17 + .../MASA v2.0/rcm/core/judge_accuracy.rcm | 17 + examples/MASA v2.0/rcm/core/judge_content.rcm | 17 + examples/MASA v2.0/rcm/core/judge_depth.rcm | 17 + .../rcm/core/judge_insightfulness.rcm | 17 + .../MASA v2.0/rcm/core/judge_synthesizer.rcm | 17 + examples/MASA v2.0/rcm/core/researcher.rcm | 17 + examples/MASA v2.0/rcm/core/supervisor.rcm | 17 + examples/MASA v2.0/rcm/finish/polisher.rcm | 17 + .../rcm/finish/reference_compiler.rcm | 17 + examples/MASA v2.0/rcm/masa_core.rcm | 83 + examples/MASA v2.0/rcm/masa_finish.rcm | 14 + examples/MASA v2.0/rcm/masa_phase0.rcm | 118 + examples/MASA v2.0/rcm/masa_revise.rcm | 26 + examples/MASA v2.0/rcm/phase0/anchor.rcm | 17 + .../MASA v2.0/rcm/phase0/coverage_auditor.rcm | 17 + examples/MASA v2.0/rcm/phase0/discovery.rcm | 73 + .../MASA v2.0/rcm/phase0/evolution_mapper.rcm | 17 + examples/MASA v2.0/rcm/phase0/expansion.rcm | 74 + .../MASA v2.0/rcm/phase0/paper_fetcher.rcm | 17 + .../MASA v2.0/rcm/phase0/paper_profiler.rcm | 17 + examples/MASA v2.0/rcm/phase0/query_plan.rcm | 17 + .../MASA v2.0/rcm/phase0/taxonomy_builder.rcm | 17 + examples/MASA v2.0/rcm/phase0/topic_miner.rcm | 17 + examples/MASA v2.0/run_masa.ps1 | 118 + examples/MASA v2.0/schema/candidate_pool.md | 19 + examples/MASA v2.0/schema/expansion.md | 18 + examples/MASA v2.0/schema/handoff.md | 51 + examples/MASA v2.0/schema/query_plan.md | 24 + examples/MASA v2.0/schema/survey_spec.md | 17 + examples/MASA v2.0/topic.md | 1 + examples/autoresearch-survey/.gitignore | 2 + examples/autoresearch-survey/AGENTS.md | 33 + examples/autoresearch-survey/CONTEXT_FLOW.md | 65 + examples/autoresearch-survey/README.md | 112 + .../autoresearch-survey/prompts/anchor.txt | 35 + .../prompts/benchmark_judge.txt | 19 + .../prompts/benchmark_scout.txt | 20 + .../autoresearch-survey/prompts/card_plan.txt | 38 + .../prompts/citation_seed_selector.txt | 19 + .../prompts/coverage_judge.txt | 19 + .../prompts/cross_domain_expander.txt | 53 + .../prompts/discovery_merger.txt | 21 + .../prompts/expansion_merger.txt | 21 + .../prompts/frontier_scout.txt | 20 + .../autoresearch-survey/prompts/gap_judge.txt | 19 + .../prompts/image_planner.txt | 24 + .../prompts/judge_synthesizer.txt | 19 + .../prompts/method_scout.txt | 20 + .../prompts/paper_card.txt | 41 + .../prompts/query_plan.txt | 19 + .../autoresearch-survey/prompts/rank_pool.txt | 22 + .../prompts/reference_expander.txt | 19 + .../prompts/research_map.txt | 22 + .../prompts/scope_judge.txt | 20 + .../prompts/section_expander.txt | 38 + .../prompts/section_translator.txt | 30 + .../prompts/semantic_expander.txt | 21 + .../prompts/survey_assembler.txt | 45 + .../prompts/survey_brief.txt | 24 + .../prompts/survey_outline.txt | 46 + .../prompts/survey_scout.txt | 20 + .../prompts/survey_writer.txt | 39 + .../prompts/survey_writer_zh.txt | 28 + .../prompts/zh_assemble.txt | 33 + .../autoresearch-survey/prompts/zh_frame.txt | 43 + examples/autoresearch-survey/rcm.toml | 6 + examples/autoresearch-survey/rcm/anchor.rcm | 17 + .../rcm/autoresearch_survey.rcm | 79 + .../autoresearch-survey/rcm/card_plan.rcm | 17 + .../autoresearch-survey/rcm/discovery.rcm | 73 + .../autoresearch-survey/rcm/expansion.rcm | 86 + .../autoresearch-survey/rcm/image_planner.rcm | 17 + .../autoresearch-survey/rcm/judge_panel.rcm | 73 + .../autoresearch-survey/rcm/paper_card.rcm | 17 + .../autoresearch-survey/rcm/query_plan.rcm | 17 + .../autoresearch-survey/rcm/rank_pool.rcm | 17 + .../autoresearch-survey/rcm/research_map.rcm | 17 + .../rcm/section_expander.rcm | 17 + .../rcm/section_translator.rcm | 18 + .../rcm/survey_assembler.rcm | 17 + .../autoresearch-survey/rcm/survey_brief.rcm | 17 + .../rcm/survey_outline.rcm | 17 + .../autoresearch-survey/rcm/survey_writer.rcm | 17 + .../rcm/survey_writer_zh.rcm | 18 + .../autoresearch-survey/rcm/zh_assemble.rcm | 18 + examples/autoresearch-survey/rcm/zh_frame.rcm | 18 + .../schema/candidate_pool.md | 19 + .../autoresearch-survey/schema/card_plan.md | 30 + .../autoresearch-survey/schema/expansion.md | 28 + .../autoresearch-survey/schema/handoff.md | 67 + .../autoresearch-survey/schema/judge_panel.md | 20 + .../autoresearch-survey/schema/paper_card.md | 36 + .../autoresearch-survey/schema/query_plan.md | 24 + .../autoresearch-survey/schema/ranked_pool.md | 30 + .../schema/research_map.md | 19 + .../autoresearch-survey/schema/section.md | 30 + examples/autoresearch-survey/schema/survey.md | 68 + .../schema/survey_brief.md | 16 + .../autoresearch-survey/schema/survey_spec.md | 17 + examples/city-halfday/README.md | 40 + examples/city-halfday/city_halfday.rcm | 23 + examples/city-halfday/outputs/.gitignore | 3 + examples/city-halfday/outputs/.gitkeep | 0 .../city-halfday/prompts/city_halfday.txt | 21 + examples/cook-tonight/README.md | 40 + examples/cook-tonight/cook_tonight.rcm | 23 + examples/cook-tonight/outputs/.gitignore | 3 + examples/cook-tonight/outputs/.gitkeep | 0 .../cook-tonight/prompts/cook_tonight.txt | 20 + examples/folder-concierge/README.md | 43 + .../folder-concierge/folder_concierge.rcm | 17 + examples/folder-concierge/outputs/.gitignore | 3 + examples/folder-concierge/outputs/.gitkeep | 0 .../prompts/folder_concierge.txt | 26 + examples/general/README.md | 8 + examples/general/general.rcm | 23 + examples/general/outputs/.gitignore | 2 + examples/general/prompts/general.txt | 5 + examples/paper-scout/README.md | 39 + examples/paper-scout/outputs/.gitignore | 3 + examples/paper-scout/outputs/.gitkeep | 0 examples/paper-scout/paper_scout.rcm | 23 + examples/paper-scout/prompts/paper_scout.txt | 23 + examples/project-maintainer/dispatch.toml | 44 + .../templates/health_check.rcm.tpl | 83 + .../templates/issue_triage.rcm.tpl | 76 + .../templates/mention_handler.rcm.tpl | 112 + .../templates/pr_review.rcm.tpl | 77 + examples/repo-snapshot/README.md | 40 + examples/repo-snapshot/outputs/.gitignore | 3 + examples/repo-snapshot/outputs/.gitkeep | 0 .../repo-snapshot/prompts/repo_snapshot.txt | 20 + examples/repo-snapshot/repo_snapshot.rcm | 17 + install.ps1 | 49 + install.sh | 104 + proto/rcm.proto | 233 + rustfmt.toml | 9 + scripts/audit-governance.mjs | 156 + scripts/capabilities.mjs | 346 + scripts/governance-config.mjs | 82 + scripts/install-hooks.mjs | 124 + scripts/render-formula.sh | 80 + scripts/run-gates.mjs | 63 + scripts/verify-commit-msg.mjs | 76 + scripts/verify-decisions.mjs | 137 + scripts/verify-doc-links.mjs | 186 + scripts/verify-manifest.mjs | 145 + scripts/verify-placeholders.mjs | 107 + scripts/verify-postmortems.mjs | 56 + scripts/verify-specs.mjs | 93 + sdks/python/AGENTS.md | 13 + sdks/python/README.md | 68 + sdks/python/examples/echo_agent.py | 76 + sdks/python/examples/math_mcp_server.py | 110 + sdks/python/examples/mcp_math.py | 100 + sdks/python/generate.sh | 34 + sdks/python/pyproject.toml | 23 + sdks/python/src/rcm/__init__.py | 16 + sdks/python/src/rcm/_pb2.py | 119 + sdks/python/src/rcm/_pb2_grpc.py | 184 + sdks/python/src/rcm/client.py | 52 + sdks/python/src/rcm/mcp.py | 95 + sdks/python/src/rcm/model.py | 86 + sdks/python/src/rcm/react.py | 57 + sdks/python/tests/test_react_policy.py | 85 + 411 files changed, 38009 insertions(+), 9761 deletions(-) create mode 100644 .agents/skills/repo-decisions/SKILL.md create mode 100644 .agents/skills/repo-governance/SKILL.md create mode 100644 .agents/skills/repo-review/SKILL.md create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/ISSUE_TEMPLATE/feature.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/maintainer.yml create mode 100644 .github/workflows/release-accelerate.yml create mode 100644 .github/workflows/release-promotion.yml create mode 100644 .repo-seed/manifest.json create mode 100644 .repo-seed/update-strategy.md create mode 100644 CLAUDE.md create mode 100644 CODEOWNERS create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 crates/AGENTS.md delete mode 100644 crates/accelerator/src/assembly.rs create mode 100644 crates/accelerator/src/catalog.rs create mode 100644 crates/accelerator/src/environment/mod.rs delete mode 100644 crates/accelerator/src/logging.rs create mode 100644 crates/accelerator/src/mcp/sse.rs create mode 100644 crates/accelerator/src/policy/moves/agent.rs create mode 100644 crates/accelerator/src/policy/moves/env.rs create mode 100644 crates/accelerator/src/policy/moves/instruction.rs create mode 100644 crates/accelerator/src/policy/moves/mod.rs create mode 100644 crates/accelerator/src/policy/moves/purpose.rs create mode 100644 crates/accelerator/src/policy/moves/react.rs create mode 100644 crates/accelerator/src/policy/moves/resources.rs delete mode 100644 crates/accelerator/src/policy/phases/bootstrap.rs delete mode 100644 crates/accelerator/src/policy/phases/env.rs delete mode 100644 crates/accelerator/src/policy/phases/instruct.rs delete mode 100644 crates/accelerator/src/policy/phases/mod.rs delete mode 100644 crates/accelerator/src/policy/phases/purpose.rs create mode 100644 crates/accelerator/src/policy/retry.rs create mode 100644 crates/accelerator/src/prompts/mod.rs delete mode 100644 crates/accelerator/src/state.rs create mode 100644 crates/accelerator/src/tools/arxiv.rs create mode 100644 crates/accelerator/src/tools/arxiv.txt create mode 100644 crates/accelerator/src/tools/fs/pdf.rs delete mode 100644 crates/accelerator/src/tools/fs/stat.rs create mode 100644 crates/accelerator/src/tools/git.rs create mode 100644 crates/accelerator/src/tools/git.txt create mode 100644 crates/accelerator/src/tools/image_gen.rs create mode 100644 crates/accelerator/src/tools/ledger.rs create mode 100644 crates/accelerator/src/tools/spawn.rs create mode 100644 crates/accelerator/src/tools/webfetch.rs create mode 100644 crates/accelerator/src/trajectory.rs create mode 100644 crates/accelerator/src/wire.rs create mode 100644 crates/accelerator/tests/captain.rs create mode 100644 crates/accelerator/tests/common/mod.rs create mode 100644 crates/accelerator/tests/fs_tool.rs create mode 100644 crates/accelerator/tests/git_tool.rs create mode 100644 crates/accelerator/tests/graph_failures.rs create mode 100644 crates/accelerator/tests/image_gen.rs create mode 100644 crates/accelerator/tests/ledger_tool.rs create mode 100644 crates/accelerator/tests/lsp_client.rs create mode 100644 crates/accelerator/tests/shell_tool.rs create mode 100644 crates/accelerator/tests/spawn.rs create mode 100644 crates/accelerator/tests/trajectory.rs create mode 100644 crates/accelerator/tests/webfetch.rs create mode 100644 crates/cli/src/cmd/dispatch.rs create mode 100644 crates/cli/src/cmd/image_canary.rs create mode 100644 crates/cli/src/cmd/inventory.rs create mode 100644 crates/cli/src/cmd/parse.rs create mode 100644 crates/cli/src/lib.rs create mode 100644 crates/cli/src/rcm/ast.rs create mode 100644 crates/cli/src/rcm/compile.rs create mode 100644 crates/cli/src/rcm/lexer.rs create mode 100644 crates/cli/src/rcm/mod.rs create mode 100644 crates/cli/src/rcm/parser.rs create mode 100644 crates/cli/tests/autoresearch_examples.rs create mode 100644 crates/cli/tests/compile.rs create mode 100644 crates/cli/tests/dispatch.rs create mode 100644 crates/cli/tests/parser.rs create mode 100644 crates/cli/tests/run_dir.rs create mode 100644 crates/cli/tests/run_stream.rs create mode 100644 crates/cli/tests/tape_animation.rs create mode 100644 crates/cli/tests/templates.rs create mode 100644 crates/machine/src/obs.rs create mode 100644 crates/machine/src/overlay.rs create mode 100644 crates/machine/src/record.rs create mode 100644 crates/machine/src/usage.rs create mode 100644 crates/machine/tests/completion.rs create mode 100644 crates/machine/tests/completion_telemetry.rs create mode 100644 crates/machine/tests/env.rs create mode 100644 crates/machine/tests/multi_modal.rs create mode 100644 crates/machine/tests/obs_budget.rs create mode 100644 crates/machine/tests/overlay.rs create mode 100644 crates/machine/tests/reactor.rs create mode 100644 crates/server/Cargo.toml create mode 100644 crates/server/DESIGN.md create mode 100644 crates/server/build.rs create mode 100644 crates/server/src/action_space.rs create mode 100644 crates/server/src/decode.rs create mode 100644 crates/server/src/lib.rs create mode 100644 crates/server/src/main.rs create mode 100644 crates/server/src/manager.rs create mode 100644 crates/server/src/mcp.rs create mode 100644 crates/server/src/service.rs create mode 100644 crates/server/src/state.rs create mode 100644 crates/server/tests/integration.rs create mode 100644 crates/server/tests/layer_consistency.rs create mode 100644 crates/server/tests/trajectory.rs create mode 100644 crates/storage/Cargo.toml create mode 100644 crates/storage/src/error.rs create mode 100644 crates/storage/src/index.rs create mode 100644 crates/storage/src/io.rs create mode 100644 crates/storage/src/lib.rs create mode 100644 crates/storage/src/manifest.rs create mode 100644 crates/storage/src/segment/mod.rs create mode 100644 crates/storage/src/segment/reader.rs create mode 100644 crates/storage/src/segment/writer.rs create mode 100644 crates/storage/src/snapshot.rs create mode 100644 crates/storage/src/store.rs create mode 100644 crates/storage/tests/index.rs create mode 100644 crates/storage/tests/manifest.rs create mode 100644 crates/storage/tests/segment.rs create mode 100644 crates/storage/tests/snapshot.rs create mode 100644 crates/storage/tests/store.rs create mode 100644 crates/storage/tests/wal.rs create mode 100644 deny.toml delete mode 100644 doc/mcp.md create mode 100644 docs/AGENTS.md create mode 100644 docs/architecture.md create mode 100644 docs/decisions/0000-use-markdown-architectural-decision-records.md create mode 100644 docs/decisions/0001-repo-seed-is-a-skill-not-a-template.md create mode 100644 docs/decisions/0002-self-governing-repository-design.md create mode 100644 docs/decisions/0003-repo-review-instantiated-per-project.md create mode 100644 docs/decisions/0004-release-from-primary-repository.md create mode 100644 docs/decisions/README.md create mode 100644 docs/development.md create mode 100644 docs/postmortems/README.md create mode 100644 docs/release-policy.md create mode 100644 docs/specs/0000-primary-repository-release-migration.md create mode 100644 docs/specs/README.md create mode 100644 docs/testing.md create mode 100644 examples/AGENTS.md create mode 100644 examples/MASA v2.0/README.md create mode 100644 examples/MASA v2.0/changelog/000_architecture_overview.md create mode 100644 examples/MASA v2.0/export_pdf.js create mode 100644 examples/MASA v2.0/prompts/core/generator.txt create mode 100644 examples/MASA v2.0/prompts/core/judge_accuracy.txt create mode 100644 examples/MASA v2.0/prompts/core/judge_content.txt create mode 100644 examples/MASA v2.0/prompts/core/judge_depth.txt create mode 100644 examples/MASA v2.0/prompts/core/judge_insightfulness.txt create mode 100644 examples/MASA v2.0/prompts/core/judge_synthesizer.txt create mode 100644 examples/MASA v2.0/prompts/core/researcher.txt create mode 100644 examples/MASA v2.0/prompts/core/supervisor.txt create mode 100644 examples/MASA v2.0/prompts/finish/polisher.txt create mode 100644 examples/MASA v2.0/prompts/finish/reference_compiler.txt create mode 100644 examples/MASA v2.0/prompts/phase0/anchor.txt create mode 100644 examples/MASA v2.0/prompts/phase0/benchmark_scout.txt create mode 100644 examples/MASA v2.0/prompts/phase0/citation_seed_selector.txt create mode 100644 examples/MASA v2.0/prompts/phase0/coverage_auditor.txt create mode 100644 examples/MASA v2.0/prompts/phase0/discovery_merger.txt create mode 100644 examples/MASA v2.0/prompts/phase0/evolution_mapper.txt create mode 100644 examples/MASA v2.0/prompts/phase0/expansion_merger.txt create mode 100644 examples/MASA v2.0/prompts/phase0/frontier_scout.txt create mode 100644 examples/MASA v2.0/prompts/phase0/method_scout.txt create mode 100644 examples/MASA v2.0/prompts/phase0/paper_fetcher.txt create mode 100644 examples/MASA v2.0/prompts/phase0/paper_profiler.txt create mode 100644 examples/MASA v2.0/prompts/phase0/query_plan.txt create mode 100644 examples/MASA v2.0/prompts/phase0/reference_expander.txt create mode 100644 examples/MASA v2.0/prompts/phase0/semantic_expander.txt create mode 100644 examples/MASA v2.0/prompts/phase0/survey_scout.txt create mode 100644 examples/MASA v2.0/prompts/phase0/taxonomy_builder.txt create mode 100644 examples/MASA v2.0/prompts/phase0/topic_miner.txt create mode 100644 examples/MASA v2.0/rcm.toml create mode 100644 examples/MASA v2.0/rcm/core/generator.rcm create mode 100644 examples/MASA v2.0/rcm/core/judge_accuracy.rcm create mode 100644 examples/MASA v2.0/rcm/core/judge_content.rcm create mode 100644 examples/MASA v2.0/rcm/core/judge_depth.rcm create mode 100644 examples/MASA v2.0/rcm/core/judge_insightfulness.rcm create mode 100644 examples/MASA v2.0/rcm/core/judge_synthesizer.rcm create mode 100644 examples/MASA v2.0/rcm/core/researcher.rcm create mode 100644 examples/MASA v2.0/rcm/core/supervisor.rcm create mode 100644 examples/MASA v2.0/rcm/finish/polisher.rcm create mode 100644 examples/MASA v2.0/rcm/finish/reference_compiler.rcm create mode 100644 examples/MASA v2.0/rcm/masa_core.rcm create mode 100644 examples/MASA v2.0/rcm/masa_finish.rcm create mode 100644 examples/MASA v2.0/rcm/masa_phase0.rcm create mode 100644 examples/MASA v2.0/rcm/masa_revise.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/anchor.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/coverage_auditor.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/discovery.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/evolution_mapper.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/expansion.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/paper_fetcher.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/paper_profiler.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/query_plan.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/taxonomy_builder.rcm create mode 100644 examples/MASA v2.0/rcm/phase0/topic_miner.rcm create mode 100644 examples/MASA v2.0/run_masa.ps1 create mode 100644 examples/MASA v2.0/schema/candidate_pool.md create mode 100644 examples/MASA v2.0/schema/expansion.md create mode 100644 examples/MASA v2.0/schema/handoff.md create mode 100644 examples/MASA v2.0/schema/query_plan.md create mode 100644 examples/MASA v2.0/schema/survey_spec.md create mode 100644 examples/MASA v2.0/topic.md create mode 100644 examples/autoresearch-survey/.gitignore create mode 100644 examples/autoresearch-survey/AGENTS.md create mode 100644 examples/autoresearch-survey/CONTEXT_FLOW.md create mode 100644 examples/autoresearch-survey/README.md create mode 100644 examples/autoresearch-survey/prompts/anchor.txt create mode 100644 examples/autoresearch-survey/prompts/benchmark_judge.txt create mode 100644 examples/autoresearch-survey/prompts/benchmark_scout.txt create mode 100644 examples/autoresearch-survey/prompts/card_plan.txt create mode 100644 examples/autoresearch-survey/prompts/citation_seed_selector.txt create mode 100644 examples/autoresearch-survey/prompts/coverage_judge.txt create mode 100644 examples/autoresearch-survey/prompts/cross_domain_expander.txt create mode 100644 examples/autoresearch-survey/prompts/discovery_merger.txt create mode 100644 examples/autoresearch-survey/prompts/expansion_merger.txt create mode 100644 examples/autoresearch-survey/prompts/frontier_scout.txt create mode 100644 examples/autoresearch-survey/prompts/gap_judge.txt create mode 100644 examples/autoresearch-survey/prompts/image_planner.txt create mode 100644 examples/autoresearch-survey/prompts/judge_synthesizer.txt create mode 100644 examples/autoresearch-survey/prompts/method_scout.txt create mode 100644 examples/autoresearch-survey/prompts/paper_card.txt create mode 100644 examples/autoresearch-survey/prompts/query_plan.txt create mode 100644 examples/autoresearch-survey/prompts/rank_pool.txt create mode 100644 examples/autoresearch-survey/prompts/reference_expander.txt create mode 100644 examples/autoresearch-survey/prompts/research_map.txt create mode 100644 examples/autoresearch-survey/prompts/scope_judge.txt create mode 100644 examples/autoresearch-survey/prompts/section_expander.txt create mode 100644 examples/autoresearch-survey/prompts/section_translator.txt create mode 100644 examples/autoresearch-survey/prompts/semantic_expander.txt create mode 100644 examples/autoresearch-survey/prompts/survey_assembler.txt create mode 100644 examples/autoresearch-survey/prompts/survey_brief.txt create mode 100644 examples/autoresearch-survey/prompts/survey_outline.txt create mode 100644 examples/autoresearch-survey/prompts/survey_scout.txt create mode 100644 examples/autoresearch-survey/prompts/survey_writer.txt create mode 100644 examples/autoresearch-survey/prompts/survey_writer_zh.txt create mode 100644 examples/autoresearch-survey/prompts/zh_assemble.txt create mode 100644 examples/autoresearch-survey/prompts/zh_frame.txt create mode 100644 examples/autoresearch-survey/rcm.toml create mode 100644 examples/autoresearch-survey/rcm/anchor.rcm create mode 100644 examples/autoresearch-survey/rcm/autoresearch_survey.rcm create mode 100644 examples/autoresearch-survey/rcm/card_plan.rcm create mode 100644 examples/autoresearch-survey/rcm/discovery.rcm create mode 100644 examples/autoresearch-survey/rcm/expansion.rcm create mode 100644 examples/autoresearch-survey/rcm/image_planner.rcm create mode 100644 examples/autoresearch-survey/rcm/judge_panel.rcm create mode 100644 examples/autoresearch-survey/rcm/paper_card.rcm create mode 100644 examples/autoresearch-survey/rcm/query_plan.rcm create mode 100644 examples/autoresearch-survey/rcm/rank_pool.rcm create mode 100644 examples/autoresearch-survey/rcm/research_map.rcm create mode 100644 examples/autoresearch-survey/rcm/section_expander.rcm create mode 100644 examples/autoresearch-survey/rcm/section_translator.rcm create mode 100644 examples/autoresearch-survey/rcm/survey_assembler.rcm create mode 100644 examples/autoresearch-survey/rcm/survey_brief.rcm create mode 100644 examples/autoresearch-survey/rcm/survey_outline.rcm create mode 100644 examples/autoresearch-survey/rcm/survey_writer.rcm create mode 100644 examples/autoresearch-survey/rcm/survey_writer_zh.rcm create mode 100644 examples/autoresearch-survey/rcm/zh_assemble.rcm create mode 100644 examples/autoresearch-survey/rcm/zh_frame.rcm create mode 100644 examples/autoresearch-survey/schema/candidate_pool.md create mode 100644 examples/autoresearch-survey/schema/card_plan.md create mode 100644 examples/autoresearch-survey/schema/expansion.md create mode 100644 examples/autoresearch-survey/schema/handoff.md create mode 100644 examples/autoresearch-survey/schema/judge_panel.md create mode 100644 examples/autoresearch-survey/schema/paper_card.md create mode 100644 examples/autoresearch-survey/schema/query_plan.md create mode 100644 examples/autoresearch-survey/schema/ranked_pool.md create mode 100644 examples/autoresearch-survey/schema/research_map.md create mode 100644 examples/autoresearch-survey/schema/section.md create mode 100644 examples/autoresearch-survey/schema/survey.md create mode 100644 examples/autoresearch-survey/schema/survey_brief.md create mode 100644 examples/autoresearch-survey/schema/survey_spec.md create mode 100644 examples/city-halfday/README.md create mode 100644 examples/city-halfday/city_halfday.rcm create mode 100644 examples/city-halfday/outputs/.gitignore create mode 100644 examples/city-halfday/outputs/.gitkeep create mode 100644 examples/city-halfday/prompts/city_halfday.txt create mode 100644 examples/cook-tonight/README.md create mode 100644 examples/cook-tonight/cook_tonight.rcm create mode 100644 examples/cook-tonight/outputs/.gitignore create mode 100644 examples/cook-tonight/outputs/.gitkeep create mode 100644 examples/cook-tonight/prompts/cook_tonight.txt create mode 100644 examples/folder-concierge/README.md create mode 100644 examples/folder-concierge/folder_concierge.rcm create mode 100644 examples/folder-concierge/outputs/.gitignore create mode 100644 examples/folder-concierge/outputs/.gitkeep create mode 100644 examples/folder-concierge/prompts/folder_concierge.txt create mode 100644 examples/general/README.md create mode 100644 examples/general/general.rcm create mode 100644 examples/general/outputs/.gitignore create mode 100644 examples/general/prompts/general.txt create mode 100644 examples/paper-scout/README.md create mode 100644 examples/paper-scout/outputs/.gitignore create mode 100644 examples/paper-scout/outputs/.gitkeep create mode 100644 examples/paper-scout/paper_scout.rcm create mode 100644 examples/paper-scout/prompts/paper_scout.txt create mode 100644 examples/project-maintainer/dispatch.toml create mode 100644 examples/project-maintainer/templates/health_check.rcm.tpl create mode 100644 examples/project-maintainer/templates/issue_triage.rcm.tpl create mode 100644 examples/project-maintainer/templates/mention_handler.rcm.tpl create mode 100644 examples/project-maintainer/templates/pr_review.rcm.tpl create mode 100644 examples/repo-snapshot/README.md create mode 100644 examples/repo-snapshot/outputs/.gitignore create mode 100644 examples/repo-snapshot/outputs/.gitkeep create mode 100644 examples/repo-snapshot/prompts/repo_snapshot.txt create mode 100644 examples/repo-snapshot/repo_snapshot.rcm create mode 100644 install.ps1 create mode 100644 install.sh create mode 100644 proto/rcm.proto create mode 100644 rustfmt.toml create mode 100644 scripts/audit-governance.mjs create mode 100644 scripts/capabilities.mjs create mode 100644 scripts/governance-config.mjs create mode 100644 scripts/install-hooks.mjs create mode 100755 scripts/render-formula.sh create mode 100644 scripts/run-gates.mjs create mode 100644 scripts/verify-commit-msg.mjs create mode 100644 scripts/verify-decisions.mjs create mode 100644 scripts/verify-doc-links.mjs create mode 100644 scripts/verify-manifest.mjs create mode 100644 scripts/verify-placeholders.mjs create mode 100644 scripts/verify-postmortems.mjs create mode 100644 scripts/verify-specs.mjs create mode 100644 sdks/python/AGENTS.md create mode 100644 sdks/python/README.md create mode 100644 sdks/python/examples/echo_agent.py create mode 100644 sdks/python/examples/math_mcp_server.py create mode 100644 sdks/python/examples/mcp_math.py create mode 100755 sdks/python/generate.sh create mode 100644 sdks/python/pyproject.toml create mode 100644 sdks/python/src/rcm/__init__.py create mode 100644 sdks/python/src/rcm/_pb2.py create mode 100644 sdks/python/src/rcm/_pb2_grpc.py create mode 100644 sdks/python/src/rcm/client.py create mode 100644 sdks/python/src/rcm/mcp.py create mode 100644 sdks/python/src/rcm/model.py create mode 100644 sdks/python/src/rcm/react.py create mode 100644 sdks/python/tests/test_react_policy.py diff --git a/.agents/skills/repo-decisions/SKILL.md b/.agents/skills/repo-decisions/SKILL.md new file mode 100644 index 00000000..14d420ef --- /dev/null +++ b/.agents/skills/repo-decisions/SKILL.md @@ -0,0 +1,46 @@ +--- +name: repo-decisions +description: Use when a change chooses among meaningful alternatives whose rationale may be revisited, or when writing, updating, reviewing, or superseding a MADR record +--- + +# Writing and updating decision records + +The decision log in [docs/decisions/](../../../docs/decisions/) is the durable home for architecture and process choices with genuine alternatives. Its directory entry defines the local or external standard; this skill carries the procedure. + +## When a decision record is required + +Write or update a decision record when a change chooses among meaningful alternatives and future maintainers may reasonably revisit the rationale. Do not create an ADR for routine implementation, a mechanical refactor, an obvious fix already defined by a regression test, or work that merely follows an accepted decision. Risk-boundary feature behavior belongs in a spec; commits retain change history. + +## Procedure: create a new record + +1. Find the next sequential number in `docs/decisions/` and take `max + 1`, zero-padded to 4 digits. Numbers are sequential starting at 0 and never change. +2. Create `docs/decisions/NNNN-kebab-case-title.md`. +3. Write the required sections in order: + - `## Status` — first line is the status value; optional `Class: <value>` on the next line. + - `## Context and Problem Statement` + - `## Decision Drivers` + - `## Considered Options` + - `## Decision Outcome` + - `## Pros and Cons of the Options` + - `## Links` +4. State the decision, what it beats, and what it gives up. `## Considered Options` lists genuine alternatives; `## Pros and Cons of the Options` records why the losers lost. When the record relies on external evidence or an implementation source, cite it descriptively with a stable URL, DOI, or versioned permalink in `## Links`; do not leave research or quantitative claims unlinked. +5. Cross-reference records with relative Markdown links (`[0001](0001-title.md)`), never bare numbers. +6. Run `node scripts/verify-decisions.mjs`. + +## Procedure: update an existing record + +- **Status change to Accepted**: the decision shipped. Keep the record current with what actually shipped (facts only — names, paths, structure — not the decision itself). Present tense. +- **Superseding**: never rewrite an old record into its opposite. Create a new record, mark the old one `Superseded by [NNNN](NNNN-title.md)`, and cross-link both. +- **Deprecation**: a record that is no longer recommended but has no single successor becomes `Deprecated`. +- **Rejected**: a proposal considered and declined. Keep it while its rationale prevents a tempting mistake; otherwise delete it. + +## Procedure: respond to a verify-decisions failure + +The verifier names the violation. Fix the named file: + +- `missing status value` — add the status line. +- `invalid status` — use Proposed/Accepted/Rejected/Deprecated or `Superseded by NNNN`. +- `invalid Class` — use architecture/process/testing/feature/bug-fix/simplification. +- `required sections missing or out of order` — add or reorder the seven required sections. +- `superseded target missing` — create the target record or point at an existing number. +- `numbers must be sequential` — renumber or fix the gap. diff --git a/.agents/skills/repo-governance/SKILL.md b/.agents/skills/repo-governance/SKILL.md new file mode 100644 index 00000000..3c5fe176 --- /dev/null +++ b/.agents/skills/repo-governance/SKILL.md @@ -0,0 +1,51 @@ +--- +name: repo-governance +description: Use when repository complexity, delivery, ownership, security, release, or incident signals change; when governance may be missing; or when adding, reviewing, deferring, or upgrading repo-seed capabilities +--- + +# Evolving repository governance + +Governance grows with the repository. Do not install every mechanism up front, and do not wait for a preventable failure before naming a new control. + +## Start with a read-only assessment + +Run `node scripts/audit-governance.mjs --json` and read `.repo-seed/manifest.json`. The audit observes discrete repository facts only; it does not authorize changes. + +- A `blocking` recommendation affects the safety boundary of the current work. Raise it before implementation. +- An `advisory` recommendation improves maturity without making the current work unsafe. Finish the requested work, then raise it in the handoff. +- A suppressed recommendation was already declined or deferred against the same facts. Do not ask again until its assessment hash changes. + +## Authority boundary + +You may autonomously inspect, audit, compare capability state, and produce a dry-run. Ask the user before you: + +- enable a capability or add its files; +- install or replace a Git hook; +- change governance policy or a source of truth; +- connect an external system or change remote permissions. + +Once a capability is enabled, untouched repo-seed-owned files may refresh through the normal upgrade channel. Preserve user-owned content and stop on conflicts. + +## Midstream capability workflow + +1. Name the new repository facts and the capability they make relevant. +2. Check whether an existing or external mechanism already satisfies the need. +3. Explain benefit, ongoing cost, files/processes added, and whether the recommendation blocks current work. +4. Ask for authorization. Record `enabled`, `external`, `deferred`, or `declined` plus the assessment hash and concise reason. +5. Preview with a dry-run, apply only the approved capability, then run `node scripts/run-gates.mjs`. +6. Update review instructions when the capability adds a new risk surface. + +## Promotion ladder + +Promote knowledge to the cheapest layer that reliably prevents recurrence: + +- one task-specific fact stays in its spec or plan; +- a repeated project convention becomes resident instruction or a focused skill; +- a rule that must hold without exception becomes a deterministic gate; +- a systemic escaped failure becomes a postmortem linked to the new guardrail. + +Do not convert every preference into a gate. Deterministic enforcement is for objective rules with low false-positive cost. + +## Unmanaged repositories + +When `.repo-seed/manifest.json` is absent, a read-only audit and adoption proposal are allowed. Never write the Core automatically. The adoption sequence is audit, dry-run, user authorization, incremental write, then verification. Preserve an existing source of truth rather than creating a duplicate. diff --git a/.agents/skills/repo-review/SKILL.md b/.agents/skills/repo-review/SKILL.md new file mode 100644 index 00000000..9fd93109 --- /dev/null +++ b/.agents/skills/repo-review/SKILL.md @@ -0,0 +1,72 @@ +--- +name: repo-review +description: Use when reviewing a pull request or a change in this repository — orients the reviewer to this repository's standards (AGENTS.md conventions, decision log, gates) and the review-specific checks that code alone cannot show +--- + +# Reviewing a change in this repository + +Read the diff, the owning docs, the decision log, and enough surrounding code to understand the design before judging it. **Blocking requirements** are hard: a violation blocks the change. **Manual checks** rank remaining risk by this project's real failure modes — apply the ones the change touches, not every one, to every change. + +## Sources of truth + +- [AGENTS.md](../../../AGENTS.md): standing repository rules. +- [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline. +- [docs/decisions/](../../../docs/decisions/): durable design rationale. Disagreement with a decision record is a design discussion, not an automatic veto. +- [docs/specs/](../../../docs/specs/): risk-triggered change contracts. +- [docs/testing.md](../../../docs/testing.md): risk-to-layer selection, test topology, evidence rules, and maintenance budget. +- [docs/architecture.md](../../../docs/architecture.md): the module map and seams. + +## Blocking requirements + +### Universal (applies to any repository) + +1. **New prose receives semantic review.** Critically review every added or changed Markdown passage, JSDoc, comment, prompt, description, diagnostic, and visible string. Verify required coverage, accuracy, placement, and editorial quality against the owning code or behavior; automated checks do not establish those properties. +2. **Docs match the code.** Config, defaults, errors, wire fields, events, and public behavior update the owning docs and JSDoc in the same diff. +3. **Contracts and decisions use the right artifact.** A risk-boundary change has an Approved spec; a durable choice with real alternatives has a decision record. Do not demand an ADR as a change log. +4. **Tests provide minimum sufficient behavior evidence.** Name the meaningful regression each changed test prevents and place its primary evidence at the lowest sufficiently real boundary. A fix links an existing deterministic reproduction or adds a regression test that fails before the fix and passes afterward; redundant coverage or implementation-only assertions do not satisfy this requirement. +5. **External-source provenance is retained.** If implementation is materially derived from a paper, article, community post, benchmark, research report, or copied/adapted code, cite it at the closest stable code location or link that location to a decision record whose `## Links` cites the source. A pull request, issue, prompt, or chat-only citation does not count; copied or adapted material also preserves applicable license and NOTICE requirements. + +### Project-specific (instantiated at seed time from this project's own rules) + +1. **Protocol and SDK mirrors stay synchronized:** changes to `proto/rcm.proto` + update the server decode/service boundary, generated Python stubs, SDK tests, + and the owning documentation in the same change. +2. **Main changes arrive through release promotion:** ordinary development targets + `dev`; a change to `main` must be the automated `release/v*` promotion PR after + the release artifacts and required checks succeed. +3. **Machine boundaries remain explicit:** `reactor` stays internal to `machine`, + and provider presets stay outside `machine`; new dependencies must respect the + workspace crate ownership documented in [architecture](../../../docs/architecture.md). +4. **External execution remains bounded:** every LLM, tool, MCP, LSP, and storage + operation preserves timeout, cancellation, and error-cleanup behavior. +5. **Credentials never enter repository state:** configuration uses environment + references or documented placeholders; literal keys, tokens, and secrets do + not appear in source, tests, examples, workflow files, or release artifacts. + +## Manual checks + +### Project-specific (instantiated at seed time from this project's stack and known failure modes) + +- **Model-facing contract:** inspect the exact prompts, tool schemas, diagnostics, + telemetry, and error text exposed to a model across CLI, accelerator, and server + paths; preserve stable fields and sanitized failure behavior. +- **Async lifecycle:** trace races before publication, cancellation during awaits, + timeout handling, child-process cleanup, and independent error reporting in + `machine`, MCP, LSP, and tool implementations. +- **Graph and resource state:** follow Flux routing, graph validation, resource + activation/deactivation, and state projection across `accelerator` and `machine`; + exercise the public crate API at the lowest real integration boundary. +- **Persistence and replay:** verify WAL writes, snapshots, trajectory envelopes, + replay effects, and cleanup on partial or failed runs in `storage`, `accelerator`, + and `server`. +- **CLI/SDK wiring:** check parser/compiler behavior, gRPC service mapping, generated + Python bindings, and example project paths together when an interface changes. + +### Universal fallbacks (apply where the project has no specific rule) + +- **Intent and interface contracts:** trace both sides of every changed interface. Confirm the implementation matches the change and any decision record, including errors, cancellation, ownership, and disposal. +- **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, check races before publication, cancellation during awaits, independent error reporting, and complete cleanup. +- **Human readability:** read changed code from its entry point downward. The primary path stays understandable without opening every helper. Challenge abstractions that only forward or rename, mixed abstraction levels, misleading or generic names, new synonyms for existing concepts, and logic outside its owning boundary. Block when these make behavior unreliable to infer, hide effects or cost, or violate established vocabulary or boundaries; otherwise report subjective polish as advisory. Line counts, parameter counts, duplication, and complexity scores are signals, not verdicts. +- **Scope and necessity:** map each abstraction, option, defensive copy, and compatibility path to its current contract and consumer. Challenge unrelated features and speculative generality. +- **Bounds cover the final operation:** probe tiny and exact limits, oversized chunks, and multibyte text for byte limits. +- **Test value and boundary:** identify the contract each changed test owns, challenge duplicate evidence, and use the real shipped entry path whenever packaging, configuration, process setup, or wiring is part of the risk. diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..e62b8286 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..be0f00c0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.md text eol=lf +*.mjs text eol=lf +*.json text eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 00000000..415e7f35 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,15 @@ +--- +name: Bug +about: Report a defect +title: "" +labels: bug +assignees: "" +--- + +## Expected behavior + +## Actual behavior + +## Reproduction steps + +## Environment diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 00000000..e3b0762f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,13 @@ +--- +name: Feature request +about: Suggest a capability +title: "" +labels: enhancement +assignees: "" +--- + +## Problem + +## Proposed capability + +## Alternatives considered diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..aecb88c1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 10 + - package-ecosystem: pip + directory: /sdks/python + schedule: + interval: monthly + open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 10 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..84afade8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +## What changed + +## Why + +## Verification +- [ ] Gates green (`node scripts/run-gates.mjs`) +- [ ] Verification names the regression risk and lowest sufficiently real test boundary; tests pass +- [ ] Approved spec linked for a risk-boundary change; durable decisions recorded only when genuine alternatives exist +- [ ] External sources are cited at the owning code or decision record, when applicable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9f456ef1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,133 @@ +name: CI + +on: + pull_request: + branches: [main, dev] + push: + branches: [main, dev] + workflow_dispatch: + +# Cancel superseded runs on the same PR / ref. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + CARGO_NET_RETRY: 10 + RUST_BACKTRACE: 1 + LINUX_BUILD_IMAGE: rust:bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa + LINUX_RUNTIME_IMAGE: debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818 + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: cargo fmt --all -- --check + run: cargo fmt --all -- --check + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - uses: Swatinem/rust-cache@v2 + with: + shared-key: rcm-ci + - name: cargo clippy --workspace --all-targets --tests + run: cargo clippy --workspace --all-targets --tests --locked -- -D warnings + + test: + name: build + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - uses: Swatinem/rust-cache@v2 + with: + shared-key: rcm-ci + - name: cargo build --workspace --all-targets + run: cargo build --workspace --all-targets --locked + - name: Install cargo-nextest + uses: taiki-e/install-action@v2 + with: + tool: cargo-nextest + - name: cargo nextest run + run: cargo nextest run --workspace --locked + - name: cargo test --doc + run: cargo test --workspace --doc --locked + + linux-release-compatibility: + name: Linux release compatibility (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-gnu + - aarch64-unknown-linux-gnu + steps: + - uses: actions/checkout@v4 + - name: Build in Debian Bookworm + env: + TARGET: ${{ matrix.target }} + run: | + docker run --rm \ + --platform linux/amd64 \ + --env TARGET \ + --volume "$GITHUB_WORKSPACE:/workspace" \ + --workdir /workspace \ + "$LINUX_BUILD_IMAGE" \ + bash -euxo pipefail -c ' + if [ "$TARGET" = "aarch64-unknown-linux-gnu" ]; then + apt-get update + apt-get install -y --no-install-recommends \ + gcc-aarch64-linux-gnu libc6-dev-arm64-cross + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc + fi + rustup target add "$TARGET" + cargo build --release --bin accelerate --target "$TARGET" --locked + ' + - name: Execute in a clean Debian Bookworm runtime + if: matrix.target == 'x86_64-unknown-linux-gnu' + run: | + docker run --rm \ + --platform linux/amd64 \ + --volume "$GITHUB_WORKSPACE:/workspace:ro" \ + "$LINUX_RUNTIME_IMAGE" \ + /workspace/target/x86_64-unknown-linux-gnu/release/accelerate --help + + hygiene: + name: dependency hygiene + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + shared-key: rcm-ci + - name: Install cargo-machete + uses: taiki-e/install-action@v2 + with: + tool: cargo-machete + - name: cargo machete --with-metadata + run: cargo machete --with-metadata + - name: Install cargo-deny + uses: taiki-e/install-action@v2 + with: + tool: cargo-deny + - name: cargo deny check + run: cargo deny check diff --git a/.github/workflows/maintainer.yml b/.github/workflows/maintainer.yml new file mode 100644 index 00000000..a65a5a95 --- /dev/null +++ b/.github/workflows/maintainer.yml @@ -0,0 +1,132 @@ +name: Maintainer + +on: + issues: + types: [opened] + pull_request: + types: [opened, synchronize] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + workflow_dispatch: + +# Each issue / PR gets its own lane. A new push or comment on the same PR +# cancels the in-progress run so the review always runs against the latest +# code. Schedule and manual triggers fall back to run_id so they never cancel +# each other. +concurrency: + group: maintainer-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + issues: write + pull-requests: write + contents: write + +env: + RUST_BACKTRACE: 1 + +jobs: + maintain: + name: dispatch + run + # Comment-triggered runs only proceed for human @maintainer mentions. The + # dispatch.toml filter is the second line of defense; gating here avoids + # spinning up a runner just to no-op on every chatty issue thread. + if: | + ( + github.event_name != 'issue_comment' && + github.event_name != 'pull_request_review_comment' + ) || + ( + contains(github.event.comment.body, '@maintainer') && + github.event.comment.user.type != 'Bot' && + github.actor != 'github-actions[bot]' + ) + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + KIMI_CODING_API_KEY: ${{ secrets.KIMI_CODING_API_KEY }} + steps: + - uses: actions/checkout@v4 + with: + # Full history so the code_change path can branch off main cleanly. + fetch-depth: 0 + + - name: Check for a published release + id: release + run: | + if gh release view --repo "$GITHUB_REPOSITORY" --json assets \ + --jq 'any(.assets[]; .name == "accelerate-x86_64-linux.tar.gz")' \ + | grep -qx true; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "No release is published yet; maintainer dispatch is skipped." + fi + + - name: Download accelerate from the latest release + if: steps.release.outputs.available == 'true' + # Decouples maintainer runs from the current HEAD: every run uses the + # last published, known-good binary. If main breaks the build the bot + # keeps working off the previous tag until a new release lands. + run: | + gh release download \ + --repo "$GITHUB_REPOSITORY" \ + --pattern 'accelerate-x86_64-linux.tar.gz' \ + --output accelerate.tar.gz + tar xzf accelerate.tar.gz + chmod +x accelerate + + - name: Configure git identity for code_change commits + if: steps.release.outputs.available == 'true' + # Only the CI shell sets repo-level git config — the agent's `git` tool + # rejects `git config` per AGENTS.md, so identity must be in place + # before the agent runs. + run: | + git config user.email "287349951+Goblin-d@users.noreply.github.com" + git config user.name "Goblin-d" + + - name: Render template for current event + id: dispatch + if: steps.release.outputs.available == 'true' + run: | + ACTION=$(jq -r '.action // ""' "$GITHUB_EVENT_PATH") + RENDERED=$(./accelerate dispatch \ + --config examples/project-maintainer/dispatch.toml \ + --event-name "$GITHUB_EVENT_NAME" \ + --action "$ACTION" \ + --event-path "$GITHUB_EVENT_PATH" \ + --verbose) + if [ -z "$RENDERED" ]; then + echo "no route matched event=$GITHUB_EVENT_NAME action=$ACTION; skipping" + else + echo "rendered=$RENDERED" >> "$GITHUB_OUTPUT" + echo "Rendered: $RENDERED" + fi + + - name: Check out PR head for in-context review reads + # pr_review's diff_review / intent_check read PR files via the fs tool, so + # the working tree must hold the PR's code — not the base we rendered from. + # Done AFTER rendering (templates + dispatch.toml come from base) and only + # for pull_request events. Files are only read, never built or executed, + # so PR code on disk is not run. The rendered .rcm lives in the gitignored + # .rcm-cache/ and survives the branch switch. Internal/branch PRs only — + # forks can't run this pipeline today (no secrets on fork pull_request). + if: ${{ steps.release.outputs.available == 'true' && steps.dispatch.outputs.rendered != '' && github.event_name == 'pull_request' }} + run: gh pr checkout ${{ github.event.pull_request.number }} + + - name: Run rendered pipeline + if: steps.release.outputs.available == 'true' && steps.dispatch.outputs.rendered != '' + run: ./accelerate run "${{ steps.dispatch.outputs.rendered }}" --stream + + - name: Upload rendered .rcm as audit artifact + # Keep even on failure — a busted run is exactly when we need the rendered + # pipeline to reproduce locally. + if: always() && steps.release.outputs.available == 'true' && steps.dispatch.outputs.rendered != '' + uses: actions/upload-artifact@v4 + with: + name: rendered-${{ github.event_name }}-${{ github.run_id }} + path: examples/project-maintainer/.rcm-cache/ + retention-days: 30 diff --git a/.github/workflows/release-accelerate.yml b/.github/workflows/release-accelerate.yml new file mode 100644 index 00000000..937637df --- /dev/null +++ b/.github/workflows/release-accelerate.yml @@ -0,0 +1,247 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + tag: + description: Release tag to publish (for example, v0.2.20) + required: true + type: string + +concurrency: + group: release-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + actions: write + +env: + CARGO_TERM_COLOR: always + CARGO_NET_RETRY: 10 + RUST_BACKTRACE: 1 + LINUX_BUILD_IMAGE: rust:bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa + LINUX_RUNTIME_IMAGE: debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818 + +jobs: + build: + name: Build ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + name: accelerate-x86_64-linux + archive: accelerate-x86_64-linux.tar.gz + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + name: accelerate-aarch64-linux + archive: accelerate-aarch64-linux.tar.gz + - os: macos-latest + target: aarch64-apple-darwin + name: accelerate-aarch64-darwin + archive: accelerate-aarch64-darwin.tar.gz + - os: macos-latest + target: x86_64-apple-darwin + name: accelerate-x86_64-darwin + archive: accelerate-x86_64-darwin.tar.gz + - os: windows-latest + target: x86_64-pc-windows-msvc + name: accelerate-x86_64-windows + archive: accelerate-x86_64-windows.zip + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + if: runner.os != 'Linux' + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build in Debian Bookworm + if: runner.os == 'Linux' + env: + TARGET: ${{ matrix.target }} + run: | + docker run --rm \ + --platform linux/amd64 \ + --env TARGET \ + --volume "$GITHUB_WORKSPACE:/workspace" \ + --workdir /workspace \ + "$LINUX_BUILD_IMAGE" \ + bash -euxo pipefail -c ' + if [ "$TARGET" = "aarch64-unknown-linux-gnu" ]; then + apt-get update + apt-get install -y --no-install-recommends \ + gcc-aarch64-linux-gnu libc6-dev-arm64-cross + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc + fi + rustup target add "$TARGET" + cargo build --release --bin accelerate --target "$TARGET" --locked + ' + + - name: Build on native runner + if: runner.os != 'Linux' + run: cargo build --release --bin accelerate --target ${{ matrix.target }} --locked + + - name: Verify Linux x86_64 runtime compatibility + if: matrix.target == 'x86_64-unknown-linux-gnu' + run: | + docker run --rm \ + --platform linux/amd64 \ + --volume "$GITHUB_WORKSPACE:/workspace:ro" \ + "$LINUX_RUNTIME_IMAGE" \ + /workspace/target/x86_64-unknown-linux-gnu/release/accelerate --help + + - name: Package (tar.gz) + if: matrix.os != 'windows-latest' + run: tar czf "${{ matrix.archive }}" -C target/${{ matrix.target }}/release accelerate + + - name: Package (zip) + if: matrix.os == 'windows-latest' + shell: pwsh + run: Compress-Archive -Path "target/${{ matrix.target }}/release/accelerate.exe" -DestinationPath "${{ matrix.archive }}" + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: ${{ matrix.archive }} + + publish: + name: Publish release and promotion PR + needs: build + runs-on: ubuntu-latest + steps: + - name: Resolve release tag + id: tag + env: + RELEASE_INPUT_TAG: ${{ inputs.tag }} + run: | + if [ -n "$RELEASE_INPUT_TAG" ]; then + TAG="$RELEASE_INPUT_TAG" + else + TAG="${GITHUB_REF#refs/tags/}" + fi + case "$TAG" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "release tag must match v<major>.<minor>.<patch>: $TAG" >&2; exit 1 ;; + esac + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Check out the release tag + uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.tag }} + fetch-depth: 0 + + - name: Validate tag provenance and CLI version + env: + TAG: ${{ steps.tag.outputs.tag }} + run: | + git fetch origin dev --no-tags + TAG_COMMIT=$(git rev-parse "$TAG^{commit}") + DEV_COMMIT=$(git rev-parse origin/dev) + git merge-base --is-ancestor "$TAG_COMMIT" "$DEV_COMMIT" || { + echo "$TAG is not reachable from origin/dev" >&2 + exit 1 + } + CLI_VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "cli") | .version') + EXPECTED_TAG="v${CLI_VERSION}" + [ "$TAG" = "$EXPECTED_TAG" ] || { + echo "tag $TAG does not match cli version $CLI_VERSION" >&2 + exit 1 + } + + - name: Download all platform artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Generate checksums + working-directory: dist + run: sha256sum -- * | tee checksums.txt + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.tag }} + files: dist/* + generate_release_notes: true + + - name: Compute Formula hashes + id: hashes + working-directory: dist + run: | + { + echo "linux_x86=$(sha256sum accelerate-x86_64-linux.tar.gz | awk '{print $1}')" + echo "linux_arm=$(sha256sum accelerate-aarch64-linux.tar.gz | awk '{print $1}')" + echo "darwin_arm=$(sha256sum accelerate-aarch64-darwin.tar.gz | awk '{print $1}')" + echo "darwin_x86=$(sha256sum accelerate-x86_64-darwin.tar.gz | awk '{print $1}')" + } >> "$GITHUB_OUTPUT" + + - name: Create release promotion branch + id: promotion + env: + TAG: ${{ steps.tag.outputs.tag }} + HASH_DARWIN_ARM: ${{ steps.hashes.outputs.darwin_arm }} + HASH_DARWIN_X86: ${{ steps.hashes.outputs.darwin_x86 }} + HASH_LINUX_ARM: ${{ steps.hashes.outputs.linux_arm }} + HASH_LINUX_X86: ${{ steps.hashes.outputs.linux_x86 }} + GH_TOKEN: ${{ github.token }} + run: | + TAG_CLEAN="${TAG#v}" + RELEASE_BRANCH="release/${TAG}" + ./scripts/render-formula.sh \ + "$TAG_CLEAN" \ + "$HASH_DARWIN_ARM" \ + "$HASH_DARWIN_X86" \ + "$HASH_LINUX_ARM" \ + "$HASH_LINUX_X86" \ + Formula/rcm.rb + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -C "$RELEASE_BRANCH" + git add Formula/rcm.rb + git commit -m "chore(release): update formula for $TAG" + git push origin "HEAD:$RELEASE_BRANCH" + PR_URL=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$RELEASE_BRANCH" --base main --state open --json url --jq '.[0].url') + if [ -z "$PR_URL" ]; then + PR_URL=$(gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$RELEASE_BRANCH" \ + --title "release: $TAG" \ + --body "This promotion PR publishes the complete $TAG release snapshot, including the generated Homebrew Formula. It is created and merged only by the release workflow after required checks pass.") + fi + PR_NUMBER=$(gh pr view "$PR_URL" --repo "$GITHUB_REPOSITORY" --json number --jq .number) + echo "branch=$RELEASE_BRANCH" >> "$GITHUB_OUTPUT" + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + + - name: Dispatch branch validation workflows + env: + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ steps.promotion.outputs.branch }} + PR_NUMBER: ${{ steps.promotion.outputs.pr_number }} + TAG: ${{ steps.tag.outputs.tag }} + run: | + gh workflow run ci.yml --repo "$GITHUB_REPOSITORY" --ref "$RELEASE_BRANCH" + gh workflow run release-promotion.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$RELEASE_BRANCH" \ + -f pr_number="$PR_NUMBER" \ + -f tag="$TAG" + + - name: Enable promotion auto-merge + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.promotion.outputs.pr_number }} + run: gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --auto --squash --delete-branch diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml new file mode 100644 index 00000000..85cc40e0 --- /dev/null +++ b/.github/workflows/release-promotion.yml @@ -0,0 +1,105 @@ +name: release-promotion + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + workflow_dispatch: + inputs: + pr_number: + description: Number of the release promotion pull request + required: true + type: string + tag: + description: Release tag (for example, v0.2.20) + required: true + type: string + +permissions: + contents: read + pull-requests: read + +jobs: + release-promotion: + name: release-promotion + runs-on: ubuntu-latest + steps: + - name: Resolve pull request metadata + id: pr + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + EVENT_PATH: ${{ github.event_path }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + PR_NUMBER=$(jq -r '.pull_request.number' "$EVENT_PATH") + BASE_REF=$(jq -r '.pull_request.base.ref' "$EVENT_PATH") + HEAD_REF=$(jq -r '.pull_request.head.ref' "$EVENT_PATH") + HEAD_SHA=$(jq -r '.pull_request.head.sha' "$EVENT_PATH") + BASE_SHA=$(jq -r '.pull_request.base.sha' "$EVENT_PATH") + else + PR_NUMBER="$INPUT_PR_NUMBER" + PR_JSON=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") + BASE_REF=$(printf '%s' "$PR_JSON" | jq -r '.base.ref') + HEAD_REF=$(printf '%s' "$PR_JSON" | jq -r '.head.ref') + HEAD_SHA=$(printf '%s' "$PR_JSON" | jq -r '.head.sha') + BASE_SHA=$(printf '%s' "$PR_JSON" | jq -r '.base.sha') + fi + if [ -z "$PR_NUMBER" ] || [ "$BASE_REF" != "main" ]; then + echo "promotion PR must target main" >&2 + exit 1 + fi + case "$HEAD_REF" in + release/v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "promotion PR head must be release/v<major>.<minor>.<patch>: $HEAD_REF" >&2; exit 1 ;; + esac + { + echo "number=$PR_NUMBER" + echo "base_sha=$BASE_SHA" + echo "head_sha=$HEAD_SHA" + echo "head_ref=$HEAD_REF" + } >> "$GITHUB_OUTPUT" + + - name: Check out promotion branch + uses: actions/checkout@v4 + with: + ref: ${{ steps.pr.outputs.head_sha }} + fetch-depth: 0 + + - name: Validate release tag and Formula + env: + TAG: ${{ inputs.tag || steps.pr.outputs.head_ref }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + run: | + if [ "${TAG#release/}" != "$TAG" ]; then + TAG="${TAG#release/}" + fi + case "$TAG" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "invalid release tag: $TAG" >&2; exit 1 ;; + esac + git fetch origin --tags --force + TAG_COMMIT=$(git rev-parse "$TAG^{commit}") + git merge-base --is-ancestor "$TAG_COMMIT" "$HEAD_SHA" || { + echo "promotion branch does not contain release tag $TAG" >&2 + exit 1 + } + [ -f Formula/rcm.rb ] || { echo "Formula/rcm.rb is missing" >&2; exit 1; } + ! grep -Fq 'rcm-dist' Formula/rcm.rb || { echo "Formula references archived repository" >&2; exit 1; } + grep -Fq 'recursive-context-machine/releases/download' Formula/rcm.rb + grep -Fq "version \"${TAG#v}\"" Formula/rcm.rb + HASH_COUNT=$(grep -Ec 'sha256 "[0-9a-fA-F]{64}"' Formula/rcm.rb) + [ "$HASH_COUNT" -eq 4 ] || { echo "expected four Formula SHA256 values, found $HASH_COUNT" >&2; exit 1; } + ruby -c Formula/rcm.rb + + - name: Verify promotion contains release metadata + env: + BASE_SHA: ${{ steps.pr.outputs.base_sha }} + run: | + CHANGED_FILES=$(git diff --name-only "$BASE_SHA"...HEAD) + printf '%s\n' "$CHANGED_FILES" + printf '%s\n' "$CHANGED_FILES" | grep -Fxq Formula/rcm.rb || { + echo "promotion PR must update Formula/rcm.rb" >&2 + exit 1 + } diff --git a/.gitignore b/.gitignore index ff616327..32c398da 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,6 @@ dist/ build/ *.log -# Research docs (auto-generated research reports) -docs/research/ - # Python venv/ .venv/ @@ -41,8 +38,11 @@ venv/ # Scout temp .scout-tmp/ -# Research notes -research/ - # Datasets (downloaded benchmarks, too large for git) -data/ \ No newline at end of file +data/ + +# Maintainer dispatcher cache — rendered .rcm files are runtime artifacts +.rcm-cache/ + +# Runtime trajectory artifacts (server default-on recording) +rcm-trajectories/ diff --git a/.repo-seed/manifest.json b/.repo-seed/manifest.json new file mode 100644 index 00000000..c341d823 --- /dev/null +++ b/.repo-seed/manifest.json @@ -0,0 +1,310 @@ +{ + "version": 1, + "repoSeedVersion": "0.6.2", + "lastSyncCommit": null, + "config": { + "license": "MIT", + "branchConvention": "dev", + "monorepo": true + }, + "governance": { + "paths": { + "architecture": "docs/architecture.md", + "testing": "docs/testing.md", + "decisions": "docs/decisions", + "specs": "docs/specs", + "postmortems": "docs/postmortems" + }, + "externalSources": {} + }, + "capabilities": { + "version": 1, + "items": { + "baseline": { + "state": "enabled" + }, + "spec": { + "state": "enabled", + "mode": "repo" + }, + "decisions": { + "state": "enabled" + }, + "postmortems": { + "state": "enabled" + }, + "ci": { + "state": "external", + "assessmentHash": "2819a6bb7c3926d7e5c96631f109b20d5634e98dc285316e2ca4d8c43b175ed5", + "reason": "existing repository mechanism detected during adoption" + }, + "release": { + "state": "enabled" + }, + "community": { + "state": "enabled" + }, + "codeowners": { + "state": "enabled" + }, + "monorepo": { + "state": "enabled" + }, + "hook": { + "state": "enabled" + } + } + }, + "files": [ + { + "path": "AGENTS.md", + "sha256": "d690af83d8d5925eebd15006e8633041a5357a9e650bc01092dec91072cb680c", + "category": "instruction", + "capability": "baseline", + "userModified": true + }, + { + "path": "CLAUDE.md", + "sha256": "573138e19d3359c451b7898112a03167e4e7bf356ecd698dc87d933d37de0b96", + "category": "instruction", + "capability": "baseline" + }, + { + "path": "docs/AGENTS.md", + "sha256": "134e5a28d1507fac4bca101df9b4b096fbb020431d5b40af8fb63eaccf5785ff", + "category": "docs", + "capability": "baseline" + }, + { + "path": "docs/architecture.md", + "sha256": "135e9e3812edafbc1af5c849660ea1416aab0bbb51aeedd1cebea72d0d6aca19", + "category": "docs", + "capability": "baseline" + }, + { + "path": "docs/development.md", + "sha256": "d443fef13673095024d5d061b2b8ab4efea09703c691eee42789fd59965d21ae", + "category": "docs", + "capability": "baseline" + }, + { + "path": "docs/testing.md", + "sha256": "b52ae96153cea319b5e92cf9621ebb56000206835d6666514e440c765f0fdfd6", + "category": "docs", + "capability": "baseline" + }, + { + "path": ".agents/skills/repo-review/SKILL.md", + "sha256": "b0c935491592d2cc61008fc91c95af1f3fb39041e1f419ace80e6c3c41605bee", + "category": "skill", + "capability": "baseline" + }, + { + "path": ".agents/skills/repo-governance/SKILL.md", + "sha256": "884a9c26f73f07c705e6273cb7bc21ede51917c16a8683e772da61d43ebb90a0", + "category": "skill", + "capability": "baseline" + }, + { + "path": "scripts/capabilities.mjs", + "sha256": "74fc315d590a4d9c491ff4034f298f1288f649b4e00b3303d07bbb6d28adcc51", + "category": "gate", + "capability": "baseline" + }, + { + "path": "scripts/governance-config.mjs", + "sha256": "37f39619b0aa082d0107d1c8d91144489c89b01f1b31602d40dc0b3f2500138e", + "category": "gate", + "capability": "baseline" + }, + { + "path": "scripts/audit-governance.mjs", + "sha256": "9644fe401ba2eba4b81bbccacb8dee86026267597fa50f9c0efd8a0964df79b8", + "category": "gate", + "capability": "baseline" + }, + { + "path": "scripts/run-gates.mjs", + "sha256": "7442033b0a7239fa5bdb5afe91cf1018c9549e2b21be8ee6f595f524be84ed4c", + "category": "gate", + "capability": "baseline" + }, + { + "path": "scripts/verify-doc-links.mjs", + "sha256": "130093c964f058c07b0cbb2022f792c4d5fcb6d37884b3ec25e88fe96fc9d17d", + "category": "gate", + "capability": "baseline" + }, + { + "path": "scripts/verify-placeholders.mjs", + "sha256": "c8cb05ce91cd15864e1c803327be4c1ddb9c7a4c836f73aa311f02a881c3f961", + "category": "gate", + "capability": "baseline" + }, + { + "path": "scripts/verify-manifest.mjs", + "sha256": "0be76f51cc1c873296ef49fe04d79b6370fab6f0c9d723fcfb22cb6966aeca68", + "category": "gate", + "capability": "baseline" + }, + { + "path": "scripts/install-hooks.mjs", + "sha256": "fbb14a4889e322ddc4ce326a953c286b70ab881cfbbb8beab9a7bc237dc3b7e8", + "category": "gate", + "capability": "baseline" + }, + { + "path": "CONTRIBUTING.md", + "sha256": "80ce6de99190bf2542da656bb3e07256c86184508538fa7bbfdfd55c7d7c9fc6", + "category": "meta", + "capability": "baseline" + }, + { + "path": "LICENSE", + "sha256": "0fcd84b1943b937895464612a1ea725a3409fa4392361fbc2127bdbbba6b94a1", + "category": "meta", + "capability": "baseline" + }, + { + "path": ".editorconfig", + "sha256": "e3146b348c6eda13df225e5ebcfe1a2b861af1025d2e59016895b33a81fc7a3f", + "category": "meta", + "capability": "baseline" + }, + { + "path": ".gitattributes", + "sha256": "5c3b2ff1cf34c6e0a482bdccbef050bd662149fd0084341ad18180e84fcde618", + "category": "meta", + "capability": "baseline" + }, + { + "path": ".repo-seed/update-strategy.md", + "sha256": "59e7398379df413e36eb508fb1637f787afff55fe5635adc262f2b914a16e686", + "category": "meta", + "capability": "baseline" + }, + { + "path": ".github/pull_request_template.md", + "sha256": "5b2db85304170bc600cbb68b98a6d758dfdc5cd6dc51a85677df111cf2720ff0", + "category": "github", + "capability": "baseline" + }, + { + "path": ".github/ISSUE_TEMPLATE/bug.md", + "sha256": "011aaa5301e6fe2db8c2ef70911da79b122c2ca4235105fc917ee3ea8574c43f", + "category": "github", + "capability": "baseline" + }, + { + "path": ".github/ISSUE_TEMPLATE/feature.md", + "sha256": "328dc950ab01003d7e41495f37407eb07f6828f9bc5ffe6053590f2a6f7d53eb", + "category": "github", + "capability": "baseline" + }, + { + "path": "docs/specs/README.md", + "sha256": "bcdff4346cb7ebb0128a19a2177ddc30269d8f191735b2f22585af2725ab3c69", + "category": "docs", + "capability": "spec" + }, + { + "path": "scripts/verify-specs.mjs", + "sha256": "8c92aa03abc3dda851b2033615661f47aad3723142f06e17f21a7c9c9783ee59", + "category": "gate", + "capability": "spec" + }, + { + "path": "docs/decisions/README.md", + "sha256": "012c0b61e20fce9c270bbf26c0beb1d2e6642f53d1125c95caf1cf56a8fb0a9a", + "category": "docs", + "capability": "decisions" + }, + { + "path": "docs/decisions/0000-use-markdown-architectural-decision-records.md", + "sha256": "77f5a0631fa74ff5daa9948f6ecb0a7e1441e4c7657fdb325ae1a7d627628513", + "category": "docs", + "capability": "decisions" + }, + { + "path": "docs/decisions/0001-repo-seed-is-a-skill-not-a-template.md", + "sha256": "171ad64e4848e9c3620a7a656d3ed680caa64bcd4c4124adf2d6045c8b5dce46", + "category": "docs", + "capability": "decisions" + }, + { + "path": "docs/decisions/0002-self-governing-repository-design.md", + "sha256": "4d9142ebc29979ec1cdc11463544e98b9108064981af4b8627d3a2f8fded20c5", + "category": "docs", + "capability": "decisions" + }, + { + "path": "docs/decisions/0003-repo-review-instantiated-per-project.md", + "sha256": "51d0f26bbe73bcdca7fe5e487ecd6382b3c28a33f174cb58d7223252951223b2", + "category": "docs", + "capability": "decisions" + }, + { + "path": ".agents/skills/repo-decisions/SKILL.md", + "sha256": "b871e064a473c5e117bef302b5b47e9fac6eacda33625a30c554b6bf693aee6d", + "category": "skill", + "capability": "decisions" + }, + { + "path": "scripts/verify-decisions.mjs", + "sha256": "2e100efd61d5b16bbd559988f36e205352716c55662ce4d38d6b9933df1e9ac8", + "category": "gate", + "capability": "decisions" + }, + { + "path": "docs/postmortems/README.md", + "sha256": "2c25abcb13a98f5c3c7f4408665c24c80e47855e5540dfa83b76777e6c46f3c3", + "category": "docs", + "capability": "postmortems" + }, + { + "path": "scripts/verify-postmortems.mjs", + "sha256": "9d8d6332aea3e6010fda2c6d5f0715d62ef08e2b0d54734bf0cdde67dbd0e9a2", + "category": "gate", + "capability": "postmortems" + }, + { + "path": "docs/release-policy.md", + "sha256": "ca67509cefef0dd43fe5ac445aa6f9f9956c59072f03be098dc3f18fd0a34696", + "category": "docs", + "extension": "release", + "capability": "release" + }, + { + "path": "scripts/verify-commit-msg.mjs", + "sha256": "20bdaa297962e323234984e41d24267c59de7d697a1f9eaa4287618b15330fde", + "category": "gate", + "extension": "release", + "capability": "release" + }, + { + "path": "SECURITY.md", + "sha256": "4e868653a3e26b1793a44089a87401a034e37e9d22608f29ef1f906661a83dfd", + "category": "meta", + "extension": "community", + "capability": "community" + }, + { + "path": "CODE_OF_CONDUCT.md", + "sha256": "b91ebc98369c63bb0ef55ac99fc8d819d02b9f7e8e313821ae944816a9e5eab7", + "category": "meta", + "extension": "community", + "capability": "community" + }, + { + "path": "CODEOWNERS", + "sha256": "ee67061f35183ca5b98b6f39d253851f8aaafa2c64bc97c26c4ace0b0d23c9a5", + "category": "meta", + "extension": "codeowners", + "capability": "codeowners" + } + ], + "artifactPolicy": { + "version": 1, + "legacy": [] + } +} diff --git a/.repo-seed/update-strategy.md b/.repo-seed/update-strategy.md new file mode 100644 index 00000000..e37b46fc --- /dev/null +++ b/.repo-seed/update-strategy.md @@ -0,0 +1,31 @@ +# repo-seed update strategy + +The manifest is the authority for seeded ownership, capability state, governance paths, external sources of truth, and progressive artifact enforcement. Re-run the repo-seed skill to upgrade this layer; do not hand-copy upstream files. + +## File ownership + +- An untouched managed file may refresh from the current seed. +- A user-modified or user-owned file is preserved unless the user explicitly authorizes replacement or a merge. +- A file absent from the manifest is user content and is never overwritten or deleted automatically. +- A deleted managed file is not silently restored. +- Record-only updates hashes and adds newly introduced Core files that already exist; it does not edit governed files. + +## Capability state + +Capability state is `enabled`, `external`, `deferred`, or `declined`. Enabled capabilities remain managed when a later run omits legacy `--extensions` flags. Deferred or declined recommendations store a normalized assessment hash and are not raised again until relevant facts or assessment rules change. Existing equivalent systems are registered as `external`, not duplicated. + +Core capability state includes `baseline`, `spec`, `decisions`, and `postmortems`. Baseline remains enabled in every managed repository; artifact capabilities may be repository-owned (`enabled`) or point to an adopted source (`external`). Core is never deferred or declined. + +Read-only audits and dry-runs require no additional authority. Enabling a capability, installing a hook, changing governance policy or source-of-truth, connecting an external system, or removing capability files requires explicit user authorization. + +## Adoption and paths + +`governance.paths` records repository-specific architecture, testing, decision, Spec, and postmortem homes. `governance.externalSources` records authoritative systems outside those paths. Verifiers and generated links read this configuration so adoption does not force a migration or create a second authority. + +## Artifact policy + +New or materially changed Specs and postmortems use Artifact-Version 1. `artifactPolicy.legacy` may grandfather an existing unversioned record only at its recorded path and hash; editing that record requires migration to the current format. + +## Safety + +repo-seed never reads secrets, commits, pushes, changes remote services, installs hooks without authorization, overwrites custom hooks, or deletes user files. diff --git a/AGENTS.md b/AGENTS.md index eb1fb2c1..30e5fc7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,88 +1,75 @@ -# RCM Development Guide - -This document encodes the development rules for RCM. All agents editing this codebase -must follow these conventions. Do not override unless explicitly instructed by the user. - -## Comments - -- Comments explain **why**, not **what**. The code already says what it does. -- Doc comments on public APIs are acceptable only when the purpose is not obvious from - the name and signature alone. -- No inline comments that restate the next line of code. -- No "defaults to ..." or "(... minutes)" decay comments that specify values — they go - stale on every config change. Types and const names are the source of truth. -- Section dividers like `// ── Foo ──` are not allowed in files under 200 lines. -- No TODO comments in published docs — use real issue tracking. - -## Naming - -- Single-letter variable names (`a`, `b`, `c`, `e`, `r`, `s`, `v`, etc.) are not - allowed. Every variable must describe what it holds. The only exceptions are: - `i` / `j` for loop indices, and `f` in closures like `|f| f.id == id`. -- Abbreviated names are acceptable when they are domain-standard and unambiguous - across the codebase: `frag` (fragment), `ctx` (context), `env` (environment), - `msg` (message), `args` (arguments), `req` (request). If in doubt, spell it out. -- Single-letter generic parameters are not allowed. Use `impl Trait` in argument - position, or a descriptive name. -- Enum variants and function names must be self-explanatory without a doc comment. - If a variant needs a comment to explain its purpose, rename it. -- `Catch`/`Drop` style verb confusion is not allowed. "Catch" is error handling, - "Drop" is deallocation. Use `Activate`/`Deactivate` for tool toggling. -- Avoid `XxxInfo`, `XxxDetail`, `XxxData` — find a real name. -- The same concept must be named consistently across all files. If `completion.rs` - calls it `endpoint`, `machine.rs` must not call it `base_url` for the same thing. - -## Architecture - -- `reactor` is internal to `machine`. It is a plain async function, not a trait, - not injected, not visible to `accelerator`. -- `machine` does not know about provider presets (DeepSeek, Groq, etc.). - Protocol + endpoint is sufficient. -- Policy receives `&self` — use atomics for internal state, not `Mutex`. -- All execution (LLM calls, tool calls) uses `tokio::time::timeout`. - Defaults are defined once as module-level constants. - -## Logging - -- Use `tracing` macros (`debug!`, `info!`, `warn!`, `trace!`) — never `println!` - or `eprintln!` for operational output. -- Every failed outcome must log at `warn!` with structured fields (`?error` or named - fields), not a bare string. - -## Execution - -- Do not autonomously modify code unless the user explicitly asks. -- When the user asks you to change code, wait for their confirmation before - starting. Do not pre-emptively implement. -- Every batch of changes must be committed before continuing to the next - topic. Do not accumulate uncommitted work. -- Commits must be atomic per logical change. Do not squash unrelated - refactors into a single commit. -- Only commit changes you authored. Do not include, revert, or modify - other people's work (e.g., Cargo.lock updates, dependency bumps, - files created by other agents or users) unless explicitly instructed. - -## Testing - -- Tests go in `tests/`, not inline modules. -- Test tools and test policies are defined in the test file, not in `src/`. -- Never test mock infrastructure (helpers, builders, replay policies). Tests must - exercise the real crate API — `Machine::run`, `Context::append`, `Fragment::system`, - etc. If a test only asserts the behaviour of a `SeqPolicy` helper, delete it. -- Never test getters (`tool_name_and_description`, `new_context_is_empty`). Test - **behaviour**: "does this produce the right side-effect?" not "does this struct - field match what I just set it to?" -- Prefer edge cases over CRUD enumeration. One test for "remove and verify length" - is enough — don't write three variations. -- Every `#[should_panic]` test must verify the panic message (`expected = "..."`). -- Tests must be fast. No test may depend on external services (LLM APIs) or long - timeouts. Tests that currently depend on the reactor loop use SeqPolicy without - `Action::Halt` to avoid HTTP calls. - -## Research - -- All research materials (literature surveys, benchmark analysis, paper summaries, - etc.) go under `research/`, not in the project root. -- Downloaded papers (PDFs) go in `research/` as well. -- Keep `research/` organized — group related files into subdirectories by topic - when a single topic accumulates multiple files. +# AGENTS.md + +RCM (Recursive Context Machine) is a Rust workspace for building and executing +composable context-machine pipelines for LLM agents. + +## Repository governance + +This repository's governance layer is managed by repo-seed; product code remains +repository-owned. `.repo-seed/manifest.json` records managed files and capability +state. + +- Use `repo-review` for change and pull-request review. +- Use `repo-decisions` for durable choices with meaningful alternatives. +- Use `repo-governance` when complexity, ownership, security, release, or incident + signals change. +- Use the global `repo-seed` skill for governance seeding and upgrades. + +## Repository layout + +See [docs/architecture.md](docs/architecture.md) for the module map and seams. +Rust crates live in `crates/`; the Python SDK lives in `sdks/python/`; runnable +RCM projects live in `examples/`; the protobuf contract is `proto/rcm.proto`. + +## Commands + +- Test: `cargo nextest run --workspace --locked` +- Documentation tests: `cargo test --workspace --doc --locked` +- Lint: `cargo fmt --all -- --check && cargo clippy --workspace --all-targets --tests --locked -- -D warnings` +- Gates: `node scripts/run-gates.mjs` + +Run the relevant tests and gates before every commit. `protoc` and the stable Rust +toolchain are required for the full workspace checks. + +## Development rules + +- Develop on `dev` or a short-lived feature branch based on `dev`. +- `main` is protected and only receives release promotion pull requests. +- Comments explain why and provenance, not what the next line already says. +- Use descriptive names; single-letter variables and generic parameters are not + allowed except loop indices `i`/`j` and the documented closure exception. +- Use `tracing` for operational logging; failed outcomes log at `warn!` with + structured error fields. +- Keep `reactor` internal to `machine`; provider presets do not belong in `machine`. +- Use module-level timeout constants for every LLM and tool execution. +- `Environment::new` is an honest host snapshot; use `Environment::empty` for + explicitly sandboxed scenarios. + +## Change contracts + +- Risk-boundary changes start with an Approved spec in [docs/specs/](docs/specs/). +- Durable alternatives belong in [docs/decisions/](docs/decisions/), not changelogs. +- A subtle or systemic escaped failure earns a postmortem linked to a permanent + guardrail in [docs/postmortems/](docs/postmortems/). +- Changes to `proto/rcm.proto` regenerate the Python SDK and update server tests in + the same change. + +## Security and scope + +- Never read `.env` files or commit credentials. +- Never modify files outside the requested scope. +- Never force-push or directly push `main`. +- Never commit or push unless the user explicitly asks. + +## Documentation and skills + +Follow [docs/AGENTS.md](docs/AGENTS.md) for document placement and hygiene, and +[docs/testing.md](docs/testing.md) for risk-adjusted evidence. Seeded governance +files are upgraded only by rerunning the repo-seed skill. + +- [repo-review](.agents/skills/repo-review/SKILL.md) — semantic review policy. +- [repo-decisions](.agents/skills/repo-decisions/SKILL.md) — MADR decision records. +- [repo-governance](.agents/skills/repo-governance/SKILL.md) — capability assessment. + +Enabled optional capabilities include CI, release policy, community health files, +CODEOWNERS, monorepo subtree instructions, and authorized local hooks. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f6aa6c02 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +@AGENTS.md diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..2e12f206 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,10 @@ +# Path-level owners + +# Each line maps a path to the GitHub user/team responsible for review. Release +# promotion remains zero-approval and is enforced by the release-only gate. + +* @EricSanchezok +/docs/ @EricSanchezok +/scripts/ @EricSanchezok +/.agents/ @EricSanchezok +/.github/ @EricSanchezok diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..410eef2e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,45 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards and will take appropriate and fair corrective action in response to any behavior they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at @EricSanchezok. + +All complaints will be reviewed and investigated promptly and fairly. Community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b22d734c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing + +Thank you for considering contributing to this project. This repository is agent-native: it expects both human and AI contributors to follow the same governance loop. + +## Governance loop + +1. Read [AGENTS.md](AGENTS.md) and [docs/AGENTS.md](docs/AGENTS.md) before making changes. +2. Write a decision record only when the change selects among meaningful alternatives; risk-boundary changes use an Approved spec in [docs/specs/](docs/specs/). +3. Run the gates before committing; an authorized pre-commit hook enforces the same runner: + ```sh + node scripts/run-gates.mjs + ``` +4. Name the regression risk and add or update the smallest test set that proves the affected contract; a fix without reproducible evidence is a rumor. +5. If the change closes a subtle or systemic escaped failure, add a postmortem in [docs/postmortems/](./docs/postmortems/) linked to the permanent guardrail. + +## Decision records + +Durable architecture and process decisions use MADR in `docs/decisions/`. Follow the `repo-decisions` skill and [.repo-seed/update-strategy.md](.repo-seed/update-strategy.md) for the format. A superseded record is never rewritten; a new record supersedes it. + +## Pull requests + +Use the pull request template. Ensure the verification checklist is complete. Reviewers: use the `repo-review` skill. + +## Code of conduct + +Be respectful and constructive. Assume good faith. diff --git a/Cargo.lock b/Cargo.lock index 95be6511..a6699f1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,26 +6,22 @@ version = 4 name = "accelerator" version = "0.1.0" dependencies = [ + "base64", "chrono", + "futures-util", "globset", "ignore", "lsp-types", "machine", "regex", - "reqwest 0.12.28", + "reqwest", + "scraper", "serde", "serde_json", + "storage", + "tempfile", "tokio", "tracing", - "tracing-appender", - "tracing-subscriber", - "tree-sitter", - "tree-sitter-go", - "tree-sitter-java", - "tree-sitter-javascript", - "tree-sitter-python", - "tree-sitter-rust", - "tree-sitter-typescript", "url", "utils", ] @@ -36,21 +32,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "const-random", - "getrandom 0.3.4", - "once_cell", - "serde", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.4" @@ -60,24 +41,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -93,12 +56,6 @@ dependencies = [ "alloc-no-stdlib", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -144,7 +101,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -155,5213 +112,1586 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] -name = "approx" -version = "0.5.1" +name = "as-any" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] +checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" [[package]] -name = "ar_archive_writer" -version = "0.5.1" +name = "async-compression" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ - "object", + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", ] [[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arc-swap" -version = "1.9.1" +name = "async-stream" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" dependencies = [ - "rustversion", + "async-stream-impl", + "futures-core", + "pin-project-lite", ] [[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" +name = "async-stream-impl" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] -name = "arrayref" -version = "0.3.9" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] -name = "arrayvec" -version = "0.7.6" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "arrow" -version = "56.2.0" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e833808ff2d94ed40d9379848a950d995043c7fb3e81a30b383f4c6033821cc" -dependencies = [ - "arrow-arith", - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-csv", - "arrow-data", - "arrow-ipc", - "arrow-json", - "arrow-ord", - "arrow-row", - "arrow-schema", - "arrow-select", - "arrow-string", -] +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "arrow-arith" -version = "56.2.0" +name = "aws-lc-rs" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad08897b81588f60ba983e3ca39bda2b179bdd84dced378e7df81a5313802ef8" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "chrono", - "num", + "aws-lc-sys", + "zeroize", ] [[package]] -name = "arrow-array" -version = "56.2.0" +name = "aws-lc-sys" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8548ca7c070d8db9ce7aa43f37393e4bfcf3f2d3681df278490772fd1673d08d" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ - "ahash", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "chrono", - "chrono-tz", - "half", - "hashbrown 0.16.1", - "num", + "cc", + "cmake", + "dunce", + "fs_extra", ] [[package]] -name = "arrow-buffer" -version = "56.2.0" +name = "axum" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e003216336f70446457e280807a73899dd822feaf02087d31febca1363e2fccc" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ + "axum-core", "bytes", - "half", - "num", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", ] [[package]] -name = "arrow-cast" -version = "56.2.0" +name = "axum-core" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "919418a0681298d3a77d1a315f625916cb5678ad0d74b9c60108eb15fd083023" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "arrow-select", - "atoi", - "base64 0.22.1", - "chrono", - "comfy-table", - "half", - "lexical-core", - "num", - "ryu", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", ] [[package]] -name = "arrow-csv" -version = "56.2.0" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa9bf02705b5cf762b6f764c65f04ae9082c7cfc4e96e0c33548ee3f67012eb" -dependencies = [ - "arrow-array", - "arrow-cast", - "arrow-schema", - "chrono", - "csv", - "csv-core", - "regex", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "arrow-data" -version = "56.2.0" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5c64fff1d142f833d78897a772f2e5b55b36cb3e6320376f0961ab0db7bd6d0" -dependencies = [ - "arrow-buffer", - "arrow-schema", - "half", - "num", -] +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "arrow-ipc" -version = "56.2.0" +name = "bitflags" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3594dcddccc7f20fd069bc8e9828ce37220372680ff638c5e00dea427d88f5" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "arrow-select", - "flatbuffers", - "lz4_flex", - "zstd", -] +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] -name = "arrow-json" -version = "56.2.0" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88cf36502b64a127dc659e3b305f1d993a544eab0d48cce704424e62074dc04b" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", - "arrow-schema", - "chrono", - "half", - "indexmap 2.14.0", - "lexical-core", - "memchr", - "num", - "serde", - "serde_json", - "simdutf8", + "generic-array", ] [[package]] -name = "arrow-ord" -version = "56.2.0" +name = "brotli" +version = "8.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8f82583eb4f8d84d4ee55fd1cb306720cddead7596edce95b50ee418edf66f" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "arrow-select", + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", ] [[package]] -name = "arrow-row" -version = "56.2.0" +name = "brotli-decompressor" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d07ba24522229d9085031df6b94605e0f4b26e099fb7cdeec37abd941a73753" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "half", + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] -name = "arrow-schema" -version = "56.2.0" +name = "bstr" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3aa9e59c611ebc291c28582077ef25c97f1975383f1479b12f3b9ffee2ffabe" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ - "bitflags 2.11.1", + "memchr", "serde", - "serde_json", ] [[package]] -name = "arrow-select" -version = "56.2.0" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c41dbbd1e97bfcaee4fcb30e29105fb2c75e4d82ae4de70b792a5d3f66b2e7a" -dependencies = [ - "ahash", - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "num", -] +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "arrow-string" -version = "56.2.0" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f5183c150fbc619eede22b861ea7c0eebed8eaac0333eaa7f6da5205fd504d" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "arrow-select", - "memchr", - "num", - "regex", - "regex-syntax", -] +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "as-any" -version = "0.3.2" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "as-slice" -version = "0.2.1" +name = "cc" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ - "stable_deref_trait", + "find-msvc-tools", + "jobserver", + "libc", + "shlex", ] [[package]] -name = "async-channel" -version = "2.5.0" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "async-compression" -version = "0.4.19" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06575e6a9673580f52661c92107baabffbf41e2141373441cbcdc47cb733003c" -dependencies = [ - "bzip2 0.5.2", - "flate2", - "futures-core", - "memchr", - "pin-project-lite", - "tokio", - "xz2", - "zstd", - "zstd-safe", -] +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "async-lock" -version = "3.4.2" +name = "chrono" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", ] [[package]] -name = "async-recursion" -version = "1.1.1" +name = "clap" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "clap_builder", + "clap_derive", ] [[package]] -name = "async-stream" -version = "0.3.6" +name = "clap_builder" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] -name = "async-stream-impl" -version = "0.3.6" +name = "clap_derive" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ + "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] -name = "async-trait" -version = "0.1.89" +name = "clap_lex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cli" +version = "0.2.20" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "accelerator", + "anyhow", + "chrono", + "clap", + "crossterm", + "machine", + "serde", + "serde_json", + "tokio", + "toml", + "tracing", + "tracing-subscriber", ] [[package]] -name = "async_cell" -version = "0.2.3" +name = "cmake" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "447ab28afbb345f5408b120702a44e5529ebf90b1796ec76e9528df8e288e6c2" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ - "loom", + "cc", ] [[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" +name = "colorchoice" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "autocfg" -version = "1.5.0" +name = "combine" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] [[package]] -name = "av-scenechange" -version = "0.14.1" +name = "compression-codecs" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", + "brotli", + "compression-core", + "flate2", + "memchr", ] [[package]] -name = "av1-grain" -version = "0.2.5" +name = "compression-core" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom 8.0.0", - "num-rational", - "v_frame", -] +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] -name = "avif-serialize" -version = "0.8.9" +name = "cookie" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "arrayvec", + "percent-encoding", + "time", + "version_check", ] [[package]] -name = "aws-config" -version = "1.8.16" +name = "cookie_store" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sdk-sts", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 1.4.0", + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", "time", - "tokio", - "tracing", "url", ] [[package]] -name = "aws-credential-types" -version = "1.2.14" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "zeroize", + "core-foundation-sys", + "libc", ] [[package]] -name = "aws-lc-rs" -version = "1.16.3" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "aws-lc-sys", - "zeroize", + "core-foundation-sys", + "libc", ] [[package]] -name = "aws-lc-sys" -version = "0.40.0" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "aws-runtime" -version = "1.7.3" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "aws-credential-types", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-eventstream", - "aws-smithy-http", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "bytes-utils", - "fastrand", - "http 1.4.0", - "http-body 1.0.1", - "percent-encoding", - "pin-project-lite", - "tracing", - "uuid", + "libc", ] [[package]] -name = "aws-sdk-bedrockruntime" -version = "1.130.0" +name = "crc32c" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2f7bca252e3c5c8f0ed12c5501bf8b0fbadb937cd9fdd71a0ebd9d7526540f" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-sigv4", - "aws-smithy-async", - "aws-smithy-eventstream", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-types", - "bytes", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "http-body-util", - "regex-lite", - "tracing", + "rustc_version", ] [[package]] -name = "aws-sdk-sts" -version = "1.103.0" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "aws-credential-types", - "aws-runtime", - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", - "aws-smithy-query", - "aws-smithy-runtime", - "aws-smithy-runtime-api", - "aws-smithy-types", - "aws-smithy-xml", - "aws-types", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "regex-lite", - "tracing", + "cfg-if", ] [[package]] -name = "aws-sigv4" -version = "1.4.3" +name = "crossbeam-deque" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ - "aws-credential-types", - "aws-smithy-eventstream", - "aws-smithy-http", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "form_urlencoded", - "hex", - "hmac", - "http 0.2.12", - "http 1.4.0", - "percent-encoding", - "sha2 0.11.0", - "time", - "tracing", + "crossbeam-epoch", + "crossbeam-utils", ] [[package]] -name = "aws-smithy-async" -version = "1.2.14" +name = "crossbeam-epoch" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ - "futures-util", - "pin-project-lite", - "tokio", + "crossbeam-utils", ] [[package]] -name = "aws-smithy-eventstream" -version = "0.60.20" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" -dependencies = [ - "aws-smithy-types", - "bytes", - "crc32fast", -] +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "aws-smithy-http" -version = "0.63.6" +name = "crossterm" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "aws-smithy-eventstream", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "bytes-utils", - "futures-core", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "percent-encoding", - "pin-project-lite", - "pin-utils", - "tracing", + "bitflags 2.11.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", ] [[package]] -name = "aws-smithy-http-client" -version = "1.1.12" +name = "crossterm_winapi" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.14", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper 1.9.0", - "hyper-rustls 0.24.2", - "hyper-rustls 0.27.9", - "hyper-util", - "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.40", - "rustls-native-certs", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.4", - "tower", - "tracing", + "winapi", ] [[package]] -name = "aws-smithy-json" -version = "0.62.5" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "aws-smithy-types", + "generic-array", + "typenum", ] [[package]] -name = "aws-smithy-observability" -version = "0.2.6" +name = "cssparser" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ - "aws-smithy-runtime-api", + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", ] [[package]] -name = "aws-smithy-query" -version = "0.60.15" +name = "cssparser-macros" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" dependencies = [ - "aws-smithy-types", - "urlencoding", + "quote", + "syn", ] [[package]] -name = "aws-smithy-runtime" -version = "1.11.1" +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-http-client", - "aws-smithy-observability", - "aws-smithy-runtime-api", - "aws-smithy-types", - "bytes", - "fastrand", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "http-body-util", - "pin-project-lite", - "pin-utils", - "tokio", - "tracing", + "powerfmt", ] [[package]] -name = "aws-smithy-runtime-api" -version = "1.12.0" +name = "derive_more" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "aws-smithy-async", - "aws-smithy-runtime-api-macros", - "aws-smithy-types", - "bytes", - "http 0.2.12", - "http 1.4.0", - "pin-project-lite", - "tokio", - "tracing", - "zeroize", + "derive_more-impl", ] [[package]] -name = "aws-smithy-runtime-api-macros" -version = "1.0.0" +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "rustc_version", + "syn", ] [[package]] -name = "aws-smithy-types" -version = "1.4.7" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "base64-simd", - "bytes", - "bytes-utils", - "futures-core", - "http 0.2.12", - "http 1.4.0", - "http-body 0.4.6", - "http-body 1.0.1", - "http-body-util", - "itoa", - "num-integer", - "pin-project-lite", - "pin-utils", - "ryu", - "serde", - "time", - "tokio", - "tokio-util", + "block-buffer", + "crypto-common", ] [[package]] -name = "aws-smithy-xml" -version = "0.60.15" +name = "displaydoc" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ - "xmlparser", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "aws-types" -version = "1.3.15" +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ - "aws-credential-types", - "aws-smithy-async", - "aws-smithy-runtime-api", - "aws-smithy-types", - "rustc_version", - "tracing", + "litrs", ] [[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.22.1" +name = "dtoa" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] -name = "base64-simd" -version = "0.8.0" +name = "dtoa-short" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" dependencies = [ - "outref", - "vsimd", + "dtoa", ] [[package]] -name = "bigdecimal" -version = "0.4.10" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" -dependencies = [ - "autocfg", - "libm", - "num-bigint", - "num-integer", - "num-traits", -] +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "bit_field" -version = "0.10.3" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "bitflags" -version = "1.3.2" +name = "ego-tree" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" [[package]] -name = "bitflags" -version = "2.11.1" +name = "either" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] -name = "bitpacking" -version = "0.9.3" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "crunchy", + "cfg-if", ] [[package]] -name = "bitstream-io" -version = "4.10.0" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "bitvec" -version = "1.0.1" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "funty", - "radium", - "tap", - "wyz", + "libc", + "windows-sys 0.52.0", ] [[package]] -name = "blake2" -version = "0.10.6" +name = "eventsource-stream" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ - "digest 0.10.7", + "futures-core", + "nom", + "pin-project-lite", ] [[package]] -name = "blake3" -version = "1.8.5" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" -dependencies = [ - "arrayref", - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.0", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "block-buffer" -version = "0.10.4" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bon" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" -dependencies = [ - "bon-macros", - "rustversion", -] - -[[package]] -name = "bon-macros" -version = "3.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" -dependencies = [ - "darling 0.23.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", -] - -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "brotli-decompressor" -version = "5.0.0" +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] -name = "bstr" -version = "1.12.1" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "memchr", - "serde", + "crc32fast", + "miniz_oxide", ] [[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "bytes-utils" +name = "fluent-uri" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" dependencies = [ - "bytes", - "either", + "bitflags 1.3.2", ] [[package]] -name = "bzip2" -version = "0.5.2" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "bzip2" -version = "0.6.1" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "cc", - "pkg-config", + "percent-encoding", ] [[package]] -name = "castaway" -version = "0.2.4" +name = "fs_extra" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "cc" -version = "1.2.62" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] -name = "census" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.44" +name = "futures-channel" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", + "futures-core", + "futures-sink", ] [[package]] -name = "chrono-tz" -version = "0.10.4" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" -dependencies = [ - "chrono", - "phf", -] +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "clap" -version = "4.6.1" +name = "futures-executor" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ - "clap_builder", - "clap_derive", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "clap_builder" -version = "4.6.0" +name = "futures-io" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim 0.11.1", -] +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] -name = "clap_derive" -version = "4.6.1" +name = "futures-macro" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ - "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "cli" -version = "0.1.0" -dependencies = [ - "accelerator", - "anyhow", - "clap", - "crossterm", - "machine", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", + "syn", ] [[package]] -name = "cmov" -version = "0.5.3" +name = "futures-sink" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] -name = "color_quant" -version = "1.1.0" +name = "futures-task" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] -name = "colorchoice" -version = "1.0.5" +name = "futures-timer" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] -name = "combine" -version = "4.6.7" +name = "futures-util" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "bytes", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", "memchr", + "pin-project-lite", + "slab", ] [[package]] -name = "comfy-table" -version = "7.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0d05af1e006a2407bedef5af410552494ce5be9090444dbbcb57258c1af3d56" -dependencies = [ - "strum", - "strum_macros", - "unicode-width", -] - -[[package]] -name = "compact_str" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "serde", - "static_assertions", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "unicode-width", - "windows-sys 0.59.0", -] - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags 2.11.1", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.117", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dary_heap" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" -dependencies = [ - "serde", -] - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "datafusion" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af15bb3c6ffa33011ef579f6b0bcbe7c26584688bd6c994f548e44df67f011a" -dependencies = [ - "arrow", - "arrow-ipc", - "arrow-schema", - "async-trait", - "bytes", - "bzip2 0.6.1", - "chrono", - "datafusion-catalog", - "datafusion-catalog-listing", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-datasource-csv", - "datafusion-datasource-json", - "datafusion-datasource-parquet", - "datafusion-execution", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-functions", - "datafusion-functions-aggregate", - "datafusion-functions-nested", - "datafusion-functions-table", - "datafusion-functions-window", - "datafusion-optimizer", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", - "datafusion-physical-expr-common", - "datafusion-physical-optimizer", - "datafusion-physical-plan", - "datafusion-session", - "datafusion-sql", - "flate2", - "futures", - "itertools 0.14.0", - "log", - "object_store", - "parking_lot", - "parquet", - "rand 0.9.4", - "regex", - "sqlparser", - "tempfile", - "tokio", - "url", - "uuid", - "xz2", - "zstd", -] - -[[package]] -name = "datafusion-catalog" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187622262ad8f7d16d3be9202b4c1e0116f1c9aa387e5074245538b755261621" -dependencies = [ - "arrow", - "async-trait", - "dashmap", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-physical-plan", - "datafusion-session", - "datafusion-sql", - "futures", - "itertools 0.14.0", - "log", - "object_store", - "parking_lot", - "tokio", -] - -[[package]] -name = "datafusion-catalog-listing" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9657314f0a32efd0382b9a46fdeb2d233273ece64baa68a7c45f5a192daf0f83" -dependencies = [ - "arrow", - "async-trait", - "datafusion-catalog", - "datafusion-common", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", - "futures", - "log", - "object_store", - "tokio", -] - -[[package]] -name = "datafusion-common" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a83760d9a13122d025fbdb1d5d5aaf93dd9ada5e90ea229add92aa30898b2d1" -dependencies = [ - "ahash", - "arrow", - "arrow-ipc", - "base64 0.22.1", - "chrono", - "half", - "hashbrown 0.14.5", - "indexmap 2.14.0", - "libc", - "log", - "object_store", - "parquet", - "paste", - "recursive", - "sqlparser", - "tokio", - "web-time", -] - -[[package]] -name = "datafusion-common-runtime" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b6234a6c7173fe5db1c6c35c01a12b2aa0f803a3007feee53483218817f8b1e" -dependencies = [ - "futures", - "log", - "tokio", -] - -[[package]] -name = "datafusion-datasource" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7256c9cb27a78709dd42d0c80f0178494637209cac6e29d5c93edd09b6721b86" -dependencies = [ - "arrow", - "async-compression", - "async-trait", - "bytes", - "bzip2 0.6.1", - "chrono", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", - "flate2", - "futures", - "glob", - "itertools 0.14.0", - "log", - "object_store", - "parquet", - "rand 0.9.4", - "tempfile", - "tokio", - "tokio-util", - "url", - "xz2", - "zstd", -] - -[[package]] -name = "datafusion-datasource-csv" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64533a90f78e1684bfb113d200b540f18f268134622d7c96bbebc91354d04825" -dependencies = [ - "arrow", - "async-trait", - "bytes", - "datafusion-catalog", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", - "futures", - "object_store", - "regex", - "tokio", -] - -[[package]] -name = "datafusion-datasource-json" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7ebeb12c77df0aacad26f21b0d033aeede423a64b2b352f53048a75bf1d6e6" -dependencies = [ - "arrow", - "async-trait", - "bytes", - "datafusion-catalog", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", - "futures", - "object_store", - "serde_json", - "tokio", -] - -[[package]] -name = "datafusion-datasource-parquet" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e783c4c7d7faa1199af2df4761c68530634521b176a8d1331ddbc5a5c75133" -dependencies = [ - "arrow", - "async-trait", - "bytes", - "datafusion-catalog", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-functions-aggregate", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", - "datafusion-physical-expr-common", - "datafusion-physical-optimizer", - "datafusion-physical-plan", - "datafusion-pruning", - "datafusion-session", - "futures", - "itertools 0.14.0", - "log", - "object_store", - "parking_lot", - "parquet", - "rand 0.9.4", - "tokio", -] - -[[package]] -name = "datafusion-doc" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99ee6b1d9a80d13f9deb2291f45c07044b8e62fb540dbde2453a18be17a36429" - -[[package]] -name = "datafusion-execution" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4cec0a57653bec7b933fb248d3ffa3fa3ab3bd33bd140dc917f714ac036f531" -dependencies = [ - "arrow", - "async-trait", - "dashmap", - "datafusion-common", - "datafusion-expr", - "futures", - "log", - "object_store", - "parking_lot", - "rand 0.9.4", - "tempfile", - "url", -] - -[[package]] -name = "datafusion-expr" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef76910bdca909722586389156d0aa4da4020e1631994d50fadd8ad4b1aa05fe" -dependencies = [ - "arrow", - "async-trait", - "chrono", - "datafusion-common", - "datafusion-doc", - "datafusion-expr-common", - "datafusion-functions-aggregate-common", - "datafusion-functions-window-common", - "datafusion-physical-expr-common", - "indexmap 2.14.0", - "paste", - "recursive", - "serde_json", - "sqlparser", -] - -[[package]] -name = "datafusion-expr-common" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d155ccbda29591ca71a1344dd6bed26c65a4438072b400df9db59447f590bb6" -dependencies = [ - "arrow", - "datafusion-common", - "indexmap 2.14.0", - "itertools 0.14.0", - "paste", -] - -[[package]] -name = "datafusion-functions" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de2782136bd6014670fd84fe3b0ca3b3e4106c96403c3ae05c0598577139977" -dependencies = [ - "arrow", - "arrow-buffer", - "base64 0.22.1", - "blake2", - "blake3", - "chrono", - "datafusion-common", - "datafusion-doc", - "datafusion-execution", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-macros", - "hex", - "itertools 0.14.0", - "log", - "md-5", - "rand 0.9.4", - "regex", - "sha2 0.10.9", - "unicode-segmentation", - "uuid", -] - -[[package]] -name = "datafusion-functions-aggregate" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07331fc13603a9da97b74fd8a273f4238222943dffdbbed1c4c6f862a30105bf" -dependencies = [ - "ahash", - "arrow", - "datafusion-common", - "datafusion-doc", - "datafusion-execution", - "datafusion-expr", - "datafusion-functions-aggregate-common", - "datafusion-macros", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "half", - "log", - "paste", -] - -[[package]] -name = "datafusion-functions-aggregate-common" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5951e572a8610b89968a09b5420515a121fbc305c0258651f318dc07c97ab17" -dependencies = [ - "ahash", - "arrow", - "datafusion-common", - "datafusion-expr-common", - "datafusion-physical-expr-common", -] - -[[package]] -name = "datafusion-functions-nested" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdacca9302c3d8fc03f3e94f338767e786a88a33f5ebad6ffc0e7b50364b9ea3" -dependencies = [ - "arrow", - "arrow-ord", - "datafusion-common", - "datafusion-doc", - "datafusion-execution", - "datafusion-expr", - "datafusion-functions", - "datafusion-functions-aggregate", - "datafusion-functions-aggregate-common", - "datafusion-macros", - "datafusion-physical-expr-common", - "itertools 0.14.0", - "log", - "paste", -] - -[[package]] -name = "datafusion-functions-table" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37ff8a99434fbbad604a7e0669717c58c7c4f14c472d45067c4b016621d981" -dependencies = [ - "arrow", - "async-trait", - "datafusion-catalog", - "datafusion-common", - "datafusion-expr", - "datafusion-physical-plan", - "parking_lot", - "paste", -] - -[[package]] -name = "datafusion-functions-window" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e2aea7c79c926cffabb13dc27309d4eaeb130f4a21c8ba91cdd241c813652b" -dependencies = [ - "arrow", - "datafusion-common", - "datafusion-doc", - "datafusion-expr", - "datafusion-functions-window-common", - "datafusion-macros", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "log", - "paste", -] - -[[package]] -name = "datafusion-functions-window-common" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fead257ab5fd2ffc3b40fda64da307e20de0040fe43d49197241d9de82a487f" -dependencies = [ - "datafusion-common", - "datafusion-physical-expr-common", -] - -[[package]] -name = "datafusion-macros" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec6f637bce95efac05cdfb9b6c19579ed4aa5f6b94d951cfa5bb054b7bb4f730" -dependencies = [ - "datafusion-expr", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "datafusion-optimizer" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6583ef666ae000a613a837e69e456681a9faa96347bf3877661e9e89e141d8a" -dependencies = [ - "arrow", - "chrono", - "datafusion-common", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-physical-expr", - "indexmap 2.14.0", - "itertools 0.14.0", - "log", - "recursive", - "regex", - "regex-syntax", -] - -[[package]] -name = "datafusion-physical-expr" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8668103361a272cbbe3a61f72eca60c9b7c706e87cc3565bcf21e2b277b84f6" -dependencies = [ - "ahash", - "arrow", - "datafusion-common", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-functions-aggregate-common", - "datafusion-physical-expr-common", - "half", - "hashbrown 0.14.5", - "indexmap 2.14.0", - "itertools 0.14.0", - "log", - "parking_lot", - "paste", - "petgraph 0.8.3", -] - -[[package]] -name = "datafusion-physical-expr-adapter" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "815acced725d30601b397e39958e0e55630e0a10d66ef7769c14ae6597298bb0" -dependencies = [ - "arrow", - "datafusion-common", - "datafusion-expr", - "datafusion-functions", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "itertools 0.14.0", -] - -[[package]] -name = "datafusion-physical-expr-common" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6652fe7b5bf87e85ed175f571745305565da2c0b599d98e697bcbedc7baa47c3" -dependencies = [ - "ahash", - "arrow", - "datafusion-common", - "datafusion-expr-common", - "hashbrown 0.14.5", - "itertools 0.14.0", -] - -[[package]] -name = "datafusion-physical-optimizer" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b7d623eb6162a3332b564a0907ba00895c505d101b99af78345f1acf929b5c" -dependencies = [ - "arrow", - "datafusion-common", - "datafusion-execution", - "datafusion-expr", - "datafusion-expr-common", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-pruning", - "itertools 0.14.0", - "log", - "recursive", -] - -[[package]] -name = "datafusion-physical-plan" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2f7f778a1a838dec124efb96eae6144237d546945587557c9e6936b3414558c" -dependencies = [ - "ahash", - "arrow", - "arrow-ord", - "arrow-schema", - "async-trait", - "chrono", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-execution", - "datafusion-expr", - "datafusion-functions-aggregate-common", - "datafusion-functions-window-common", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "futures", - "half", - "hashbrown 0.14.5", - "indexmap 2.14.0", - "itertools 0.14.0", - "log", - "parking_lot", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "datafusion-pruning" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1e59e2ca14fe3c30f141600b10ad8815e2856caa59ebbd0e3e07cd3d127a65" -dependencies = [ - "arrow", - "arrow-schema", - "datafusion-common", - "datafusion-datasource", - "datafusion-expr-common", - "datafusion-physical-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "itertools 0.14.0", - "log", -] - -[[package]] -name = "datafusion-session" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21ef8e2745583619bd7a49474e8f45fbe98ebb31a133f27802217125a7b3d58d" -dependencies = [ - "arrow", - "async-trait", - "dashmap", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-physical-plan", - "datafusion-sql", - "futures", - "itertools 0.14.0", - "log", - "object_store", - "parking_lot", - "tokio", -] - -[[package]] -name = "datafusion-sql" -version = "50.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89abd9868770386fede29e5a4b14f49c0bf48d652c3b9d7a8a0332329b87d50b" -dependencies = [ - "arrow", - "bigdecimal", - "datafusion-common", - "datafusion-expr", - "indexmap 2.14.0", - "log", - "recursive", - "regex", - "sqlparser", -] - -[[package]] -name = "deepsize" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb987ec36f6bf7bfbea3f928b75590b736fc42af8e54d97592481351b2b96c" -dependencies = [ - "deepsize_derive", -] - -[[package]] -name = "deepsize_derive" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990101d41f3bc8c1a45641024377ee284ecc338e5ecf3ea0f0e236d897c72796" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "deluxe" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed332aaf752b459088acf3dd4eca323e3ef4b83c70a84ca48fb0ec5305f1488" -dependencies = [ - "deluxe-core", - "deluxe-macros", - "once_cell", - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "deluxe-core" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddada51c8576df9d6a8450c351ff63042b092c9458b8ac7d20f89cbd0ffd313" -dependencies = [ - "arrayvec", - "proc-macro2", - "quote", - "strsim 0.10.0", - "syn 2.0.117", -] - -[[package]] -name = "deluxe-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87546d9c837f0b7557e47b8bd6eae52c3c223141b76aa233c345c9ab41d9117" -dependencies = [ - "deluxe-core", - "heck 0.4.1", - "if_chain", - "proc-macro-crate 1.3.1", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "deranged" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.117", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.0", - "const-oid", - "crypto-common 0.2.1", - "ctutils", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "downcast-rs" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "earcutr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" -dependencies = [ - "itertools 0.11.0", - "num-traits", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "esaxx-rs" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" - -[[package]] -name = "ethnum" -version = "1.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "eventsource-stream" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" -dependencies = [ - "futures-core", - "nom 7.1.3", - "pin-project-lite", -] - -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fast-float2" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" - -[[package]] -name = "fastdivide" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" - -[[package]] -name = "fastembed" -version = "4.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04c269a76bfc6cea69553b7d040acb16c793119cebd97c756d21e08d0f075ff8" -dependencies = [ - "anyhow", - "hf-hub", - "image", - "ndarray", - "ort", - "ort-sys", - "rayon", - "serde_json", - "tokenizers", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "flatbuffers" -version = "25.12.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" -dependencies = [ - "bitflags 2.11.1", - "rustc_version", -] - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", - "zlib-rs", -] - -[[package]] -name = "float_next_after" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" - -[[package]] -name = "fluent-uri" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs4" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" -dependencies = [ - "rustix 0.38.44", - "windows-sys 0.52.0", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "fsst" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ffdff7a2d68d22afc0657eddde3e946371ce7cfe730a3f78a5ed44ea5b1cb2e" -dependencies = [ - "arrow-array", - "rand 0.9.4", -] - -[[package]] -name = "fst" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" -dependencies = [ - "utf8-ranges", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-timer" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generator" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link", - "windows-result", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "geo" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc1a1678e54befc9b4bcab6cd43b8e7f834ae8ea121118b0fd8c42747675b4a" -dependencies = [ - "earcutr", - "float_next_after", - "geo-types", - "geographiclib-rs", - "i_overlay", - "log", - "num-traits", - "robust", - "rstar", - "spade", -] - -[[package]] -name = "geo-traits" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7c353d12a704ccfab1ba8bfb1a7fe6cb18b665bf89d37f4f7890edcd260206" -dependencies = [ - "geo-types", -] - -[[package]] -name = "geo-types" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" -dependencies = [ - "approx", - "num-traits", - "rayon", - "rstar", - "serde", -] - -[[package]] -name = "geoarrow-array" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d1884b17253d8572e88833c282fcbb442365e4ae5f9052ced2831608253436c" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-schema", - "geo-traits", - "geoarrow-schema", - "num-traits", - "wkb", - "wkt", -] - -[[package]] -name = "geoarrow-expr-geo" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a67d3b543bc3ebeffdc204b67d69b8f9fcd33d76269ddd4a4618df99f053a934" -dependencies = [ - "arrow-array", - "arrow-buffer", - "geo", - "geo-traits", - "geoarrow-array", - "geoarrow-schema", -] - -[[package]] -name = "geoarrow-schema" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02f1b18b1c9a44ecd72be02e53d6e63bbccfdc8d1765206226af227327e2be6e" -dependencies = [ - "arrow-schema", - "geo-traits", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "geodatafusion" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d676b8d8b5f391ab4270ba31e9b599ee2c3d780405a38e272a0a7565ea189c" -dependencies = [ - "arrow-arith", - "arrow-array", - "arrow-schema", - "datafusion", - "geo", - "geo-traits", - "geoarrow-array", - "geoarrow-expr-geo", - "geoarrow-schema", - "geohash", - "thiserror 1.0.69", - "wkt", -] - -[[package]] -name = "geographiclib-rs" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" -dependencies = [ - "libm", -] - -[[package]] -name = "geohash" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb94b1a65401d6cbf22958a9040aa364812c26674f841bee538b12c135db1e6" -dependencies = [ - "geo-types", - "libm", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "globset" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.0", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", -] - -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hf-hub" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" -dependencies = [ - "dirs", - "http 1.4.0", - "indicatif", - "libc", - "log", - "rand 0.9.4", - "reqwest 0.12.28", - "serde", - "serde_json", - "thiserror 2.0.18", - "ureq", - "windows-sys 0.60.2", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[package]] -name = "htmlescape" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http 1.4.0", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http 1.4.0", - "http-body 1.0.1", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - -[[package]] -name = "hybrid-array" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2 0.4.14", - "http 1.4.0", - "http-body 1.0.1", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http 1.4.0", - "hyper 1.9.0", - "hyper-util", - "rustls 0.23.40", - "rustls-native-certs", - "tokio", - "tokio-rustls 0.26.4", - "tower-service", - "webpki-roots 1.0.7", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.9.0", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.3", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "hyperloglogplus" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "621debdf94dcac33e50475fdd76d34d5ea9c0362a834b9db08c3024696c1fbe3" -dependencies = [ - "serde", -] - -[[package]] -name = "i_float" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010025c2c532c8d82e42d0b8bb5184afa449fa6f06c709ea9adcb16c49ae405b" -dependencies = [ - "libm", -] - -[[package]] -name = "i_key_sort" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9190f86706ca38ac8add223b2aed8b1330002b5cdbbce28fb58b10914d38fc27" - -[[package]] -name = "i_overlay" -version = "4.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413183068e6e0289e18d7d0a1f661b81546e6918d5453a44570b9ab30cbed1b3" -dependencies = [ - "i_float", - "i_key_sort", - "i_shape", - "i_tree", - "rayon", -] - -[[package]] -name = "i_shape" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ea154b742f7d43dae2897fcd5ead86bc7b5eefcedd305a7ebf9f69d44d61082" -dependencies = [ - "i_float", -] - -[[package]] -name = "i_tree" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e6d558e6d4c7b82bc51d9c771e7a927862a161a7d87bf2b0541450e0e20915" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "if_chain" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" - -[[package]] -name = "ignore" -version = "0.4.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imgref" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "indicatif" -version = "0.17.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" -dependencies = [ - "console", - "number_prefix", - "portable-atomic", - "unicode-width", - "web-time", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "integer-encoding" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" -dependencies = [ - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-sys 0.61.2", -] - -[[package]] -name = "jiff-static" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror 2.0.18", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.117", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "jsonb" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953" -dependencies = [ - "byteorder", - "ethnum", - "fast-float2", - "itoa", - "jiff", - "nom 8.0.0", - "num-traits", - "ordered-float 5.3.0", - "rand 0.9.4", - "serde", - "serde_json", - "zmij", -] - -[[package]] -name = "lance" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c439decbc304e180748e34bb6d3df729069a222e83e74e2185c38f107136e9" -dependencies = [ - "arrow", - "arrow-arith", - "arrow-array", - "arrow-buffer", - "arrow-ipc", - "arrow-ord", - "arrow-row", - "arrow-schema", - "arrow-select", - "async-recursion", - "async-trait", - "async_cell", - "byteorder", - "bytes", - "chrono", - "dashmap", - "datafusion", - "datafusion-expr", - "datafusion-functions", - "datafusion-physical-expr", - "datafusion-physical-plan", - "deepsize", - "either", - "futures", - "half", - "humantime", - "itertools 0.13.0", - "lance-arrow", - "lance-core", - "lance-datafusion", - "lance-encoding", - "lance-file", - "lance-geo", - "lance-index", - "lance-io", - "lance-linalg", - "lance-namespace", - "lance-table", - "log", - "moka", - "object_store", - "permutation", - "pin-project", - "prost", - "prost-types", - "rand 0.9.4", - "roaring", - "semver", - "serde", - "serde_json", - "snafu", - "tantivy", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "lance-arrow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ee5508b225456d3d56998eaeef0d8fbce5ea93856df47b12a94d2e74153210" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", - "arrow-schema", - "arrow-select", - "bytes", - "getrandom 0.2.17", - "half", - "jsonb", - "num-traits", - "rand 0.9.4", -] - -[[package]] -name = "lance-bitpacking" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c065fb3bd4a8cc4f78428443e990d4921aa08f707b676753db740e0b402a21" -dependencies = [ - "arrayref", - "paste", - "seq-macro", -] - -[[package]] -name = "lance-core" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8856abad92e624b75cd57a04703f6441948a239463bdf973f2ac1924b0bcdbe" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-schema", - "async-trait", - "byteorder", - "bytes", - "chrono", - "datafusion-common", - "datafusion-sql", - "deepsize", - "futures", - "lance-arrow", - "libc", - "log", - "mock_instant", - "moka", - "num_cpus", - "object_store", - "pin-project", - "prost", - "rand 0.9.4", - "roaring", - "serde_json", - "snafu", - "tempfile", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", -] - -[[package]] -name = "lance-datafusion" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8835308044cef5467d7751be87fcbefc2db01c22370726a8704bd62991693f" -dependencies = [ - "arrow", - "arrow-array", - "arrow-buffer", - "arrow-ord", - "arrow-schema", - "arrow-select", - "async-trait", - "chrono", - "datafusion", - "datafusion-common", - "datafusion-functions", - "datafusion-physical-expr", - "futures", - "jsonb", - "lance-arrow", - "lance-core", - "lance-datagen", - "lance-geo", - "log", - "pin-project", - "prost", - "snafu", - "tokio", - "tracing", -] - -[[package]] -name = "lance-datagen" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612de1e888bb36f6bf51196a6eb9574587fdf256b1759a4c50e643e00d5f96d0" -dependencies = [ - "arrow", - "arrow-array", - "arrow-cast", - "arrow-schema", - "chrono", - "futures", - "half", - "hex", - "rand 0.9.4", - "rand_xoshiro", - "random_word", -] - -[[package]] -name = "lance-encoding" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b456b29b135d3c7192602e516ccade38b5483986e121895fa43cf1fdb38bf60" -dependencies = [ - "arrow-arith", - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", - "arrow-schema", - "arrow-select", - "bytemuck", - "byteorder", - "bytes", - "fsst", - "futures", - "hex", - "hyperloglogplus", - "itertools 0.13.0", - "lance-arrow", - "lance-bitpacking", - "lance-core", - "log", - "lz4", - "num-traits", - "prost", - "prost-build", - "prost-types", - "rand 0.9.4", - "snafu", - "strum", - "tokio", - "tracing", - "xxhash-rust", - "zstd", -] - -[[package]] -name = "lance-file" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab1538d14d5bb3735b4222b3f5aff83cfa59cc6ef7cdd3dd9139e4c77193c80b" -dependencies = [ - "arrow-arith", - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "arrow-select", - "async-recursion", - "async-trait", - "byteorder", - "bytes", - "datafusion-common", - "deepsize", - "futures", - "lance-arrow", - "lance-core", - "lance-encoding", - "lance-io", - "log", - "num-traits", - "object_store", - "prost", - "prost-build", - "prost-types", - "snafu", - "tokio", - "tracing", -] - -[[package]] -name = "lance-geo" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5a69a2f3b55703d9c240ad7c5ffa2c755db69e9cf8aa05efe274a212910472d" -dependencies = [ - "datafusion", - "geo-types", - "geoarrow-array", - "geoarrow-schema", - "geodatafusion", -] - -[[package]] -name = "lance-index" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea84613df6fa6b9168a1f056ba4f9cb73b90a1b452814c6fd4b3529bcdbfc78" -dependencies = [ - "arrow", - "arrow-arith", - "arrow-array", - "arrow-ord", - "arrow-schema", - "arrow-select", - "async-channel", - "async-recursion", - "async-trait", - "bitpacking", - "bitvec", - "bytes", - "crossbeam-queue", - "datafusion", - "datafusion-common", - "datafusion-expr", - "datafusion-physical-expr", - "datafusion-sql", - "deepsize", - "dirs", - "fst", - "futures", - "half", - "itertools 0.13.0", - "jsonb", - "lance-arrow", - "lance-core", - "lance-datafusion", - "lance-datagen", - "lance-encoding", - "lance-file", - "lance-io", - "lance-linalg", - "lance-table", - "libm", - "log", - "ndarray", - "num-traits", - "object_store", - "prost", - "prost-build", - "prost-types", - "rand 0.9.4", - "rand_distr 0.5.1", - "rayon", - "roaring", - "serde", - "serde_json", - "snafu", - "tantivy", - "tempfile", - "tokio", - "tracing", - "twox-hash", - "uuid", -] - -[[package]] -name = "lance-io" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3fc4c1d941fceef40a0edbd664dbef108acfc5d559bb9e7f588d0c733cbc35" -dependencies = [ - "arrow", - "arrow-arith", - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", - "arrow-schema", - "arrow-select", - "async-recursion", - "async-trait", - "byteorder", - "bytes", - "chrono", - "deepsize", - "futures", - "lance-arrow", - "lance-core", - "lance-namespace", - "log", - "object_store", - "path_abs", - "pin-project", - "prost", - "rand 0.9.4", - "serde", - "shellexpand", - "snafu", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "lance-linalg" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ffbc5ce367fbf700a69de3fe0612ee1a11191a64a632888610b6bacfa0f63" -dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-schema", - "cc", - "deepsize", - "half", - "lance-arrow", - "lance-core", - "num-traits", - "rand 0.9.4", -] - -[[package]] -name = "lance-namespace" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791bbcd868ee758123a34e07d320a1fb99379432b5ecc0e78d6b4686e999b629" -dependencies = [ - "arrow", - "async-trait", - "bytes", - "lance-core", - "lance-namespace-reqwest-client", - "snafu", -] - -[[package]] -name = "lance-namespace-impls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee713505576f6b1988a491f77c7ca8b0cf7090a393598e63c85079fa70a53ebf" -dependencies = [ - "arrow", - "arrow-ipc", - "arrow-schema", - "async-trait", - "bytes", - "futures", - "lance", - "lance-core", - "lance-index", - "lance-io", - "lance-namespace", - "log", - "object_store", - "rand 0.9.4", - "serde_json", - "snafu", - "tokio", - "url", -] - -[[package]] -name = "lance-namespace-reqwest-client" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea349999bcda4eea53fc05d334b3775ec314761e6a706555c777d7a29b18d19" -dependencies = [ - "reqwest 0.12.28", - "serde", - "serde_json", - "serde_repr", - "url", -] - -[[package]] -name = "lance-table" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fdb2d56bfa4d1511c765fa0cc00fdaa37e5d2d1cd2f57b3c6355d9072177052" -dependencies = [ - "arrow", - "arrow-array", - "arrow-buffer", - "arrow-ipc", - "arrow-schema", - "async-trait", - "byteorder", - "bytes", - "chrono", - "deepsize", - "futures", - "lance-arrow", - "lance-core", - "lance-file", - "lance-io", - "log", - "object_store", - "prost", - "prost-build", - "prost-types", - "rand 0.9.4", - "rangemap", - "roaring", - "semver", - "serde", - "serde_json", - "snafu", - "tokio", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "lance-testing" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8ccb1a4a9284435c6a8c02c8c06e7e041bece0d7f722152159353cf55dc51e3" -dependencies = [ - "arrow-array", - "arrow-schema", - "lance-arrow", - "num-traits", - "rand 0.9.4", -] - -[[package]] -name = "lancedb" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9217d7d3a1f4e088bdedaad9b4fa79045b077e07f961f1cd3ec6f90850c425f2" -dependencies = [ - "ahash", - "arrow", - "arrow-array", - "arrow-cast", - "arrow-data", - "arrow-ipc", - "arrow-ord", - "arrow-schema", - "arrow-select", - "async-trait", - "bytes", - "chrono", - "datafusion", - "datafusion-catalog", - "datafusion-common", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-plan", - "futures", - "half", - "lance", - "lance-arrow", - "lance-core", - "lance-datafusion", - "lance-datagen", - "lance-encoding", - "lance-file", - "lance-index", - "lance-io", - "lance-linalg", - "lance-namespace", - "lance-namespace-impls", - "lance-table", - "lance-testing", - "lazy_static", - "log", - "moka", - "num-traits", - "object_store", - "pin-project", - "rand 0.9.4", - "regex", - "semver", - "serde", - "serde_json", - "serde_with", - "snafu", - "tempfile", - "tokio", - "url", - "uuid", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "lebe" -version = "0.5.3" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] [[package]] -name = "levenshtein_automata" -version = "0.2.1" +name = "getopts" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] [[package]] -name = "lexical-core" -version = "1.0.6" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "lexical-parse-float", - "lexical-parse-integer", - "lexical-util", - "lexical-write-float", - "lexical-write-integer", + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", ] [[package]] -name = "lexical-parse-float" -version = "1.0.6" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "lexical-parse-integer", - "lexical-util", + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", ] [[package]] -name = "lexical-parse-integer" -version = "1.0.6" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "lexical-util", + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", ] [[package]] -name = "lexical-util" -version = "1.0.7" +name = "glob" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] -name = "lexical-write-float" -version = "1.0.6" +name = "globset" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ - "lexical-util", - "lexical-write-integer", + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", ] [[package]] -name = "lexical-write-integer" -version = "1.0.6" +name = "h2" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ - "lexical-util", + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "libbz2-rs-sys" -version = "0.2.3" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] [[package]] -name = "libc" -version = "0.2.186" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "libfuzzer-sys" -version = "0.4.12" +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "html5ever" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ - "arbitrary", - "cc", + "log", + "markup5ever", ] [[package]] -name = "libm" -version = "0.2.16" +name = "http" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] [[package]] -name = "libredox" -version = "0.1.16" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "libc", + "bytes", + "http", ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "litemap" -version = "0.8.2" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "lock_api" -version = "0.4.14" +name = "hyper" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ - "scopeguard", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "loom" -version = "0.7.2" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] -name = "loop9" -version = "0.1.5" +name = "hyper-timeout" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "imgref", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] -name = "lru" -version = "0.12.5" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "hashbrown 0.15.5", + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.5.10", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", ] [[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lsp-types" -version = "0.97.0" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "bitflags 1.3.2", - "fluent-uri", - "serde", - "serde_json", - "serde_repr", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "lz4" -version = "1.28.1" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "lz4-sys", + "cc", ] [[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "cc", - "libc", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "lz4_flex" -version = "0.11.6" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "twox-hash", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "lzma-sys" -version = "0.1.20" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "cc", - "libc", - "pkg-config", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "machine" -version = "0.1.0" -dependencies = [ - "rig", - "serde", - "serde_json", - "tokio", - "tracing", - "utils", -] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] -name = "macro_rules_attribute" -version = "0.2.2" +name = "icu_properties" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "macro_rules_attribute-proc_macro", - "paste", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "macro_rules_attribute-proc_macro" -version = "0.2.2" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] -name = "matchers" -version = "0.2.0" +name = "icu_provider" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ - "regex-automata", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "matrixmultiply" -version = "0.3.10" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" -dependencies = [ - "autocfg", - "num_cpus", - "once_cell", - "rawpointer", - "thread-tree", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] -name = "maybe-rayon" -version = "0.1.1" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "cfg-if", - "rayon", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "md-5" -version = "0.10.6" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "cfg-if", - "digest 0.10.7", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "measure_time" -version = "0.9.0" +name = "ignore" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" dependencies = [ + "crossbeam-deque", + "globset", "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", ] [[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memmap2" -version = "0.9.10" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "libc", + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] -name = "mime" -version = "0.3.17" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "mime_guess" -version = "2.0.5" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "minimal-lexical" -version = "0.2.1" +name = "itertools" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "mio" -version = "1.2.0" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "libc", + "cfg-if", + "combine", + "jni-macros", + "jni-sys", "log", - "wasi", - "windows-sys 0.61.2", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", ] [[package]] -name = "mock_instant" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" - -[[package]] -name = "moka" -version = "0.12.15" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "async-lock", - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "event-listener", - "futures-util", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] -name = "monostate" -version = "0.1.18" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" dependencies = [ - "monostate-impl", - "serde", - "serde_core", + "jni-sys-macros", ] [[package]] -name = "monostate-impl" -version = "0.1.18" +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ - "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] -name = "moxcms" -version = "0.8.1" +name = "jobserver" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "num-traits", - "pxfm", + "getrandom 0.3.4", + "libc", ] [[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - -[[package]] -name = "murmurhash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" - -[[package]] -name = "nanoid" -version = "0.4.0" +name = "js-sys" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ - "rand 0.8.6", + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", ] [[package]] -name = "ndarray" -version = "0.16.1" +name = "lazy_static" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", -] +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "new_debug_unreachable" -version = "1.0.6" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "no_std_io2" -version = "0.9.4" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" -dependencies = [ - "memchr", -] +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "nom" -version = "7.1.3" +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] -name = "nom" -version = "8.0.0" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "noop_proc_macro" -version = "0.3.0" +name = "litemap" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "litrs" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] -name = "num" -version = "0.4.3" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", + "scopeguard", ] [[package]] -name = "num-bigint" -version = "0.4.6" +name = "log" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] -name = "num-complex" -version = "0.4.6" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "num-conv" -version = "0.2.1" +name = "lsp-types" +version = "0.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] [[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +name = "machine" +version = "0.1.0" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "base64", + "futures-util", + "http", + "rig-core", + "serde", + "serde_json", + "tokio", + "tracing", + "utils", ] [[package]] -name = "num-integer" -version = "0.1.46" +name = "markup5ever" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ - "num-traits", + "log", + "tendril", + "web_atoms", ] [[package]] -name = "num-iter" -version = "0.1.45" +name = "matchers" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "autocfg", - "num-integer", - "num-traits", + "regex-automata", ] [[package]] -name = "num-rational" -version = "0.4.2" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] -name = "num-traits" -version = "0.2.19" +name = "memchr" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] -name = "num_cpus" -version = "1.17.0" +name = "memmap2" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ - "hermit-abi", "libc", ] [[package]] -name = "num_enum" -version = "0.7.6" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "num_enum_derive" -version = "0.7.6" +name = "mime_guess" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", + "mime", + "unicase", ] [[package]] -name = "number_prefix" -version = "0.4.0" +name = "minimal-lexical" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] -name = "object" -version = "0.37.3" +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "memchr", + "adler2", + "simd-adler32", ] [[package]] -name = "object_store" -version = "0.12.5" +name = "mio" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "http 1.4.0", - "humantime", - "itertools 0.14.0", - "parking_lot", - "percent-encoding", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", - "walkdir", - "wasm-bindgen-futures", - "web-time", + "libc", + "log", + "wasi", + "windows-sys 0.61.2", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "multimap" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "nanoid" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.6", +] [[package]] -name = "oneshot" -version = "0.1.13" +name = "new_debug_unreachable" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] -name = "onig" -version = "6.5.3" +name = "nom" +version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ - "bitflags 2.11.1", - "libc", - "once_cell", - "onig_sys", + "memchr", + "minimal-lexical", ] [[package]] -name = "onig_sys" -version = "69.9.3" +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "cc", - "pkg-config", + "windows-sys 0.59.0", ] [[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-float" -version = "2.10.1" +name = "num-conv" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] -name = "ordered-float" -version = "5.3.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "num-traits", + "autocfg", ] [[package]] -name = "ort" -version = "2.0.0-rc.9" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52afb44b6b0cffa9bf45e4d37e5a4935b0334a51570658e279e9e3e6cf324aa5" -dependencies = [ - "ndarray", - "ort-sys", - "tracing", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "ort-sys" -version = "2.0.0-rc.9" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41d7757331aef2d04b9cb09b45583a59217628beaf91895b7e76187b6e8c088" -dependencies = [ - "pkg-config", -] +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "outref" -version = "0.5.2" +name = "openssl-probe" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "ownedbytes" -version = "0.9.0" +name = "ordered-float" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" dependencies = [ - "stable_deref_trait", + "num-traits", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -5386,136 +1716,92 @@ dependencies = [ ] [[package]] -name = "parquet" -version = "56.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dbd48ad52d7dccf8ea1b90a3ddbfaea4f69878dd7683e51c507d4bc52b5b27" -dependencies = [ - "ahash", - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", - "arrow-ipc", - "arrow-schema", - "arrow-select", - "base64 0.22.1", - "brotli", - "bytes", - "chrono", - "flate2", - "futures", - "half", - "hashbrown 0.16.1", - "lz4_flex", - "num", - "num-bigint", - "object_store", - "paste", - "ring", - "seq-macro", - "simdutf8", - "snap", - "thrift", - "tokio", - "twox-hash", - "zstd", -] - -[[package]] -name = "paste" -version = "1.0.15" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "pastey" -version = "0.1.1" +name = "petgraph" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] [[package]] -name = "path_abs" -version = "0.5.1" +name = "phf" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ef02f6342ac01d8a93b65f96db53fe68a92a15f41144f97fb00a9e669633c3" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ + "phf_macros", + "phf_shared", "serde", - "serde_derive", - "std_prelude", - "stfu8", ] [[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "permutation" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df202b0b0f5b8e389955afd5f27b007b00fb948162953f1db9c70d2c7e3157d7" - -[[package]] -name = "petgraph" -version = "0.7.1" +name = "phf_codegen" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "fixedbitset", - "indexmap 2.14.0", + "phf_generator", + "phf_shared", ] [[package]] -name = "petgraph" -version = "0.8.3" +name = "phf_generator" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "serde", + "fastrand", + "phf_shared", ] [[package]] -name = "phf" -version = "0.12.1" +name = "phf_macros" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ + "phf_generator", "phf_shared", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "phf_shared" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", ] [[package]] name = "pin-project" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -5524,46 +1810,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.11.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -5589,60 +1835,28 @@ dependencies = [ ] [[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] -name = "profiling" -version = "1.0.18" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "profiling-procmacros", + "proc-macro2", + "syn", ] [[package]] -name = "profiling-procmacros" -version = "1.0.18" +name = "proc-macro2" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ - "quote", - "syn 2.0.117", + "unicode-ident", ] [[package]] @@ -5661,17 +1875,17 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "heck 0.5.0", - "itertools 0.14.0", + "heck", + "itertools", "log", "multimap", "once_cell", - "petgraph 0.7.1", + "petgraph", "prettyplease", "prost", "prost-types", "regex", - "syn 2.0.117", + "syn", "tempfile", ] @@ -5682,10 +1896,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -5698,36 +1912,21 @@ dependencies = [ ] [[package]] -name = "psm" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" -dependencies = [ - "ar_archive_writer", - "cc", -] - -[[package]] -name = "pxfm" -version = "0.1.29" +name = "psl-types" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" [[package]] -name = "qoi" -version = "0.4.1" +name = "publicsuffix" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" dependencies = [ - "bytemuck", + "idna", + "psl-types", ] -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - [[package]] name = "quinn" version = "0.11.9" @@ -5740,8 +1939,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.40", - "socket2 0.6.3", + "rustls", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5761,7 +1960,7 @@ dependencies = [ "rand 0.9.4", "ring", "rustc-hash", - "rustls 0.23.40", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -5779,7 +1978,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5805,12 +2004,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - [[package]] name = "rand" version = "0.8.6" @@ -5870,161 +2063,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.6", -] - -[[package]] -name = "rand_distr" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" -dependencies = [ - "num-traits", - "rand 0.9.4", -] - -[[package]] -name = "rand_xoshiro" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" -dependencies = [ - "rand_core 0.9.5", -] - -[[package]] -name = "random_word" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47a395bdb55442b883c89062d6bcff25dc90fa5f8369af81e0ac6d49d78cf81" -dependencies = [ - "ahash", - "brotli", - "paste", - "rand 0.9.4", - "unicase", -] - -[[package]] -name = "rangemap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools 0.14.0", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.4", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-cond" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" -dependencies = [ - "either", - "itertools 0.14.0", - "rayon", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "recursive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" -dependencies = [ - "recursive-proc-macro-impl", - "stacker", -] - -[[package]] -name = "recursive-proc-macro-impl" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -6034,17 +2072,6 @@ dependencies = [ "bitflags 2.11.1", ] -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - [[package]] name = "ref-cast" version = "1.0.25" @@ -6062,7 +2089,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -6088,12 +2115,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "regex-lite" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" - [[package]] name = "regex-syntax" version = "0.8.10" @@ -6102,67 +2123,23 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.4.14", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", - "hyper-util", - "js-sys", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls 0.23.40", - "rustls-native-certs", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls 0.26.4", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", - "webpki-roots 1.0.7", -] - -[[package]] -name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64 0.22.1", + "base64", "bytes", + "cookie", + "cookie_store", "encoding_rs", "futures-core", "futures-util", - "h2 0.4.14", - "http 1.4.0", - "http-body 1.0.1", + "h2", + "http", + "http-body", "http-body-util", - "hyper 1.9.0", - "hyper-rustls 0.27.9", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", @@ -6171,14 +2148,14 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.40", + "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -6186,52 +2163,10 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - -[[package]] -name = "rig" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ae0ae11332e16b7ce705a8ed268d4960f9e1ee157bffccd5cd023cc74c42930" -dependencies = [ - "rig-bedrock", - "rig-core", - "rig-fastembed", - "rig-helixdb", - "rig-lancedb", - "rig-milvus", -] - -[[package]] -name = "rig-bedrock" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08b42d73a3610e77e41bfbdea3fd6ae3974927b09aab6d5a24350d6a9dfb221b" -dependencies = [ - "async-stream", - "aws-config", - "aws-sdk-bedrockruntime", - "aws-smithy-types", - "base64 0.22.1", - "nanoid", - "rig-core", - "rig-derive", - "schemars 1.2.1", - "serde", - "serde_json", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "rig-core" version = "0.36.0" @@ -6239,100 +2174,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ac7d75266ac1f79b1faeff611c85cb40be00a0a983db10036591424f0433dc1" dependencies = [ "as-any", - "async-stream", - "base64 0.22.1", - "bytes", - "eventsource-stream", - "fastrand", - "futures", - "futures-timer", - "glob", - "http 1.4.0", - "mime", - "mime_guess", - "nanoid", - "ordered-float 5.3.0", - "pin-project-lite", - "reqwest 0.13.3", - "schemars 1.2.1", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-tungstenite", - "tracing", - "tracing-futures", - "url", -] - -[[package]] -name = "rig-derive" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1a6de0e69cfc929772efa85c19602a341b5837aba5eafe8339ccb26f185116" -dependencies = [ - "convert_case", - "deluxe", - "indoc", - "proc-macro2", - "quote", - "serde_json", - "syn 2.0.117", -] - -[[package]] -name = "rig-fastembed" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bae4b1b26380e407e8ceb6e29d7b09a01c55922b70bda567dea5109e1ed0555" -dependencies = [ - "fastembed", - "rig-core", - "schemars 1.2.1", - "serde", - "serde_json", - "tracing", -] - -[[package]] -name = "rig-helixdb" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d30afd0ad65fa2399d4fac431fc3425838b6470e973abf08c8614b2bd5b4683" -dependencies = [ - "reqwest 0.13.3", - "rig-core", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "rig-lancedb" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c4e2eaba620d97eea6057d06a2238875a4d591ecb97c89da91456f249862e5" -dependencies = [ - "arrow-array", - "deranged", - "futures", - "lancedb", - "rig-core", - "serde", - "serde_json", -] - -[[package]] -name = "rig-milvus" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5bad67372530b11ccf849d2fc1c604e46dcc76cd02f6d89c75fea825f750ea8" -dependencies = [ - "reqwest 0.13.3", - "rig-core", + "async-stream", + "base64", + "bytes", + "eventsource-stream", + "fastrand", + "futures", + "futures-timer", + "glob", + "http", + "mime", + "mime_guess", + "nanoid", + "ordered-float", + "pin-project-lite", + "reqwest", + "schemars", "serde", "serde_json", - "uuid", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-futures", + "url", ] [[package]] @@ -6349,43 +2214,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "roaring" -version = "0.10.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" -dependencies = [ - "bytemuck", - "byteorder", -] - -[[package]] -name = "robust" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" - -[[package]] -name = "rstar" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" -dependencies = [ - "heapless", - "num-traits", - "smallvec", -] - -[[package]] -name = "rust-stemmers" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" -dependencies = [ - "serde", - "serde_derive", -] - [[package]] name = "rustc-hash" version = "2.1.2" @@ -6424,19 +2252,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", + "windows-sys 0.52.0", ] [[package]] @@ -6446,20 +2262,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", - "log", "once_cell", - "ring", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -6488,14 +2302,14 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.40", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.13", + "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6504,16 +2318,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.13" @@ -6532,12 +2336,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -6556,18 +2354,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "schemars" version = "1.2.1" @@ -6590,15 +2376,9 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -6606,13 +2386,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "sct" -version = "0.7.1" +name = "scraper" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" dependencies = [ - "ring", - "untrusted", + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", ] [[package]] @@ -6639,16 +2424,29 @@ dependencies = [ ] [[package]] -name = "semver" -version = "1.0.28" +name = "selectors" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.11.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] [[package]] -name = "seq-macro" -version = "0.3.6" +name = "semver" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -6677,7 +2475,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -6688,16 +2486,15 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -6713,50 +2510,43 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "serde_spanned" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", "serde", ] [[package]] -name = "serde_with" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +name = "server" +version = "0.1.0" dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", + "accelerator", + "machine", + "prost", "serde_json", - "serde_with_macros", - "time", + "storage", + "tempfile", + "tokio", + "tonic", + "tonic-build", + "tracing", + "tracing-subscriber", + "utils", ] [[package]] -name = "serde_with_macros" -version = "3.19.0" +name = "servo_arc" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn 2.0.117", + "stable_deref_trait", ] [[package]] @@ -6766,30 +2556,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -6801,20 +2569,11 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "dirs", -] - [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -6848,266 +2607,121 @@ dependencies = [ ] [[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simd_cesu8" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] -name = "sketches-ddsketch" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" -dependencies = [ - "serde", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "snafu" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" -dependencies = [ - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "snap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - -[[package]] -name = "spade" -version = "2.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" -dependencies = [ - "hashbrown 0.16.1", - "num-traits", - "robust", - "smallvec", -] - -[[package]] -name = "spm_precompiled" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" -dependencies = [ - "base64 0.13.1", - "nom 7.1.3", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "sqlparser" -version = "0.58.0" +name = "simd-adler32" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4b661c54b1e4b603b37873a18c59920e4c51ea8ea2cf527d925424dbd4437c" -dependencies = [ - "log", - "recursive", - "sqlparser_derive", -] +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] -name = "sqlparser_derive" -version = "0.3.0" +name = "simd_cesu8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "rustc_version", + "simdutf8", ] [[package]] -name = "stable_deref_trait" -version = "1.2.1" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] -name = "stacker" -version = "0.1.24" +name = "siphasher" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.61.2", -] +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] -name = "static_assertions" -version = "1.1.0" +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] -name = "std_prelude" -version = "0.2.12" +name = "smallvec" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8207e78455ffdf55661170876f88daf85356e4edd54e0a3dbc79586ca1e50cbe" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] -name = "stfu8" -version = "0.2.7" +name = "socket2" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51f1e89f093f99e7432c491c382b88a6860a5adbe6bf02574bf0a08efff1978" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] [[package]] -name = "streaming-iterator" -version = "0.1.9" +name = "socket2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] [[package]] -name = "strsim" -version = "0.10.0" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +name = "storage" +version = "0.1.0" +dependencies = [ + "crc32c", + "machine", + "memmap2", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", +] [[package]] -name = "strum" -version = "0.26.3" +name = "string_cache" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ - "strum_macros", + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", ] [[package]] -name = "strum_macros" -version = "0.26.4" +name = "string_cache_codegen" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "heck 0.5.0", + "phf_generator", + "phf_shared", "proc-macro2", "quote", - "rustversion", - "syn 2.0.117", ] [[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "symlink" -version = "0.1.0" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "syn" -version = "1.0.109" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -7137,7 +2751,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -7161,164 +2775,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "tantivy" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a966cb0e76e311f09cf18507c9af192f15d34886ee43d7ba7c7e3803660c43" -dependencies = [ - "aho-corasick", - "arc-swap", - "base64 0.22.1", - "bitpacking", - "bon", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "downcast-rs", - "fastdivide", - "fnv", - "fs4", - "htmlescape", - "hyperloglogplus", - "itertools 0.14.0", - "levenshtein_automata", - "log", - "lru", - "lz4_flex", - "measure_time", - "memmap2", - "once_cell", - "oneshot", - "rayon", - "regex", - "rust-stemmers", - "rustc-hash", - "serde", - "serde_json", - "sketches-ddsketch", - "smallvec", - "tantivy-bitpacker", - "tantivy-columnar", - "tantivy-common", - "tantivy-fst", - "tantivy-query-grammar", - "tantivy-stacker", - "tantivy-tokenizer-api", - "tempfile", - "thiserror 2.0.18", - "time", - "uuid", - "winapi", -] - -[[package]] -name = "tantivy-bitpacker" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1adc286a39e089ae9938935cd488d7d34f14502544a36607effd2239ff0e2494" -dependencies = [ - "bitpacking", -] - -[[package]] -name = "tantivy-columnar" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6300428e0c104c4f7db6f95b466a6f5c1b9aece094ec57cdd365337908dc7344" -dependencies = [ - "downcast-rs", - "fastdivide", - "itertools 0.14.0", - "serde", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-sstable", - "tantivy-stacker", -] - -[[package]] -name = "tantivy-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b6ea6090ce03dc72c27d0619e77185d26cc3b20775966c346c6d4f7e99d7f" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes", - "serde", - "time", -] - -[[package]] -name = "tantivy-fst" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" -dependencies = [ - "byteorder", - "regex-syntax", - "utf8-ranges", -] - -[[package]] -name = "tantivy-query-grammar" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" -dependencies = [ - "nom 7.1.3", - "serde", - "serde_json", -] - -[[package]] -name = "tantivy-sstable" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" -dependencies = [ - "futures-util", - "itertools 0.14.0", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-fst", - "zstd", -] - -[[package]] -name = "tantivy-stacker" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" -dependencies = [ - "murmurhash32", - "rand_distr 0.4.3", - "tantivy-common", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" -dependencies = [ - "serde", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tempfile" version = "3.27.0" @@ -7329,7 +2785,17 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", ] [[package]] @@ -7358,7 +2824,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -7369,16 +2835,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "thread-tree" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbd370cb847953a25954d9f63e14824a36113f8c72eecf6eccef5dc4b45d630" -dependencies = [ - "crossbeam-channel", + "syn", ] [[package]] @@ -7390,31 +2847,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "thrift" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" -dependencies = [ - "byteorder", - "integer-encoding", - "ordered-float 2.10.1", -] - -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - [[package]] name = "time" version = "0.3.47" @@ -7446,15 +2878,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -7480,39 +2903,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tokenizers" -version = "0.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476" -dependencies = [ - "ahash", - "aho-corasick", - "compact_str", - "dary_heap", - "derive_builder", - "esaxx-rs", - "getrandom 0.3.4", - "itertools 0.14.0", - "log", - "macro_rules_attribute", - "monostate", - "onig", - "paste", - "rand 0.9.4", - "rayon", - "rayon-cond", - "regex", - "regex-syntax", - "serde", - "serde_json", - "spm_precompiled", - "thiserror 2.0.18", - "unicode-normalization-alignments", - "unicode-segmentation", - "unicode_categories", -] - [[package]] name = "tokio" version = "1.52.3" @@ -7525,7 +2915,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -7538,17 +2928,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", + "syn", ] [[package]] @@ -7557,7 +2937,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.40", + "rustls", "tokio", ] @@ -7580,10 +2960,10 @@ checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" dependencies = [ "futures-util", "log", - "rustls 0.23.40", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tungstenite", "webpki-roots 0.26.11", ] @@ -7602,50 +2982,87 @@ dependencies = [ ] [[package]] -name = "toml_datetime" -version = "0.6.11" +name = "toml" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ - "serde_core", + "serde", ] [[package]] name = "toml_edit" -version = "0.19.15" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.11", - "winnow 0.5.40", + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", ] [[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" dependencies = [ - "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.2", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" +name = "tonic-build" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847" dependencies = [ - "winnow 1.0.2", + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", ] [[package]] @@ -7656,26 +3073,30 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", "bitflags 2.11.1", "bytes", "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", "pin-project-lite", "tokio", @@ -7709,19 +3130,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-appender" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" -dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - [[package]] name = "tracing-attributes" version = "0.1.31" @@ -7730,7 +3138,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -7766,16 +3174,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "tracing-core", -] - [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -7786,95 +3184,12 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", - "serde", - "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", - "tracing-serde", -] - -[[package]] -name = "tree-sitter" -version = "0.26.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "887bd495d0582c5e3e0d8ece2233666169fa56a9644d172fc22ad179ab2d0538" -dependencies = [ - "cc", - "regex", - "regex-syntax", - "serde_json", - "streaming-iterator", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-go" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-java" -version = "0.23.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-javascript" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-language" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" - -[[package]] -name = "tree-sitter-python" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-rust" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" -dependencies = [ - "cc", - "tree-sitter-language", -] - -[[package]] -name = "tree-sitter-typescript" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" -dependencies = [ - "cc", - "tree-sitter-language", ] [[package]] @@ -7892,31 +3207,22 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http", "httparse", "log", "rand 0.8.6", - "rustls 0.23.40", + "rustls", "rustls-pki-types", "sha1", "thiserror 1.0.69", "utf-8", ] -[[package]] -name = "twox-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" -dependencies = [ - "rand 0.9.4", -] - [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -7930,21 +3236,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization-alignments" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" -dependencies = [ - "smallvec", -] - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - [[package]] name = "unicode-width" version = "0.2.2" @@ -7957,37 +3248,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls 0.23.40", - "rustls-pki-types", - "serde", - "serde_json", - "socks", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "url" version = "2.5.8" @@ -8000,24 +3266,12 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-ranges" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -8038,29 +3292,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" @@ -8073,12 +3304,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vsimd" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" - [[package]] name = "walkdir" version = "2.5.0" @@ -8124,9 +3349,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -8137,9 +3362,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -8147,9 +3372,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8157,22 +3382,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -8194,24 +3419,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.14.0", + "indexmap", "wasm-encoder", "wasmparser", ] -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -8233,15 +3445,15 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.1", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap", "semver", ] [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -8257,6 +3469,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-root-certs" version = "1.0.7" @@ -8284,12 +3508,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - [[package]] name = "winapi" version = "0.3.9" @@ -8312,7 +3530,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8342,7 +3560,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -8353,7 +3571,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -8558,18 +3776,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.2" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -8596,7 +3805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -8607,10 +3816,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", + "heck", + "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -8626,7 +3835,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -8639,7 +3848,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.1", - "indexmap 2.14.0", + "indexmap", "log", "serde", "serde_derive", @@ -8658,7 +3867,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.14.0", + "indexmap", "log", "semver", "serde", @@ -8668,73 +3877,12 @@ dependencies = [ "wasmparser", ] -[[package]] -name = "wkb" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a120b336c7ad17749026d50427c23d838ecb50cd64aaea6254b5030152f890a9" -dependencies = [ - "byteorder", - "geo-traits", - "num_enum", - "thiserror 1.0.69", -] - -[[package]] -name = "wkt" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efb2b923ccc882312e559ffaa832a055ba9d1ac0cc8e86b3e25453247e4b81d7" -dependencies = [ - "geo-traits", - "geo-types", - "log", - "num-traits", - "thiserror 1.0.69", -] - [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "xmlparser" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" - -[[package]] -name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "yoke" version = "0.8.2" @@ -8754,35 +3902,35 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -8795,7 +3943,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -8835,69 +3983,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] -[[package]] -name = "zlib-rs" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" - [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] diff --git a/Cargo.toml b/Cargo.toml index 170591fd..04a80c34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,8 @@ [workspace] -members = ["crates/accelerator", "crates/machine", "crates/cli", "crates/utils"] +members = ["crates/accelerator", "crates/machine", "crates/cli", "crates/server", "crates/utils", "crates/storage"] resolver = "3" [workspace.dependencies] -rig = "0.36" tokio = { version = "1", features = ["full"] } anyhow = "1" tracing = "0.1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..887a4c16 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Eric Sanchezok + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..f0b3ce57 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# RCM — Recursive Context Machine + +RCM is a Rust runtime for composable, stateful AI workflows. Pipelines are +described with `.rcm` files and can combine model calls, tools, MCP servers, +LSP diagnostics, storage, and explicit state transitions. + +The public source, release assets, installers, and Homebrew Formula live in +this repository: + +<https://github.com/EricSanchezok/recursive-context-machine> + +## Install + +### Homebrew + +The Formula is generated from each release and is available directly from this +repository: + +```bash +brew install --formula \ + https://raw.githubusercontent.com/EricSanchezok/recursive-context-machine/main/Formula/rcm.rb +``` + +### Shell installer + +```bash +curl -fsSL https://raw.githubusercontent.com/EricSanchezok/recursive-context-machine/main/install.sh | sh +``` + +On Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/EricSanchezok/recursive-context-machine/main/install.ps1 | iex +``` + +Manual downloads are published on the [Releases page](https://github.com/EricSanchezok/recursive-context-machine/releases/latest). + +## Quick start + +Build the CLI from source and inspect an example pipeline: + +```bash +cargo run --release --bin accelerate -- inventory examples/general +cargo run --release --bin accelerate -- parse examples/general/general.rcm +cargo run --release --bin accelerate -- run examples/general/general.rcm --stream +``` + +Other complete examples include: + +- `examples/city-halfday/city_halfday.rcm` — a compact multi-step workflow. +- `examples/cook-tonight/cook_tonight.rcm` — a tool-oriented workflow. +- `examples/paper-scout/paper_scout.rcm` — a research and synthesis workflow. +- `examples/project-maintainer/dispatch.toml` — event routing for the maintainer pipeline. + +Each example directory contains its own README with setup and runtime notes. + +## gRPC and Python SDK + +Run the server locally: + +```bash +cargo run --release -p server --bin rcm-server +``` + +The Python SDK is an independent package under `sdks/python`. Install it and +regenerate the protobuf stubs when `proto/rcm.proto` changes: + +```bash +cd sdks/python +pip install -e . grpcio-tools +bash generate.sh +PYTHONPATH=src python examples/echo_agent.py "hello from Python" +``` + +The SDK examples also include a local MCP math server. See +[`sdks/python/README.md`](sdks/python/README.md) for the API shape and model +credential setup. + +## Development + +Normal development happens on `dev` or a feature branch. `main` is updated only +by the automated release-promotion pull request. Useful local checks are: + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --tests --locked -- -D warnings +cargo nextest run --workspace --locked +cargo test --workspace --doc --locked +node scripts/run-gates.mjs +``` + +Read [`AGENTS.md`](AGENTS.md) for repository rules, and the focused guides in +[`docs/`](docs/) for architecture, development, testing, and release policy. + +## License + +RCM is released under the [MIT License](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..173b84b9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security policy + +## Reporting a vulnerability + +Please report security vulnerabilities privately — do **not** open a public issue. + +- **Contact**: https://github.com/EricSanchezok/recursive-context-machine/security/advisories/new +- **Response target**: acknowledgment within 3 business days; a fix plan within 10 business days. +- **Scope**: the software in this repository and its released artifacts. + +## Supported versions + +Only the latest release receives security fixes. Older versions are patched on a best-effort basis. + +## Disclosure + +We coordinate disclosure: the reporter and maintainers agree on timing before a public announcement, with credit to the reporter unless anonymity is requested. diff --git a/crates/AGENTS.md b/crates/AGENTS.md new file mode 100644 index 00000000..d524795d --- /dev/null +++ b/crates/AGENTS.md @@ -0,0 +1,25 @@ +# Rust crates + +The crates in this directory form one Cargo workspace. Keep package versions, +path dependencies, and public vocabulary consistent with the workspace root. + +## Boundaries + +- `machine` owns context, model protocol types, policies, the reactor, tools, and + execution state. +- `accelerator` owns graph composition, Flux routing, resource catalogs, built-in + tools, MCP, LSP, and trajectory integration. +- `storage` owns WAL, indexes, snapshots, and persistence errors. +- `server` owns the gRPC transport and maps protobuf messages to domain actions. +- `cli` owns the `accelerate` binary, RCM parser/compiler, output, and dispatch. +- `utils` owns shared identifiers and naming constraints. + +Respect these seams when adding code; a cross-crate move needs an architecture +update and a decision record when alternatives are meaningful. + +## Verification + +Use the workspace commands in the root [AGENTS.md](../AGENTS.md). Unit tests stay +with their owning module; integration tests stay under each crate's `tests/`. +Use `cargo nextest run -p <crate> --locked` for focused evidence and the complete +workspace command before a release. diff --git a/crates/accelerator/Cargo.toml b/crates/accelerator/Cargo.toml index 0e875fb0..c82cf6ee 100644 --- a/crates/accelerator/Cargo.toml +++ b/crates/accelerator/Cargo.toml @@ -2,28 +2,27 @@ name = "accelerator" version = "0.1.0" edition = "2024" +publish = false description = "RCM accelerator — lightweight execution entry for Context Machine" [dependencies] -machine = { path = "../machine" } -utils = { path = "../utils" } +machine = { version = "0.1.0", path = "../machine" } +utils = { version = "0.1.0", path = "../utils" } +storage = { version = "0.1.0", path = "../storage" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "registry", "json"] } -tracing-appender = "0.2" +tracing = { workspace = true } chrono = { workspace = true } regex = "1" ignore = "0.4" -tree-sitter = "0.26.8" -tree-sitter-rust = "0.24.2" -tree-sitter-python = "0.25.0" -tree-sitter-javascript = "0.25.0" -tree-sitter-typescript = "0.23.2" -tree-sitter-go = "0.25.0" -tree-sitter-java = "0.23.5" globset = "0.4.18" -reqwest = { version = "0.12", default-features = false, features = ["json"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "http2", "brotli", "gzip", "deflate", "cookies"] } +base64 = "0.22" +futures-util = "0.3" url = "2.5.8" lsp-types = "0.97.0" +scraper = "0.27" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/accelerator/src/accelerator.rs b/crates/accelerator/src/accelerator.rs index 4496970c..f926b325 100644 --- a/crates/accelerator/src/accelerator.rs +++ b/crates/accelerator/src/accelerator.rs @@ -1,176 +1,337 @@ -use machine::{Machine, Purpose}; -use std::future::Future; -use std::pin::Pin; -use utils::{AcceleratorId, ConditionId, FluxId, Name}; +use std::sync::Arc; -use crate::condition::ConditionBranch; +use machine::hook; +use machine::{ + Action, ExecutionMode, Fragment, Machine, MachineFrame, MachineState, PolicyView, Role, + RunState, Tool, ToolRuntime, +}; +use utils::{AcceleratorId, Name}; -use crate::state::State; +use crate::graph::Graph; +use crate::trajectory::TrajectoryRecorder; -/// A single agent — runs the Context Machine. +fn is_scaffolding(fragment: &Fragment) -> bool { + fragment.role == Role::System + && (fragment.tag == "agent" || fragment.tag == "instruction" || fragment.tag == "env") +} + +fn is_purpose_tag(fragment: &Fragment) -> bool { + fragment.role == Role::User + && (fragment.tag == "purpose" + || fragment.tag == "purpose_initial" + || fragment.tag == "purpose_b") +} + +#[derive(Clone)] pub struct Accelerator { id: AcceleratorId, pub name: Name, - pub(crate) state: State, + body: AcceleratorBody, } impl Accelerator { - pub fn id(&self) -> &AcceleratorId { - &self.id + pub fn primitive( + state: RunState, + policy: Box<dyn machine::Policy>, + tool_runtime: ToolRuntime, + name: impl Into<String>, + ) -> Self { + Self { + id: AcceleratorId::new(), + name: Name::new(name).expect("accelerator name must be valid"), + body: AcceleratorBody::Primitive(Box::new(PrimitiveAccelerator { + state, + policy, + tool_runtime, + })), + } } - pub fn new(state: State) -> Self { - Self::named("accelerator", state) + pub fn composite(graph: Graph) -> Self { + let name = graph.name.clone(); + Self::composite_named(name.as_str(), graph) } - pub fn named(name: impl Into<String>, state: State) -> Self { + pub fn composite_named(name: impl Into<String>, graph: Graph) -> Self { Self { id: AcceleratorId::new(), name: Name::new(name).expect("accelerator name must be valid"), - state, + body: AcceleratorBody::Composite(graph), } } - pub fn run(self) -> Pin<Box<dyn Future<Output = State> + Send>> { - Box::pin(async move { fire(self.state).await }) + /// Inject a tool into this accelerator's tool runtime and tool definitions. + /// Used by the DSL compiler to wire spawn tools into a planner. + pub fn inject_tool(&mut self, tool: Arc<dyn Tool>) { + if let AcceleratorBody::Primitive(ref mut primitive) = self.body { + let name = tool.name().to_string(); + primitive + .state + .resources + .tool_definitions + .entry(name.clone()) + .or_insert_with(|| machine::ToolDefinition::from_tool(tool.as_ref())); + primitive.tool_runtime.insert(tool); + } } -} -pub(crate) async fn fire(mut state: State) -> State { - let purpose = Purpose::new(&state.purpose); - let machine = Machine::new(state.policy.clone()); - machine - .run(&purpose, &mut state.ctx, &mut state.env, &mut state.res) - .await; - state -} - -#[derive(Clone, Debug)] -pub struct AcceleratorRef { - pub(crate) index: usize, - pub(crate) id: AcceleratorId, -} - -impl AcceleratorRef { pub fn id(&self) -> &AcceleratorId { &self.id } - pub fn purpose_out(&self) -> Port { - self.port(Channel::Purpose) - } - pub fn ctx_out(&self) -> Port { - self.port(Channel::Context) - } - pub fn env_out(&self) -> Port { - self.port(Channel::Environment) - } - pub fn policy_out(&self) -> Port { - self.port(Channel::Policy) - } - pub fn res_out(&self) -> Port { - self.port(Channel::Resources) - } - pub fn done(&self) -> Port { - self.port(Channel::Pulse) - } - pub fn purpose_in(&self) -> Port { - self.port(Channel::Purpose) - } - pub fn ctx_in(&self) -> Port { - self.port(Channel::Context) - } - pub fn env_in(&self) -> Port { - self.port(Channel::Environment) - } - pub fn policy_in(&self) -> Port { - self.port(Channel::Policy) - } - pub fn res_in(&self) -> Port { - self.port(Channel::Resources) + pub fn run_with( + self, + input: RunState, + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = RunState> + Send>> { + Box::pin(async move { + let input = self.merge_input(input); + // Label for the trajectory WAL directory: readable name plus the + // unique accelerator id, so graph components sharing a run_dir + // never collide on the same WAL. + let label = format!("{}-{}", self.name.as_str(), self.id); + match self.body { + AcceleratorBody::Primitive(primitive) => primitive.fire(input, &label).await, + AcceleratorBody::Composite(graph) => graph.run(input).await, + } + }) } - pub fn trigger(&self) -> Port { - self.port(Channel::Pulse) + + pub fn internal_state(&self) -> Option<&RunState> { + match &self.body { + AcceleratorBody::Primitive(primitive) => Some(&primitive.state), + AcceleratorBody::Composite(_) => None, + } } - fn port(&self, channel: Channel) -> Port { - Port::Accel { - index: self.index, - accelerator_id: self.id.clone(), - channel, + fn merge_input(&self, input: RunState) -> RunState { + let mut state = input; + if let AcceleratorBody::Primitive(primitive) = &self.body { + let base = &primitive.state; + let base_purpose = base.purpose.text.clone(); + if !state.purpose.is_empty() + && !base_purpose.is_empty() + && state.purpose.text != base_purpose + { + state.purpose.text = format!("{}\n\n{}", state.purpose.text, base_purpose); + } else if state.purpose.is_empty() { + state.purpose.text.clone_from(&base_purpose); + } + if state.run_dir.is_none() { + state.run_dir.clone_from(&base.run_dir); + // When a child inherits run_dir, also use the parent's cwd + // so fs tools resolve paths inside the run directory. + if let Some(ref dir) = state.run_dir { + state.environment.cwd = dir.clone(); + } + } + if state.context.is_empty() { + state.context = base.context.clone(); + } + if state.environment.cwd.as_os_str().is_empty() { + state.environment = base.environment.clone(); + } else if state.environment.run_dir.is_none() { + state + .environment + .run_dir + .clone_from(&base.environment.run_dir); + } + state.resources.models.clone_from(&base.resources.models); + state + .resources + .model_order + .clone_from(&base.resources.model_order); + state + .resources + .tool_definitions + .clone_from(&base.resources.tool_definitions); + state.resources.prompts.clone_from(&base.resources.prompts); + state + .resources + .active_model + .clone_from(&base.resources.active_model); + state + .resources + .active_tools + .clone_from(&base.resources.active_tools); } + state } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum Channel { - Purpose, - Context, - Environment, - Policy, - Resources, - Pulse, +#[derive(Clone)] +#[allow(clippy::large_enum_variant)] +enum AcceleratorBody { + Primitive(Box<PrimitiveAccelerator>), + Composite(Graph), } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum Port { - Accel { - index: usize, - accelerator_id: AcceleratorId, - channel: Channel, - }, - FluxOut { - index: usize, - flux_id: FluxId, - channel: Channel, - }, - FluxSlot { - index: usize, - flux_id: FluxId, - slot: usize, - channel: Channel, - }, - ConditionIn { - index: usize, - condition_id: ConditionId, - }, - ConditionOut { - index: usize, - condition_id: ConditionId, - branch: ConditionBranch, - }, +#[derive(Clone)] +struct PrimitiveAccelerator { + state: RunState, + policy: Box<dyn machine::Policy>, + tool_runtime: ToolRuntime, +} + +impl PrimitiveAccelerator { + async fn fire(self, state: RunState, machine_label: &str) -> RunState { + let mut machine_state = MachineState { + run: state, + frame: MachineFrame::default(), + }; + let base_purpose = machine_state.run.purpose.text.clone(); + let needs_reorder = machine_state + .run + .context + .fragments() + .iter() + .any(|fragment| !is_scaffolding(fragment) && !is_purpose_tag(fragment)); + + let mut machine = Machine::new("ephemeral", "ephemeral"); + let policy = self.policy; + let tool_runtime = self.tool_runtime; + let mut reorder_pending = needs_reorder; + // Trajectory recording is opt-in: only when the caller supplied a + // run directory. Graph components share run_dir but run concurrently, + // so each recorder writes its own per-machine WAL under trajectory/. + let mut recorder = machine_state.run.run_dir.as_ref().and_then(|run_dir| { + match TrajectoryRecorder::open(run_dir, machine_label) { + Ok(recorder) => Some(recorder), + Err(error) => { + tracing::warn!( + run_dir = %run_dir.display(), + ?error, + "trajectory recorder unavailable; continuing without recording" + ); + None + } + } + }); + hook!(event = "machine_start", purpose = %machine_state.run.purpose.text); + + loop { + // Derived fresh each step: obs must never be a stale snapshot. + // The ledger digest is enriched from tool state — the machine + // itself never performs IO. + let mut obs = machine::obs::measure(&machine_state.run); + if let Some(run_dir) = machine_state.run.run_dir.as_deref() { + obs.ledger_digest = crate::tools::ledger_digest_for(run_dir); + } + let action = policy + .decide(PolicyView { + run: &machine_state.run, + inbox: &machine_state.frame.inbox, + step: machine_state.frame.step, + status: machine_state.frame.status, + obs: &obs, + }) + .await; + + if matches!(&action, Action::Halt) && reorder_pending { + reorder_pending = false; + reorder_context_before_first_halt(&mut machine_state.run, &base_purpose); + } + + // Overlay is declared alongside the decision but consumed only + // when that decision is Halt; every other action carries an + // empty declaration. + let overlay_declared = if matches!(action, Action::Halt) { + let overlay = policy.overlay(&PolicyView { + run: &machine_state.run, + inbox: &machine_state.frame.inbox, + step: machine_state.frame.step, + status: machine_state.frame.status, + obs: &obs, + }); + obs.overlay_status = machine::OverlayStatus { + declared: !overlay.is_empty(), + system_prefix_count: overlay.system_prefix.len() as u64, + tail_count: overlay.tail.len() as u64, + }; + overlay + } else { + machine::Overlay::default() + }; + + let result = machine + .apply( + action, + &mut machine_state, + ExecutionMode::Live { + tool_runtime: &tool_runtime, + overlay: &overlay_declared, + }, + ) + .await; + if let Some(ref mut recorder) = recorder { + let ledger_transitions = machine::ledger_transitions_in(&result.event.effects); + recorder.record_step(result.event.step, &obs, &ledger_transitions, &result.event); + } + if result.done { + break; + } + } + + if let Some(ref mut recorder) = recorder { + recorder.checkpoint(&machine_state); + } + machine_state.run + } } -impl Port { - pub fn is_output(&self) -> bool { - matches!( - self, - Port::Accel { .. } | Port::FluxOut { .. } | Port::ConditionOut { .. } - ) +fn reorder_context_before_first_halt(state: &mut RunState, base_purpose: &str) { + let env_position = state + .context + .fragments() + .iter() + .position(|fragment| is_scaffolding(fragment) && fragment.tag == "env"); + let Some(env_position) = env_position else { + return; + }; + let env_id = state.context.fragments()[env_position].id(); + let before_env = state.context.fragments()[..env_position] + .iter() + .filter(|fragment| !is_scaffolding(fragment) && !is_purpose_tag(fragment)) + .map(|fragment| (fragment.id(), fragment.clone())) + .collect::<Vec<_>>(); + if before_env.is_empty() { + return; } - pub fn is_input(&self) -> bool { - matches!( - self, - Port::Accel { .. } | Port::FluxSlot { .. } | Port::ConditionIn { .. } - ) + for (id, _) in &before_env { + let _ = state.context.remove(*id); } - pub fn channel(&self) -> Channel { - match self { - Port::Accel { channel, .. } - | Port::FluxOut { channel, .. } - | Port::FluxSlot { channel, .. } => *channel, - Port::ConditionIn { .. } | Port::ConditionOut { .. } => Channel::Pulse, + let mut cursor = env_id; + for (_, fragment) in &before_env { + if let Ok(new_id) = state.context.insert(cursor, fragment.clone()) { + cursor = new_id; } } - pub(crate) fn node_index(&self, num_accelerators: usize, num_fluxes: usize) -> usize { - let flux_offset = |index: usize| num_accelerators + index; - let condition_offset = |index: usize| num_accelerators + num_fluxes + index; - match self { - Port::Accel { index, .. } => *index, - Port::FluxOut { index, .. } | Port::FluxSlot { index, .. } => flux_offset(*index), - Port::ConditionIn { index, .. } | Port::ConditionOut { index, .. } => { - condition_offset(*index) - } - } + if !base_purpose.is_empty() { + let _ = state + .context + .insert(cursor, Fragment::user(base_purpose).with_tag("purpose_b")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use machine::Fragment; + + fn state_with_assistant(text: &str) -> RunState { + let mut state = RunState::default(); + state.context.append(Fragment::assistant(text)); + state + } + + #[test] + fn run_dir_recovered_from_handoff() { + let input = state_with_assistant( + "run_dir: runs/20260604T110702Z\nartifact: runs/20260604T110702Z/00_card_plan.json\nstatus: ok", + ); + assert!(input.context.fragments().iter().any(|f| { + f.as_text() + .unwrap_or("") + .contains("run_dir: runs/20260604T110702Z") + })); } } diff --git a/crates/accelerator/src/assembly.rs b/crates/accelerator/src/assembly.rs deleted file mode 100644 index 1bca09cd..00000000 --- a/crates/accelerator/src/assembly.rs +++ /dev/null @@ -1,365 +0,0 @@ -use std::collections::HashMap; -use std::collections::VecDeque; -use std::future::Future; -use std::pin::Pin; - -use machine::hook; -use machine::{Context, Environment, Policy, Resources}; -use tracing::trace; -use utils::{AcceleratorId, AssemblyId, Name, SlotId}; - -use crate::accelerator::{Channel, Port, fire}; -use crate::condition::Predicate; -use crate::flux::Flux; -use crate::state::State; - -/// A frozen multi-agent graph ready to run. -pub struct Assembly { - pub(crate) id: AssemblyId, - pub name: Name, - pub(crate) slots: Vec<Slot>, - pub(crate) fluxes: Vec<Flux>, - pub(crate) downstream: Vec<Vec<usize>>, - pub(crate) pending: Vec<usize>, - pub(crate) active_inputs: Vec<usize>, - pub(crate) skipped: Vec<bool>, - pub(crate) state_wires: HashMap<Port, Port>, - pub(crate) flux_slot_wires: HashMap<(usize, usize), Port>, - pub(crate) is_sink: Vec<bool>, - pub(crate) condition_routing: Vec<ConditionRouting>, - pub(crate) condition_map: HashMap<usize, Vec<usize>>, -} - -pub(crate) struct ConditionRouting { - pub predicate: Predicate, - pub true_target: usize, - pub false_target: usize, -} - -pub(crate) struct Slot { - id: SlotId, - accelerator_id: AcceleratorId, - pub name: Name, - pub input: State, - pub output: Option<State>, -} - -impl Slot { - pub fn new(accelerator_id: AcceleratorId, name: Name, state: State) -> Self { - Self { - id: SlotId::new(), - accelerator_id, - name, - input: state, - output: None, - } - } - - fn port(&self, index: usize, channel: Channel) -> Port { - Port::Accel { - index, - accelerator_id: self.accelerator_id.clone(), - channel, - } - } -} - -impl Assembly { - pub fn id(&self) -> &AssemblyId { - &self.id - } - - pub fn run(mut self) -> Pin<Box<dyn Future<Output = Vec<State>> + Send>> { - Box::pin(async move { - let mut queue = VecDeque::new(); - for (id, count) in self.pending.iter().enumerate() { - if *count == 0 { - queue.push_back(id); - } - } - - while let Some(id) = queue.pop_front() { - let slot_id = self.slots[id].id.clone(); - let slot_name = self.slots[id].name.clone(); - trace!(assembly = %self.id, graph = %self.name, slot = id, slot_id = %slot_id, slot_name = %slot_name, "running"); - - hook!( - event = "slot_started", - assembly = self.id.as_str(), - graph = self.name.as_str(), - slot = id, - slot_id = slot_id.as_str(), - name = slot_name.as_str() - ); - - let mut state = self.slots[id].input.clone(); - state.purpose = self.resolve_purpose(id); - state.ctx = self.resolve_ctx(id); - state.env = self.resolve_env(id); - state.policy = self.resolve_policy(id); - state.res = self.resolve_res(id); - - let output = fire(state).await; - self.slots[id].output = Some(output); - - hook!( - event = "slot_finished", - assembly = self.id.as_str(), - graph = self.name.as_str(), - slot = id, - slot_id = slot_id.as_str(), - name = slot_name.as_str() - ); - - self.route_conditions(id, &mut queue); - self.release_downstream(id, &mut queue); - } - - self.sink_outputs() - }) - } - - fn route_conditions(&mut self, source: usize, queue: &mut VecDeque<usize>) { - let Some(routing_ids) = self.condition_map.get(&source) else { - return; - }; - let routing_ids: Vec<usize> = routing_ids.to_vec(); - - for routing_id in routing_ids { - let routing = &self.condition_routing[routing_id]; - let output = self.slots[source] - .output - .as_ref() - .expect("condition source did not run"); - let matched = routing.predicate.evaluate(output); - let selected = if matched { - routing.true_target - } else { - routing.false_target - }; - let skipped = if matched { - routing.false_target - } else { - routing.true_target - }; - self.release_slot(selected, queue); - self.skip_slot(skipped, queue); - } - } - - fn release_downstream(&mut self, source: usize, queue: &mut VecDeque<usize>) { - let downstream_len = self.downstream[source].len(); - for index in 0..downstream_len { - let target = self.downstream[source][index]; - self.release_slot(target, queue); - } - } - - fn release_slot(&mut self, target: usize, queue: &mut VecDeque<usize>) { - if self.skipped[target] || self.slots[target].output.is_some() { - return; - } - self.active_inputs[target] += 1; - self.complete_dependency(target, queue); - } - - fn skip_slot(&mut self, target: usize, queue: &mut VecDeque<usize>) { - if self.skipped[target] || self.slots[target].output.is_some() { - return; - } - self.skipped[target] = true; - self.skip_downstream(target, queue); - } - - fn skip_downstream(&mut self, source: usize, queue: &mut VecDeque<usize>) { - let downstream_len = self.downstream[source].len(); - for index in 0..downstream_len { - let target = self.downstream[source][index]; - self.complete_dependency(target, queue); - } - } - - fn complete_dependency(&mut self, target: usize, queue: &mut VecDeque<usize>) { - self.pending[target] -= 1; - if self.pending[target] != 0 { - return; - } - if self.active_inputs[target] > 0 { - queue.push_back(target); - } else { - self.skip_slot(target, queue); - } - } - - fn sink_outputs(&mut self) -> Vec<State> { - let mut sinks = Vec::new(); - for (id, slot) in self.slots.iter_mut().enumerate() { - if self.is_sink[id] - && let Some(output) = slot.output.take() - { - sinks.push(output); - } - } - sinks - } - - fn resolve_purpose(&self, slot_idx: usize) -> String { - let pin = self.slots[slot_idx].port(slot_idx, Channel::Purpose); - match self.state_wires.get(&pin) { - Some(from) => self.read_purpose(from.clone()), - None => self.slots[slot_idx].input.purpose.clone(), - } - } - - fn read_purpose(&self, pin: Port) -> String { - match pin { - Port::Accel { - index, - channel: Channel::Purpose, - .. - } => self.slots[index] - .output - .as_ref() - .expect("upstream not ready") - .purpose - .clone(), - Port::FluxOut { - index, - channel: Channel::Purpose, - .. - } => crate::flux::apply_purpose(&self.fluxes[index], |slot| { - let from = self.flux_slot_wires[&(index, slot)].clone(); - self.read_purpose(from) - }), - _ => panic!("type mismatch in purpose wire"), - } - } - - fn resolve_ctx(&self, slot_idx: usize) -> Context { - let pin = self.slots[slot_idx].port(slot_idx, Channel::Context); - match self.state_wires.get(&pin) { - Some(from) => self.read_ctx(from.clone()), - None => self.slots[slot_idx].input.ctx.clone(), - } - } - - fn read_ctx(&self, pin: Port) -> Context { - match pin { - Port::Accel { - index, - channel: Channel::Context, - .. - } => self.slots[index] - .output - .as_ref() - .expect("upstream not ready") - .ctx - .clone(), - Port::FluxOut { - index, - channel: Channel::Context, - .. - } => crate::flux::apply_ctx(&self.fluxes[index], |slot| { - let from = self.flux_slot_wires[&(index, slot)].clone(); - self.read_ctx(from) - }), - _ => panic!("type mismatch in ctx wire"), - } - } - - fn resolve_env(&self, slot_idx: usize) -> Environment { - let pin = self.slots[slot_idx].port(slot_idx, Channel::Environment); - match self.state_wires.get(&pin) { - Some(from) => self.read_env(from.clone()), - None => self.slots[slot_idx].input.env.clone(), - } - } - - fn read_env(&self, pin: Port) -> Environment { - match pin { - Port::Accel { - index, - channel: Channel::Environment, - .. - } => self.slots[index] - .output - .as_ref() - .expect("upstream not ready") - .env - .clone(), - Port::FluxOut { - index, - channel: Channel::Environment, - .. - } => crate::flux::apply_env(&self.fluxes[index], |slot| { - let from = self.flux_slot_wires[&(index, slot)].clone(); - self.read_env(from) - }), - _ => panic!("type mismatch in env wire"), - } - } - - fn resolve_policy(&self, slot_idx: usize) -> Box<dyn Policy> { - let pin = self.slots[slot_idx].port(slot_idx, Channel::Policy); - match self.state_wires.get(&pin) { - Some(from) => self.read_policy(from.clone()), - None => self.slots[slot_idx].input.policy.clone(), - } - } - - fn read_policy(&self, pin: Port) -> Box<dyn Policy> { - match pin { - Port::Accel { - index, - channel: Channel::Policy, - .. - } => self.slots[index] - .output - .as_ref() - .expect("upstream not ready") - .policy - .clone(), - Port::FluxOut { - index, - channel: Channel::Policy, - .. - } => crate::flux::apply_policy(&self.fluxes[index], |slot| { - let from = self.flux_slot_wires[&(index, slot)].clone(); - self.read_policy(from) - }), - _ => panic!("type mismatch in policy wire"), - } - } - - fn resolve_res(&self, slot_idx: usize) -> Resources { - let pin = self.slots[slot_idx].port(slot_idx, Channel::Resources); - match self.state_wires.get(&pin) { - Some(from) => self.read_res(from.clone()), - None => self.slots[slot_idx].input.res.clone(), - } - } - - fn read_res(&self, pin: Port) -> Resources { - match pin { - Port::Accel { - index, - channel: Channel::Resources, - .. - } => self.slots[index] - .output - .as_ref() - .expect("upstream not ready") - .res - .clone(), - Port::FluxOut { - index, - channel: Channel::Resources, - .. - } => crate::flux::apply_res(&self.fluxes[index], |slot| { - let from = self.flux_slot_wires[&(index, slot)].clone(); - self.read_res(from) - }), - _ => panic!("type mismatch in res wire"), - } - } -} diff --git a/crates/accelerator/src/catalog.rs b/crates/accelerator/src/catalog.rs new file mode 100644 index 00000000..8101400b --- /dev/null +++ b/crates/accelerator/src/catalog.rs @@ -0,0 +1,282 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use machine::{Environment, Model, Policy, Resources, Tool, ToolDefinition, ToolRuntime}; +use tokio::sync::OnceCell; + +use crate::mcp::McpServerConfig; + +pub type PolicyFactory = Arc<dyn Fn() -> Box<dyn Policy> + Send + Sync>; +pub type EnvironmentFactory = Arc<dyn Fn() -> Environment + Send + Sync>; + +#[derive(Clone, Default)] +pub struct Catalog { + policies: HashMap<String, PolicyFactory>, + environments: HashMap<String, EnvironmentFactory>, + resources: ResourceCatalog, +} + +#[derive(Clone, Default)] +struct ResourceCatalog { + tool_executors: HashMap<String, Arc<dyn Tool>>, + models: HashMap<String, Model>, + prompts: HashMap<String, String>, + mcp_servers: HashMap<String, McpServerEntry>, +} + +#[derive(Clone)] +struct McpServerEntry { + config: McpServerConfig, + tool_executors: Arc<OnceCell<Vec<Arc<dyn Tool>>>>, +} + +#[derive(Clone, Default)] +pub struct ResourceSelection { + pub models: Vec<String>, + pub tools: Vec<String>, + pub mcp_servers: Vec<String>, + pub prompt_texts: HashMap<String, String>, +} + +pub struct RuntimeResources { + pub resources: Resources, + pub tool_runtime: ToolRuntime, +} + +impl Catalog { + pub fn new() -> Self { + let mut catalog = Self::empty(); + + crate::policy::register(&mut catalog); + crate::tools::register(&mut catalog); + crate::prompts::register(&mut catalog); + crate::environment::register(&mut catalog); + + catalog + } + + pub fn empty() -> Self { + Self::default() + } + + pub fn register_policy( + &mut self, + name: impl Into<String>, + factory: impl Fn() -> Box<dyn Policy> + Send + Sync + 'static, + ) -> Result<(), String> { + let name = name.into(); + ensure_name_is_usable(&name, "policy")?; + ensure_name_is_free(&self.policies, &name, "policy")?; + self.policies.insert(name, Arc::new(factory)); + Ok(()) + } + + pub fn register_environment( + &mut self, + name: impl Into<String>, + factory: impl Fn() -> Environment + Send + Sync + 'static, + ) -> Result<(), String> { + let name = name.into(); + ensure_name_is_usable(&name, "environment")?; + ensure_name_is_free(&self.environments, &name, "environment")?; + self.environments.insert(name, Arc::new(factory)); + Ok(()) + } + + pub fn register_tool(&mut self, tool: Arc<dyn Tool>) -> Result<(), String> { + let name = tool.name().to_string(); + ensure_name_is_usable(&name, "tool")?; + ensure_name_is_free(&self.resources.tool_executors, &name, "tool")?; + self.resources.tool_executors.insert(name, tool); + Ok(()) + } + + pub fn register_model(&mut self, model: Model) -> Result<(), String> { + ensure_name_is_usable(&model.name, "model")?; + ensure_name_is_free(&self.resources.models, &model.name, "model")?; + self.resources.models.insert(model.name.clone(), model); + Ok(()) + } + + pub fn register_prompt( + &mut self, + name: impl Into<String>, + content: impl Into<String>, + ) -> Result<(), String> { + let name = name.into(); + ensure_name_is_usable(&name, "prompt")?; + ensure_name_is_free(&self.resources.prompts, &name, "prompt")?; + self.resources.prompts.insert(name, content.into()); + Ok(()) + } + + pub fn register_mcp_server(&mut self, config: McpServerConfig) -> Result<(), String> { + ensure_name_is_usable(&config.label, "mcp server")?; + ensure_name_is_free(&self.resources.mcp_servers, &config.label, "mcp server")?; + self.resources.mcp_servers.insert( + config.label.clone(), + McpServerEntry { + config, + tool_executors: Arc::new(OnceCell::new()), + }, + ); + Ok(()) + } + + pub fn policy(&self, name: &str) -> Result<Box<dyn Policy>, String> { + self.policies + .get(name) + .map(|factory| factory()) + .ok_or_else(|| format!("unknown policy: {name}")) + } + + pub fn environment(&self, name: &str) -> Result<Environment, String> { + self.environments + .get(name) + .map(|factory| factory()) + .ok_or_else(|| format!("unknown environment: {name}")) + } + + pub async fn build_runtime_resources( + &self, + selection: ResourceSelection, + ) -> Result<RuntimeResources, String> { + let mut resources = Resources::new(); + let mut tool_runtime = ToolRuntime::new(); + let mut selected_tool_names = HashSet::new(); + + for (name, content) in selection.prompt_texts { + ensure_name_is_usable(&name, "prompt")?; + resources.prompts.insert(name, content); + } + + for model_name in selection.models { + let model = self + .resources + .models + .get(&model_name) + .cloned() + .ok_or_else(|| format!("unknown model: {model_name}"))?; + resources = resources.with_model(model); + } + + for tool_name in selection.tools { + let tool = self + .resources + .tool_executors + .get(&tool_name) + .cloned() + .ok_or_else(|| format!("unknown tool: {tool_name}"))?; + remember_selected_tool(&mut selected_tool_names, tool.name())?; + add_tool_to_runtime_resources(tool, &mut resources, &mut tool_runtime); + } + + for server_name in selection.mcp_servers { + let entry = self + .resources + .mcp_servers + .get(&server_name) + .ok_or_else(|| format!("unknown mcp server: {server_name}"))?; + let tool_executors = entry + .tool_executors + .get_or_try_init(|| async { + let registry = + crate::mcp::McpRegistry::start(std::slice::from_ref(&entry.config)).await?; + Ok::<_, String>(registry.tools_for(&server_name).unwrap_or_default()) + }) + .await?; + for tool in tool_executors { + ensure_name_is_usable(tool.name(), "mcp tool")?; + remember_selected_tool(&mut selected_tool_names, tool.name())?; + add_tool_to_runtime_resources(tool.clone(), &mut resources, &mut tool_runtime); + } + } + + Ok(RuntimeResources { + resources, + tool_runtime, + }) + } + + pub fn policy_names(&self) -> Vec<String> { + sorted_names(self.policies.keys()) + } + + pub fn tool_names(&self) -> Vec<String> { + sorted_names(self.resources.tool_executors.keys()) + } + + pub fn prompt_names(&self) -> Vec<String> { + sorted_names(self.resources.prompts.keys()) + } + + pub fn model_names(&self) -> Vec<String> { + sorted_names(self.resources.models.keys()) + } + + pub fn environment_names(&self) -> Vec<String> { + sorted_names(self.environments.keys()) + } + + pub fn mcp_server_names(&self) -> Vec<String> { + sorted_names(self.resources.mcp_servers.keys()) + } +} + +fn add_tool_to_runtime_resources( + tool: Arc<dyn Tool>, + resources: &mut Resources, + tool_runtime: &mut ToolRuntime, +) { + resources.tool_definitions.insert( + tool.name().to_string(), + ToolDefinition::from_tool(tool.as_ref()), + ); + tool_runtime.insert(tool); +} + +fn sorted_names<'a>(names: impl Iterator<Item = &'a String>) -> Vec<String> { + let mut names: Vec<String> = names.cloned().collect(); + names.sort(); + names +} + +fn remember_selected_tool( + selected_tool_names: &mut HashSet<String>, + name: &str, +) -> Result<(), String> { + if selected_tool_names.insert(name.to_string()) { + Ok(()) + } else { + Err(format!("duplicate selected tool: {name}")) + } +} + +fn ensure_name_is_usable(name: &str, kind: &str) -> Result<(), String> { + if name.is_empty() { + return Err(format!("{kind} name must not be empty")); + } + if name.len() > 64 { + return Err(format!("{kind} name '{name}' is too long")); + } + if !name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-') + { + return Err(format!( + "{kind} name '{name}' may only contain ASCII letters, numbers, '_' and '-'" + )); + } + Ok(()) +} + +fn ensure_name_is_free<Value>( + entries: &HashMap<String, Value>, + name: &str, + kind: &str, +) -> Result<(), String> { + if entries.contains_key(name) { + return Err(format!("duplicate {kind}: {name}")); + } + Ok(()) +} diff --git a/crates/accelerator/src/condition.rs b/crates/accelerator/src/condition.rs index 78feee7e..58b913c2 100644 --- a/crates/accelerator/src/condition.rs +++ b/crates/accelerator/src/condition.rs @@ -1,59 +1,22 @@ -use machine::{Content, Environment, Fragment, Resources, Role}; +use machine::{Content, Environment, Fragment, Resources, Role, RunState}; use serde::{Deserialize, Serialize}; use utils::{ConditionId, Name}; -use crate::accelerator::Port; -use crate::state::State; - -#[derive(Clone, Debug)] -pub struct ConditionRef { - pub(crate) index: usize, - pub(crate) id: ConditionId, -} - -impl ConditionRef { - pub fn id(&self) -> &ConditionId { - &self.id - } - - pub fn trigger(&self) -> Port { - Port::ConditionIn { - index: self.index, - condition_id: self.id.clone(), - } - } - - pub fn pulse_true(&self) -> Port { - self.out(ConditionBranch::True) - } - - pub fn pulse_false(&self) -> Port { - self.out(ConditionBranch::False) - } - - fn out(&self, branch: ConditionBranch) -> Port { - Port::ConditionOut { - index: self.index, - condition_id: self.id.clone(), - branch, - } - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ConditionBranch { True, False, } -pub(crate) struct Condition { +#[derive(Clone)] +pub struct Condition { id: ConditionId, pub name: Name, pub predicate: Predicate, } impl Condition { - pub(crate) fn new(name: impl Into<String>, predicate: Predicate) -> Self { + pub fn new(name: impl Into<String>, predicate: Predicate) -> Self { Self { id: ConditionId::new(), name: Name::new(name).expect("condition name must be valid"), @@ -61,9 +24,17 @@ impl Condition { } } - pub(crate) fn id(&self) -> &ConditionId { + pub fn id(&self) -> &ConditionId { &self.id } + + pub fn route(&self, state: &RunState) -> ConditionBranch { + if self.predicate.evaluate(state) { + ConditionBranch::True + } else { + ConditionBranch::False + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -78,12 +49,12 @@ pub enum Predicate { } impl Predicate { - pub fn evaluate(&self, state: &State) -> bool { + pub fn evaluate(&self, state: &RunState) -> bool { match self { - Self::Purpose(predicate) => predicate.evaluate(&state.purpose), - Self::Context(predicate) => predicate.evaluate(&state.ctx), - Self::Environment(predicate) => predicate.evaluate(&state.env), - Self::Resources(predicate) => predicate.evaluate(&state.res), + Self::Purpose(predicate) => predicate.evaluate(&state.purpose.text), + Self::Context(predicate) => predicate.evaluate(&state.context), + Self::Environment(predicate) => predicate.evaluate(&state.environment), + Self::Resources(predicate) => predicate.evaluate(&state.resources), Self::All(predicates) => predicates.iter().all(|predicate| predicate.evaluate(state)), Self::Any(predicates) => predicates.iter().any(|predicate| predicate.evaluate(state)), Self::Not(predicate) => !predicate.evaluate(state), @@ -159,12 +130,12 @@ pub enum EnvironmentPredicate { } impl EnvironmentPredicate { - fn evaluate(&self, env: &Environment) -> bool { + fn evaluate(&self, environment: &Environment) -> bool { match self { - Self::VarExists(key) => env.vars.contains_key(key), - Self::VarEquals(key, value) => env.vars.get(key) == Some(value), - Self::CwdContains(value) => env.cwd.to_string_lossy().contains(value), - Self::PlatformIs(value) => env.platform == *value, + Self::VarExists(key) => environment.vars.contains_key(key), + Self::VarEquals(key, value) => environment.vars.get(key) == Some(value), + Self::CwdContains(value) => environment.cwd.to_string_lossy().contains(value), + Self::PlatformIs(value) => environment.platform == *value, } } } @@ -183,7 +154,7 @@ impl ResourcesPredicate { match self { Self::HasModel(name) => resources.models.contains_key(name), Self::ActiveModelIs(name) => resources.active_model == *name, - Self::HasTool(name) => resources.tools.contains_key(name), + Self::HasTool(name) => resources.tool_definitions.contains_key(name), Self::ToolEnabled(name) => resources.active_tools.contains(name), Self::HasPrompt(name) => resources.prompts.contains_key(name), } diff --git a/crates/accelerator/src/environment/mod.rs b/crates/accelerator/src/environment/mod.rs new file mode 100644 index 00000000..8acfcc38 --- /dev/null +++ b/crates/accelerator/src/environment/mod.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +use machine::Environment; + +use crate::catalog::Catalog; + +pub fn register(catalog: &mut Catalog) { + catalog + .register_environment("local", local) + .expect("built-in environment names must be unique"); +} + +fn local() -> Environment { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut environment = Environment::named("local", cwd); + environment.root = Some(environment.cwd.clone()); + environment +} diff --git a/crates/accelerator/src/flux.rs b/crates/accelerator/src/flux.rs index cdfd6e09..3c794c63 100644 --- a/crates/accelerator/src/flux.rs +++ b/crates/accelerator/src/flux.rs @@ -1,74 +1,67 @@ -use machine::{Context, Environment, Policy, Resources}; +use machine::{Context, Environment, Fragment, Resources, Role, RunState}; use utils::{FluxId, Name}; -use crate::accelerator::Channel; - -#[derive(Clone, Debug)] -pub struct FluxRef { - pub(crate) index: usize, - pub(crate) id: FluxId, - pub(crate) channel: Channel, -} - -impl FluxRef { - pub fn id(&self) -> &FluxId { - &self.id - } - - pub fn slot(&self, slot: usize) -> crate::accelerator::Port { - crate::accelerator::Port::FluxSlot { - index: self.index, - flux_id: self.id.clone(), - slot, - channel: self.channel, - } - } - pub fn out(&self) -> crate::accelerator::Port { - crate::accelerator::Port::FluxOut { - index: self.index, - flux_id: self.id.clone(), - channel: self.channel, - } - } -} +use crate::wire::Channel; +#[derive(Clone)] pub enum PurposeFlux { Concat, } +#[derive(Clone)] pub enum ContextFlux { Append, - Replace, + Last, + Digest, + Thread, } +#[derive(Clone)] pub enum EnvFlux { Overlay, } +#[derive(Clone)] pub enum ResFlux { Merge, } -pub enum PolicyFlux { - Replace, +#[derive(Clone, Copy)] +pub enum BridgeKind { + ContextToPurpose, } +#[derive(Clone)] pub enum FluxMode { Purpose(PurposeFlux), Context(ContextFlux), Environment(EnvFlux), Resources(ResFlux), - Policy(PolicyFlux), + Bridge { + from: Channel, + to: Channel, + kind: BridgeKind, + }, } impl FluxMode { - pub fn channel(&self) -> Channel { + pub fn input_channel(&self) -> Channel { match self { FluxMode::Purpose(_) => Channel::Purpose, FluxMode::Context(_) => Channel::Context, FluxMode::Environment(_) => Channel::Environment, FluxMode::Resources(_) => Channel::Resources, - FluxMode::Policy(_) => Channel::Policy, + FluxMode::Bridge { from, .. } => *from, + } + } + + pub fn output_channel(&self) -> Channel { + match self { + FluxMode::Purpose(_) => Channel::Purpose, + FluxMode::Context(_) => Channel::Context, + FluxMode::Environment(_) => Channel::Environment, + FluxMode::Resources(_) => Channel::Resources, + FluxMode::Bridge { to, .. } => *to, } } @@ -76,15 +69,21 @@ impl FluxMode { match self { FluxMode::Purpose(PurposeFlux::Concat) => "purpose_concat", FluxMode::Context(ContextFlux::Append) => "context_append", - FluxMode::Context(ContextFlux::Replace) => "context_replace", + FluxMode::Context(ContextFlux::Last) => "context_last", + FluxMode::Context(ContextFlux::Digest) => "context_digest", + FluxMode::Context(ContextFlux::Thread) => "context_thread", FluxMode::Environment(EnvFlux::Overlay) => "environment_overlay", FluxMode::Resources(ResFlux::Merge) => "resources_merge", - FluxMode::Policy(PolicyFlux::Replace) => "policy_replace", + FluxMode::Bridge { + kind: BridgeKind::ContextToPurpose, + .. + } => "bridge_context_to_purpose", } } } -pub(crate) struct Flux { +#[derive(Clone)] +pub struct Flux { id: FluxId, pub name: Name, pub mode: FluxMode, @@ -92,7 +91,7 @@ pub(crate) struct Flux { } impl Flux { - pub(crate) fn new(name: impl Into<String>, mode: FluxMode, arity: usize) -> Self { + pub fn new(name: impl Into<String>, mode: FluxMode, arity: usize) -> Self { Self { id: FluxId::new(), name: Name::new(name).expect("flux name must be valid"), @@ -101,12 +100,49 @@ impl Flux { } } - pub(crate) fn id(&self) -> &FluxId { + pub fn id(&self) -> &FluxId { &self.id } + + pub fn apply(&self, slots: &[RunState]) -> RunState { + let mut state = RunState::default(); + match &self.mode { + FluxMode::Purpose(_) => { + state.purpose.text = apply_purpose(self, |slot| slots[slot].purpose.text.clone()) + } + FluxMode::Context(_) => { + state.context = apply_context(self, |slot| slots[slot].context.clone()) + } + FluxMode::Environment(_) => { + state.environment = apply_environment(self, |slot| slots[slot].environment.clone()) + } + FluxMode::Resources(_) => { + state.resources = apply_resources(self, |slot| slots[slot].resources.clone()) + } + FluxMode::Bridge { kind, .. } => match kind { + BridgeKind::ContextToPurpose => state.purpose.text = flatten_context_to_text(slots), + }, + } + state + } +} + +fn flatten_context_to_text(slots: &[RunState]) -> String { + let mut parts = Vec::new(); + for slot in slots { + for fragment in slot.context.fragments() { + if let Some(text) = fragment.as_text() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + parts.push(trimmed.to_string()); + } + } + } + } + parts.join("\n\n") } -pub(crate) fn apply_purpose(flux: &Flux, mut read: impl FnMut(usize) -> String) -> String { +fn apply_purpose(flux: &Flux, mut read: impl FnMut(usize) -> String) -> String { match &flux.mode { FluxMode::Purpose(PurposeFlux::Concat) => { let mut parts = Vec::with_capacity(flux.arity); @@ -115,101 +151,159 @@ pub(crate) fn apply_purpose(flux: &Flux, mut read: impl FnMut(usize) -> String) } parts.concat() } - _ => panic!("unexpected flux mode for purpose"), + _ => unreachable!("flux mode channel already matched"), } } -pub(crate) fn apply_ctx(flux: &Flux, mut read: impl FnMut(usize) -> Context) -> Context { +fn apply_context(flux: &Flux, mut read: impl FnMut(usize) -> Context) -> Context { match &flux.mode { FluxMode::Context(ContextFlux::Append) => { let mut result = Context::new(); for slot in 0..flux.arity { - let ctx = read(slot); - for frag in ctx.fragments().iter() { - result.append(frag.clone()); + let context = read(slot); + for fragment in context.fragments() { + result.append(fragment.clone()); } } result } - FluxMode::Context(ContextFlux::Replace) => { + FluxMode::Context(ContextFlux::Last) => { let mut result = Context::new(); for slot in 0..flux.arity { - let ctx = read(slot); - if !ctx.is_empty() { - result = ctx; + let context = read(slot); + if let Some(last) = context.fragments().last() { + result.append(last.clone()); } } result } - _ => panic!("unexpected flux mode for context"), + FluxMode::Context(ContextFlux::Digest) => { + let mut result = Context::new(); + for slot in 0..flux.arity { + let context = read(slot); + digest_context_into(&context, &mut result); + } + result + } + FluxMode::Context(ContextFlux::Thread) => { + let mut result = Context::new(); + for slot in 0..flux.arity { + let context = read(slot); + thread_context_into(slot, &context, &mut result); + } + result + } + _ => unreachable!("flux mode channel already matched"), } } -pub(crate) fn apply_env(flux: &Flux, mut read: impl FnMut(usize) -> Environment) -> Environment { +fn apply_environment(flux: &Flux, mut read: impl FnMut(usize) -> Environment) -> Environment { match &flux.mode { FluxMode::Environment(EnvFlux::Overlay) => { if flux.arity == 0 { return Environment::named(flux.name.as_str(), "."); } - let first = read(0); let mut result = Environment::named(flux.name.as_str(), first.cwd.clone()); result.vars = first.vars.clone(); result.root = first.root.clone(); result.platform = first.platform.clone(); - for slot in 1..flux.arity { - let env = read(slot); - result.cwd.clone_from(&env.cwd); - for (key, value) in &env.vars { + let environment = read(slot); + result.cwd.clone_from(&environment.cwd); + for (key, value) in &environment.vars { result.vars.insert(key.clone(), value.clone()); } - result.root.clone_from(&env.root); - result.platform.clone_from(&env.platform); + result.root.clone_from(&environment.root); + result.platform.clone_from(&environment.platform); } result } - _ => panic!("unexpected flux mode for environment"), + _ => unreachable!("flux mode channel already matched"), } } -pub(crate) fn apply_res(flux: &Flux, mut read: impl FnMut(usize) -> Resources) -> Resources { +fn apply_resources(flux: &Flux, mut read: impl FnMut(usize) -> Resources) -> Resources { match &flux.mode { FluxMode::Resources(ResFlux::Merge) => { let mut result = Resources::named(flux.name.as_str()); for slot in 0..flux.arity { - let res = read(slot); - for (name, model) in &res.models { - result.models.entry(name.clone()).or_insert(model.clone()); + let resources = read(slot); + for model_name in &resources.model_order { + if let Some(model) = resources.models.get(model_name) { + result = result.with_model(model.clone()); + } } - if result.active_model.is_empty() && !res.active_model.is_empty() { - result.active_model.clone_from(&res.active_model); + for (name, definition) in &resources.tool_definitions { + result + .tool_definitions + .entry(name.clone()) + .or_insert(definition.clone()); } - for name in &res.active_tools { + if result.active_model.is_empty() && !resources.active_model.is_empty() { + result.active_model.clone_from(&resources.active_model); + } + for name in &resources.active_tools { result.active_tools.insert(name.clone()); } - for (name, prompt) in &res.prompts { + for (name, prompt) in &resources.prompts { result.prompts.entry(name.clone()).or_insert(prompt.clone()); } } result } - _ => panic!("unexpected flux mode for resources"), + _ => unreachable!("flux mode channel already matched"), } } -pub(crate) fn apply_policy( - flux: &Flux, - mut read: impl FnMut(usize) -> Box<dyn Policy>, -) -> Box<dyn Policy> { - match &flux.mode { - FluxMode::Policy(PolicyFlux::Replace) => { - let mut result = None; - for slot in 0..flux.arity { - result = Some(read(slot)); +fn digest_context_into(source: &Context, target: &mut Context) { + let fragments = source.fragments(); + if fragments.is_empty() { + return; + } + let mut lines = Vec::new(); + for fragment in fragments { + match &fragment.content { + machine::Content::Text(text) => { + if fragment.role == Role::System { + continue; + } + let trimmed = text.text.trim(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + } + } + machine::Content::ToolResult(result) => { + lines.push(format!("[Tool result] {}", result.content.trim())); } - result.expect("policy flux requires at least one input") + machine::Content::Hitch { message, .. } => { + lines.push(format!("[Error] {}", message.trim())); + } + machine::Content::ToolCall(_) => {} + _ => {} } - _ => panic!("unexpected flux mode for policy"), } + if lines.is_empty() { + return; + } + target.append(Fragment::assistant(lines.join("\n"))); +} + +fn thread_context_into(slot: usize, source: &Context, target: &mut Context) { + let fragments = source.fragments(); + if fragments.is_empty() { + return; + } + let mut digest = Context::new(); + digest_context_into(source, &mut digest); + let answer_text = digest + .fragments() + .last() + .and_then(|fragment| fragment.as_text()) + .unwrap_or("(no output)"); + target.append(Fragment::user(format!( + "[Task {}] Please complete the following task.", + slot + 1 + ))); + target.append(Fragment::assistant(answer_text)); } diff --git a/crates/accelerator/src/graph.rs b/crates/accelerator/src/graph.rs index b164d614..2e83a252 100644 --- a/crates/accelerator/src/graph.rs +++ b/crates/accelerator/src/graph.rs @@ -1,21 +1,28 @@ -use std::collections::{HashMap, VecDeque}; - -use utils::{AssemblyId, ConditionId, FluxId, GraphId, Name}; - -use crate::accelerator::{Accelerator, AcceleratorRef, Channel, Port}; -use crate::assembly::{Assembly, ConditionRouting, Slot}; -use crate::condition::{Condition, ConditionBranch, ConditionRef, Predicate}; -use crate::flux::{Flux, FluxMode, FluxRef}; -use crate::state::State; - -/// Build a multi-agent execution graph. +use std::collections::{HashSet, VecDeque}; + +use machine::hook; +use tracing::{Instrument, warn}; +use utils::{GraphId, Name}; + +use crate::accelerator::Accelerator; +use crate::condition::{Condition, ConditionBranch}; +use crate::flux::{Flux, FluxMode}; +use crate::wire::{Channel, ComponentId, ComponentRef, Endpoint, Port, PortOwner, Wire}; +use machine::RunState; + +/// Maximum number of components executed concurrently within a single frontier. +/// Bounds a wide `map` fan-out (one worker per item over a runtime-sized list) so +/// it does not launch hundreds of model / arXiv calls at once, while still +/// allowing healthy parallelism. Ordinary frontiers — sequential pipelines, the +/// handful of parallel scouts/judges — are smaller than this and run unaffected. +const FRONTIER_CONCURRENCY: usize = 16; + +#[derive(Clone)] pub struct Graph { id: GraphId, pub name: Name, - accelerators: Vec<Accelerator>, - fluxes: Vec<Flux>, - conditions: Vec<Condition>, - wires: Vec<(Port, Port)>, + components: Vec<Component>, + wires: Vec<Wire>, } impl Graph { @@ -27,9 +34,7 @@ impl Graph { Self { id: GraphId::new(), name: Name::new(name).expect("graph name must be valid"), - accelerators: Vec::new(), - fluxes: Vec::new(), - conditions: Vec::new(), + components: Vec::new(), wires: Vec::new(), } } @@ -42,398 +47,660 @@ impl Graph { self.name = Name::new(name).expect("graph name must be valid"); } - pub fn spawn(&mut self, state: State) -> AcceleratorRef { - self.spawn_named("accelerator", state) - } - - pub fn spawn_named(&mut self, name: impl Into<String>, state: State) -> AcceleratorRef { - let index = self.accelerators.len(); - self.accelerators.push(Accelerator::named(name, state)); - AcceleratorRef { - index, - id: self.accelerators[index].id().clone(), - } + pub fn add_accelerator( + &mut self, + name: impl Into<String>, + accelerator: Accelerator, + ) -> ComponentRef { + let id = ComponentId::new(self.components.len()); + let accelerator_id = accelerator.id().clone(); + self.components.push(Component { + name: Name::new(name).expect("component name must be valid"), + kind: ComponentKind::Accelerator(Box::new(accelerator)), + }); + ComponentRef::new(id, Some(accelerator_id)) } - pub fn weave(&mut self, arity: usize, mode: FluxMode) -> FluxRef { - self.weave_named(mode.name(), arity, mode) + pub fn add_flux( + &mut self, + name: impl Into<String>, + mode: FluxMode, + arity: usize, + ) -> ComponentRef { + let id = ComponentId::new(self.components.len()); + let flux = Flux::new(name, mode, arity); + let component_name = flux.name.clone(); + self.components.push(Component { + name: component_name, + kind: ComponentKind::Flux(flux), + }); + ComponentRef::new(id, None) } - pub fn weave_named( + pub fn add_condition( &mut self, name: impl Into<String>, - arity: usize, - mode: FluxMode, - ) -> FluxRef { - let index = self.fluxes.len(); - let channel = mode.channel(); - self.fluxes.push(Flux::new(name, mode, arity)); - FluxRef { - index, - id: self.fluxes[index].id().clone(), - channel, - } + predicate: crate::condition::Predicate, + ) -> ComponentRef { + let id = ComponentId::new(self.components.len()); + let condition = Condition::new(name, predicate); + let component_name = condition.name.clone(); + self.components.push(Component { + name: component_name, + kind: ComponentKind::Condition(condition), + }); + ComponentRef::new(id, None) } - pub fn condition(&mut self, predicate: Predicate) -> ConditionRef { - self.condition_named("condition", predicate) + pub fn components(&self) -> &[Component] { + &self.components } - pub fn condition_named( - &mut self, - name: impl Into<String>, - predicate: Predicate, - ) -> ConditionRef { - let index = self.conditions.len(); - self.conditions.push(Condition::new(name, predicate)); - ConditionRef { - index, - id: self.conditions[index].id().clone(), - } + pub fn validate(&self) -> Result<(), String> { + self.validate_flux_inputs()?; + self.validate_conditions()?; + self.validate_acyclic() + } + + pub fn wire(&mut self, from: Port, to: Port) { + self.wires.push(Wire::new(from, to)); } - pub fn rename_accelerator(&mut self, reference: AcceleratorRef, name: impl Into<String>) { - self.assert_accelerator_ref(&reference); - self.accelerators[reference.index].name = - Name::new(name).expect("accelerator name must be valid"); + pub fn input(endpoint: Endpoint) -> Port { + Port::input(endpoint) } - pub fn rename_flux(&mut self, reference: FluxRef, name: impl Into<String>) { - self.assert_flux_ref(&reference); - self.fluxes[reference.index].name = Name::new(name).expect("flux name must be valid"); + pub fn output(endpoint: Endpoint) -> Port { + Port::output(endpoint) } - pub fn rename_condition(&mut self, reference: ConditionRef, name: impl Into<String>) { - self.assert_condition_ref(&reference); - self.conditions[reference.index].name = - Name::new(name).expect("condition name must be valid"); + pub async fn run(self, input: RunState) -> RunState { + self.validate().expect("invalid graph"); + GraphRun::new(self, input).run().await } - pub fn wire(&mut self, from: Port, to: Port) { - self.assert_port_ref(&from); - self.assert_port_ref(&to); - assert!(from.is_output(), "source must be an output pin"); - assert!(to.is_input(), "target must be an input pin"); - assert_eq!(from.channel(), to.channel(), "channel mismatch"); - self.assert_supported_pulse_wire(&from, &to); - if to.channel() != Channel::Pulse { - assert!( - !self.wires.iter().any(|(_, target)| target == &to), - "input pin already wired" - ); + /// Run with specific per-component initial inputs (by component id), on top of + /// any boundary-input wiring. Used by a `Map` to seed each dynamically-added + /// worker with its own item before execution. + pub async fn run_seeded( + self, + input: RunState, + seeds: Vec<(ComponentId, RunState)>, + ) -> RunState { + self.validate().expect("invalid graph"); + let mut run = GraphRun::new(self, input); + for (id, state) in seeds { + run.inputs[id.index()] = state; } - self.wires.push((from, to)); + run.run().await } - fn assert_supported_pulse_wire(&self, from: &Port, to: &Port) { - if from.channel() != Channel::Pulse { - return; + fn validate_flux_inputs(&self) -> Result<(), String> { + let mut filled_slots = self + .components + .iter() + .map(|component| match &component.kind { + ComponentKind::Flux(flux) => vec![false; flux.arity], + _ => Vec::new(), + }) + .collect::<Vec<_>>(); + + for wire in &self.wires { + let Some(component) = component_id(&wire.to) else { + continue; + }; + let Endpoint::FluxSlot { slot, .. } = wire.to.endpoint else { + continue; + }; + if let Some(slot_filled) = filled_slots[component.index()].get_mut(slot) { + *slot_filled = true; + } } - match (from, to) { - (Port::Accel { .. }, Port::Accel { .. }) - | (Port::Accel { .. }, Port::ConditionIn { .. }) - | (Port::ConditionOut { .. }, Port::Accel { .. }) => {} - _ => panic!("unsupported pulse wire"), + for (index, slots) in filled_slots.iter().enumerate() { + if let Some(slot) = slots.iter().position(|filled| !filled) { + return Err(format!( + "flux {} missing input for slot {}", + self.components[index].name.as_str(), + slot + )); + } } + Ok(()) } - fn assert_accelerator_ref(&self, reference: &AcceleratorRef) { - let Some(accelerator) = self.accelerators.get(reference.index) else { - panic!("accelerator reference does not belong to this graph"); - }; - assert_eq!( - accelerator.id(), - &reference.id, - "accelerator reference does not belong to this graph" - ); - } + fn validate_conditions(&self) -> Result<(), String> { + let mut condition_inputs = vec![0usize; self.components.len()]; + let mut true_outputs = vec![0usize; self.components.len()]; + let mut false_outputs = vec![0usize; self.components.len()]; - fn assert_flux_ref(&self, reference: &FluxRef) { - let Some(flux) = self.fluxes.get(reference.index) else { - panic!("flux reference does not belong to this graph"); - }; - assert_eq!( - flux.id(), - &reference.id, - "flux reference does not belong to this graph" - ); - } + for wire in &self.wires { + if let Some(component) = component_id(&wire.to) + && matches!(wire.to.endpoint, Endpoint::ConditionIn) + { + condition_inputs[component.index()] += 1; + } + if let Some(component) = component_id(&wire.from) { + match wire.from.endpoint { + Endpoint::ConditionOut(ConditionBranch::True) => { + true_outputs[component.index()] += 1 + } + Endpoint::ConditionOut(ConditionBranch::False) => { + false_outputs[component.index()] += 1 + } + _ => {} + } + } + } - fn assert_condition_ref(&self, reference: &ConditionRef) { - let Some(condition) = self.conditions.get(reference.index) else { - panic!("condition reference does not belong to this graph"); - }; - assert_eq!( - condition.id(), - &reference.id, - "condition reference does not belong to this graph" - ); + for (index, component) in self.components.iter().enumerate() { + if !matches!(component.kind, ComponentKind::Condition(_)) { + continue; + } + if condition_inputs[index] != 1 { + return Err(format!( + "condition {} requires exactly one trigger input", + component.name.as_str() + )); + } + if true_outputs[index] == 0 || false_outputs[index] == 0 { + return Err(format!( + "condition {} requires true and false outputs", + component.name.as_str() + )); + } + } + Ok(()) } - fn assert_port_ref(&self, port: &Port) { - match port { - Port::Accel { - index, - accelerator_id, - .. - } => { - let Some(accelerator) = self.accelerators.get(*index) else { - panic!("accelerator port does not belong to this graph"); - }; - assert_eq!( - accelerator.id(), - accelerator_id, - "accelerator port does not belong to this graph" - ); + fn validate_acyclic(&self) -> Result<(), String> { + let mut incoming = vec![0usize; self.components.len()]; + let mut downstream = vec![Vec::new(); self.components.len()]; + let mut edges = HashSet::new(); + + for wire in &self.wires { + let Some(source) = component_id(&wire.from) else { + continue; + }; + let Some(target) = component_id(&wire.to) else { + continue; + }; + if source == target { + return Err("graph contains a self cycle".to_string()); } - Port::FluxOut { index, flux_id, .. } => self.assert_flux_port(*index, flux_id), - Port::FluxSlot { - index, - flux_id, - slot, - .. - } => { - self.assert_flux_port(*index, flux_id); - assert!( - *slot < self.fluxes[*index].arity, - "flux slot index is out of range" - ); + if edges.insert((source.index(), target.index())) { + incoming[target.index()] += 1; + downstream[source.index()].push(target.index()); } - Port::ConditionIn { - index, - condition_id, + } + + let mut queue = VecDeque::new(); + for (index, count) in incoming.iter().enumerate() { + if *count == 0 { + queue.push_back(index); + } + } + + let mut visited = 0usize; + while let Some(index) = queue.pop_front() { + visited += 1; + for target in &downstream[index] { + incoming[*target] -= 1; + if incoming[*target] == 0 { + queue.push_back(*target); + } } - | Port::ConditionOut { - index, - condition_id, - .. - } => self.assert_condition_port(*index, condition_id), + } + + if visited == self.components.len() { + Ok(()) + } else { + Err("graph contains a cycle".to_string()) } } +} - fn assert_flux_port(&self, index: usize, id: &FluxId) { - let Some(flux) = self.fluxes.get(index) else { - panic!("flux port does not belong to this graph"); - }; - assert_eq!(flux.id(), id, "flux port does not belong to this graph"); +impl Default for Graph { + fn default() -> Self { + Self::new() } +} - fn assert_condition_port(&self, index: usize, id: &ConditionId) { - let Some(condition) = self.conditions.get(index) else { - panic!("condition port does not belong to this graph"); - }; - assert_eq!( - condition.id(), - id, - "condition port does not belong to this graph" - ); +#[derive(Clone)] +pub struct Component { + pub name: Name, + pub kind: ComponentKind, +} + +#[derive(Clone)] +pub enum ComponentKind { + Accelerator(Box<Accelerator>), + Flux(Flux), + Condition(Condition), +} + +impl ComponentKind { + fn name(&self) -> &'static str { + match self { + ComponentKind::Accelerator(_) => "accelerator", + ComponentKind::Flux(_) => "flux", + ComponentKind::Condition(_) => "condition", + } } +} + +struct GraphRun { + graph: Graph, + inputs: Vec<RunState>, + outputs: Vec<Option<RunState>>, + flux_slots: Vec<Vec<Option<RunState>>>, + condition_inputs: Vec<Option<RunState>>, + branches: Vec<Option<ConditionBranch>>, + remaining_deps: Vec<usize>, + active_incoming: Vec<usize>, + skipped: Vec<bool>, + result: RunState, + next_frontier: u64, +} + +impl GraphRun { + fn new(graph: Graph, input: RunState) -> Self { + let mut inputs = Vec::with_capacity(graph.components.len()); + let mut flux_slots = Vec::with_capacity(graph.components.len()); - pub fn build(self) -> Result<Assembly, BuildError> { - self.validate_acyclic()?; - - let num = self.accelerators.len(); - let mut downstream = vec![Vec::new(); num]; - let mut pending = vec![0usize; num]; - let active_inputs = vec![0usize; num]; - let skipped = vec![false; num]; - let mut condition_sources = vec![None; self.conditions.len()]; - let mut condition_branches = vec![ConditionBranches::default(); self.conditions.len()]; - - for (from, to) in &self.wires { - match (from, to) { - ( - Port::Accel { - index: src, - channel: Channel::Pulse, - .. - }, - Port::Accel { - index: dst, - channel: Channel::Pulse, - .. - }, - ) => { - downstream[*src].push(*dst); - pending[*dst] += 1; + for component in &graph.components { + match &component.kind { + ComponentKind::Accelerator(_) => { + inputs.push(RunState::default()); + flux_slots.push(Vec::new()); } - ( - Port::Accel { - index: src, - channel: Channel::Pulse, - .. - }, - Port::ConditionIn { index, .. }, - ) => { - if condition_sources[*index].replace(*src).is_some() { - return Err(BuildError::DuplicateConditionSource); - } + ComponentKind::Flux(flux) => { + inputs.push(RunState::default()); + flux_slots.push(vec![None; flux.arity]); } - ( - Port::ConditionOut { index, branch, .. }, - Port::Accel { - index: dst, - channel: Channel::Pulse, - .. - }, - ) => { - condition_branches[*index].set(*branch, *dst)?; - pending[*dst] += 1; + ComponentKind::Condition(_) => { + inputs.push(RunState::default()); + flux_slots.push(Vec::new()); } - _ => {} } } - let mut condition_map: HashMap<usize, Vec<usize>> = HashMap::new(); - let mut condition_routing = Vec::with_capacity(self.conditions.len()); - for (index, condition) in self.conditions.into_iter().enumerate() { - let source = condition_sources[index].ok_or(BuildError::MissingConditionSource)?; - let branches = condition_branches[index]; - let true_target = branches - .true_target - .ok_or(BuildError::MissingConditionTrueBranch)?; - let false_target = branches - .false_target - .ok_or(BuildError::MissingConditionFalseBranch)?; - let routing_index = condition_routing.len(); - condition_map.entry(source).or_default().push(routing_index); - condition_routing.push(ConditionRouting { - predicate: condition.predicate, - true_target, - false_target, - }); + let component_count = graph.components.len(); + let mut run = Self { + graph, + inputs, + outputs: vec![None; component_count], + flux_slots, + condition_inputs: vec![None; component_count], + branches: vec![None; component_count], + remaining_deps: vec![0; component_count], + active_incoming: vec![0; component_count], + skipped: vec![false; component_count], + result: RunState::default(), + next_frontier: 1, + }; + + run.count_dependencies(); + run.apply_boundary_input(&input); + + // Propagate cwd from the graph's input to every sub-component so + // fs tools inside children write to the user-specified --run-dir. + // + // CLI callers set both RunState.run_dir and environment.run_dir, but + // graph channel wiring may carry only environment metadata into a + // nested composite. Treat environment.run_dir as an equivalent source + // of truth and write the resolved value back onto each child input. + let input_run_dir = input + .run_dir + .as_ref() + .or(input.environment.run_dir.as_ref()) + .cloned(); + if let Some(ref dir) = input_run_dir { + for inp in run.inputs.iter_mut() { + inp.run_dir = Some(dir.clone()); + inp.environment.cwd = dir.clone(); + inp.environment.run_dir = Some(dir.clone()); + inp.environment + .vars + .entry("RCM_RUN_DIR".to_string()) + .or_insert_with(|| dir.display().to_string()); + } + run.result.run_dir = Some(dir.clone()); + run.result.environment.cwd = dir.clone(); + run.result.environment.run_dir = Some(dir.clone()); } + run + } - let mut state_wires = HashMap::new(); - let mut flux_slot_wires = HashMap::new(); - for (from, to) in &self.wires { - match to { - Port::Accel { channel, .. } if *channel != Channel::Pulse => { - state_wires.insert(to.clone(), from.clone()); - } - Port::FluxSlot { index, slot, .. } => { - flux_slot_wires.insert((*index, *slot), from.clone()); + async fn run(mut self) -> RunState { + hook!(event = "graph_start", graph = self.graph.name.as_str()); + let mut queue = VecDeque::new(); + for (index, count) in self.remaining_deps.iter().enumerate() { + if *count == 0 { + queue.push_back(index); + } + } + + while !queue.is_empty() { + let frontier = self.drain_ready_frontier(&mut queue); + let completed = self.run_frontier(frontier).await; + for index in completed { + self.propagate_component(index, &mut queue); + } + } + + hook!(event = "graph_done", graph = self.graph.name.as_str()); + self.result + } + + fn drain_ready_frontier(&self, queue: &mut VecDeque<usize>) -> Vec<usize> { + let mut frontier = Vec::new(); + while let Some(index) = queue.pop_front() { + if self.skipped[index] || self.outputs[index].is_some() { + continue; + } + frontier.push(index); + } + frontier + } + + async fn run_frontier(&mut self, frontier: Vec<usize>) -> Vec<usize> { + let frontier_id = self.next_frontier; + self.next_frontier += 1; + hook!( + event = "frontier_start", + graph = self.graph.name.as_str(), + frontier = frontier_id, + count = frontier.len() + ); + let mut completed = Vec::new(); + // Run the frontier in bounded waves so a wide `map` fan-out cannot launch + // hundreds of model/arXiv calls at once. Each wave is fully awaited before + // the next starts. + for chunk in frontier.chunks(FRONTIER_CONCURRENCY) { + let mut tasks = tokio::task::JoinSet::new(); + for &index in chunk { + let component = self.graph.components[index].clone(); + let input = self.inputs[index].clone(); + let flux_slots = self.flux_slots[index].clone(); + let condition_input = self.condition_inputs[index].clone(); + let graph_name = self.graph.name.to_string(); + let component_name = component.name.to_string(); + let component_kind = component.kind.name(); + let span = tracing::trace_span!( + target: "hook", + "component", + graph = graph_name.as_str(), + frontier = frontier_id, + component = component_name.as_str(), + component_index = index, + component_kind + ); + tasks.spawn( + async move { + hook!( + event = "component_start", + graph = graph_name.as_str(), + frontier = frontier_id, + component = component_name.as_str(), + component_index = index, + component_kind + ); + let (state, branch) = match component.kind { + ComponentKind::Accelerator(accelerator) => { + (accelerator.run_with(input).await, None) + } + ComponentKind::Flux(flux) => { + let slots = flux_slots + .into_iter() + .map(|slot| slot.unwrap_or_default()) + .collect::<Vec<_>>(); + (flux.apply(&slots), None) + } + ComponentKind::Condition(condition) => { + let state = condition_input.unwrap_or_default(); + let branch = condition.route(&state); + (state, Some(branch)) + } + }; + hook!( + event = "component_done", + graph = graph_name.as_str(), + frontier = frontier_id, + component = component_name.as_str(), + component_index = index, + component_kind + ); + (index, state, branch) + } + .instrument(span), + ); + } + + while let Some(result) = tasks.join_next().await { + let (index, state, branch) = match result { + Ok(output) => output, + Err(error) if error.is_panic() => { + warn!(?error, "graph component task panicked"); + std::panic::resume_unwind(error.into_panic()); + } + Err(error) => { + warn!(?error, "graph component task was cancelled"); + panic!("graph component task was cancelled: {error}"); + } + }; + if let Some(branch) = branch { + self.branches[index] = Some(branch); } - _ => {} + self.outputs[index] = Some(state); + completed.push(index); } } + hook!( + event = "frontier_done", + graph = self.graph.name.as_str(), + frontier = frontier_id, + count = completed.len() + ); + completed + } - let mut is_sink = vec![true; num]; - for (from, _) in &self.wires { - if let Port::Accel { - index, - channel: Channel::Pulse, - .. - } = from - { - is_sink[*index] = false; + fn count_dependencies(&mut self) { + let mut dependencies = HashSet::new(); + for wire in &self.graph.wires { + let Some(source) = component_id(&wire.from) else { + continue; + }; + let Some(target) = component_id(&wire.to) else { + continue; + }; + if source != target && dependencies.insert((source.index(), target.index())) { + self.remaining_deps[target.index()] += 1; } } + } - let slots = self - .accelerators - .into_iter() - .map(|accelerator| { - Slot::new( - accelerator.id().clone(), - accelerator.name, - accelerator.state, - ) + fn apply_boundary_input(&mut self, input: &RunState) { + let wire_indices = self + .graph + .wires + .iter() + .enumerate() + .filter_map(|(index, wire)| { + (wire.from.owner == PortOwner::BoundaryInput).then_some(index) }) - .collect(); - - Ok(Assembly { - id: AssemblyId::new(), - name: self.name, - slots, - fluxes: self.fluxes, - downstream, - pending, - active_inputs, - skipped, - state_wires, - flux_slot_wires, - is_sink, - condition_routing, - condition_map, - }) - } - - fn validate_acyclic(&self) -> Result<(), BuildError> { - let total = self.accelerators.len() + self.fluxes.len() + self.conditions.len(); - let mut adj = vec![Vec::new(); total]; - - for (from, to) in &self.wires { - let src = from.node_index(self.accelerators.len(), self.fluxes.len()); - let dst = to.node_index(self.accelerators.len(), self.fluxes.len()); - adj[src].push(dst); + .collect::<Vec<_>>(); + for index in wire_indices { + let wire = self.graph.wires[index].clone(); + self.apply_wire(&wire, input); } + } - let mut in_degree = vec![0; total]; - for neighbors in &adj { - for node in neighbors { - in_degree[*node] += 1; + fn propagate_component(&mut self, source: usize, queue: &mut VecDeque<usize>) { + let source_id = ComponentId::new(source); + let wire_indices = self + .graph + .wires + .iter() + .enumerate() + .filter_map(|(index, wire)| { + (component_id(&wire.from) == Some(source_id)).then_some(index) + }) + .collect::<Vec<_>>(); + let mut released = HashSet::new(); + for index in wire_indices { + let wire = self.graph.wires[index].clone(); + let active = self.branch_is_active(source, &wire.from.endpoint); + if active { + let state = self.source_state(source, &wire.from.endpoint); + self.apply_wire(&wire, &state); + } + if let Some(target) = component_id(&wire.to) + && released.insert(target.index()) + { + self.resolve_dependency(target.index(), active, queue); } } + } - let mut queue = VecDeque::new(); - for (idx, deg) in in_degree.iter().enumerate() { - if *deg == 0 { - queue.push_back(idx); + fn propagate_skip(&mut self, source: usize, queue: &mut VecDeque<usize>) { + let source_id = ComponentId::new(source); + let wire_indices = self + .graph + .wires + .iter() + .enumerate() + .filter_map(|(index, wire)| { + (component_id(&wire.from) == Some(source_id)).then_some(index) + }) + .collect::<Vec<_>>(); + let mut released = HashSet::new(); + for index in wire_indices { + let wire = self.graph.wires[index].clone(); + if let Some(target) = component_id(&wire.to) + && released.insert(target.index()) + { + self.resolve_dependency(target.index(), false, queue); } } + } - let mut visited = 0; - while let Some(node) = queue.pop_front() { - visited += 1; - for next in &adj[node] { - in_degree[*next] -= 1; - if in_degree[*next] == 0 { - queue.push_back(*next); + fn branch_is_active(&self, source: usize, endpoint: &Endpoint) -> bool { + match endpoint { + Endpoint::ConditionOut(branch) => self.branches[source] == Some(*branch), + _ => true, + } + } + + fn source_state(&self, source: usize, endpoint: &Endpoint) -> RunState { + match endpoint { + Endpoint::ConditionOut(_) => self.outputs[source].clone().unwrap_or_default(), + _ => match self.outputs[source].clone() { + Some(state) => state, + None => { + // The source component should have produced an output + // before its wires propagate (see set at line 490). A + // missing output here indicates a graph/topology bug; + // rather than panicking the whole pipeline, fall back to + // an empty state and warn so the operator can diagnose. + let name = self.graph.components[source].name.as_str(); + warn!( + component = name, + component_index = source, + "component output missing during propagation; \ + forwarding empty RunState downstream" + ); + RunState::default() } + }, + } + } + + fn apply_wire(&mut self, wire: &Wire, state: &RunState) { + match (&wire.to.owner, wire.to.endpoint) { + (PortOwner::Component(component), Endpoint::State(channel)) => { + set_channel( + &mut self.inputs[component.index()], + channel, + state_with_channel(channel, state), + ); + } + (PortOwner::Component(component), Endpoint::FluxSlot { slot, channel }) => { + self.flux_slots[component.index()][slot] = Some(state_with_channel(channel, state)); + } + (PortOwner::Component(component), Endpoint::ConditionIn) => { + self.condition_inputs[component.index()] = Some(state.clone()); + } + (PortOwner::Component(_), Endpoint::Trigger) => {} + (PortOwner::BoundaryOutput, Endpoint::State(channel)) => { + set_channel( + &mut self.result, + channel, + state_with_channel(channel, state), + ); } + (PortOwner::BoundaryOutput, Endpoint::Done) => {} + _ => unreachable!("wire validation accepted an unsupported endpoint pair"), } + } - if visited == total { - Ok(()) + fn resolve_dependency( + &mut self, + target: usize, + active_branch: bool, + queue: &mut VecDeque<usize>, + ) { + if self.skipped[target] || self.outputs[target].is_some() { + return; + } + if active_branch { + self.active_incoming[target] += 1; + } + self.remaining_deps[target] -= 1; + if self.remaining_deps[target] != 0 { + return; + } + if self.active_incoming[target] > 0 { + queue.push_back(target); } else { - Err(BuildError::Cycle) + self.skipped[target] = true; + hook!( + event = "component_skipped", + graph = self.graph.name.as_str(), + component = self.graph.components[target].name.as_str(), + component_index = target, + component_kind = self.graph.components[target].kind.name() + ); + self.propagate_skip(target, queue); } } } -impl Default for Graph { - fn default() -> Self { - Self::new() +fn component_id(port: &Port) -> Option<ComponentId> { + match port.owner { + PortOwner::Component(component) => Some(component), + _ => None, } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum BuildError { - Cycle, - DuplicateConditionSource, - DuplicateConditionBranch, - MissingConditionSource, - MissingConditionTrueBranch, - MissingConditionFalseBranch, -} - -#[derive(Clone, Copy, Debug, Default)] -struct ConditionBranches { - true_target: Option<usize>, - false_target: Option<usize>, +fn state_with_channel(channel: Channel, source: &RunState) -> RunState { + let mut state = RunState::default(); + match channel { + Channel::Purpose => state.purpose.clone_from(&source.purpose), + Channel::Context => state.context = source.context.clone(), + Channel::Environment => state.environment = source.environment.clone(), + Channel::Resources => state.resources = source.resources.clone(), + Channel::Pulse => {} + } + state } -impl ConditionBranches { - fn set(&mut self, branch: ConditionBranch, target: usize) -> Result<(), BuildError> { - let slot = match branch { - ConditionBranch::True => &mut self.true_target, - ConditionBranch::False => &mut self.false_target, - }; - if slot.replace(target).is_some() { - return Err(BuildError::DuplicateConditionBranch); - } - Ok(()) +fn set_channel(target: &mut RunState, channel: Channel, source: RunState) { + match channel { + Channel::Purpose => target.purpose = source.purpose, + Channel::Context => target.context = source.context, + Channel::Environment => target.environment = source.environment, + Channel::Resources => target.resources = source.resources, + Channel::Pulse => {} } } diff --git a/crates/accelerator/src/lib.rs b/crates/accelerator/src/lib.rs index 668f849a..63a31f68 100644 --- a/crates/accelerator/src/lib.rs +++ b/crates/accelerator/src/lib.rs @@ -1,28 +1,30 @@ -//! RCM Accelerator — composable agent execution via dataflow graphs. +//! RCM Accelerator — composable execution via accelerator graphs. pub mod accelerator; -pub mod assembly; +pub mod catalog; pub mod condition; +pub mod environment; pub mod flux; pub mod graph; -pub mod logging; pub mod lsp; pub mod mcp; pub mod policy; +pub mod prompts; pub mod provider; -pub mod state; pub mod tools; +pub mod trajectory; +pub mod wire; pub use accelerator::Accelerator; -pub use accelerator::{AcceleratorRef, Channel, Port}; -pub use assembly::Assembly; +pub use catalog::{Catalog, ResourceSelection, RuntimeResources}; pub use condition::{ - ConditionBranch, ConditionRef, ContextPredicate, EnvironmentPredicate, Predicate, + Condition, ConditionBranch, ContextPredicate, EnvironmentPredicate, Predicate, PurposePredicate, ResourcesPredicate, }; -pub use flux::{ContextFlux, EnvFlux, FluxMode, FluxRef, PolicyFlux, PurposeFlux, ResFlux}; -pub use graph::{BuildError, Graph}; -pub use provider::{ - MODEL_PRESETS, ModelPreset, PROVIDERS, Provider, ResolveError, resolve_model, -}; -pub use state::State; +pub use flux::{BridgeKind, ContextFlux, EnvFlux, Flux, FluxMode, PurposeFlux, ResFlux}; +pub use graph::{Component, ComponentKind, Graph}; +pub use machine::RunState; +pub use policy::Captain; +pub use provider::{MODEL_PRESETS, ModelPreset, PROVIDERS, Provider, ResolveError, resolve_model}; +pub use tools::SpawnTool; +pub use wire::{Channel, ComponentId, ComponentRef, Endpoint, Port, PortOwner, Wire}; diff --git a/crates/accelerator/src/logging.rs b/crates/accelerator/src/logging.rs deleted file mode 100644 index ac761ecc..00000000 --- a/crates/accelerator/src/logging.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::path::PathBuf; - -use tracing_subscriber::EnvFilter; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::util::SubscriberInitExt; - -pub struct LogGuard { - _file_guard: Option<tracing_appender::non_blocking::WorkerGuard>, -} - -pub fn init() -> LogGuard { - let dir = log_dir(); - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new("info")) - .add_directive("h2=warn".parse().unwrap()) - .add_directive("hyper=warn".parse().unwrap()) - .add_directive("reqwest=warn".parse().unwrap()) - .add_directive("rustls=warn".parse().unwrap()); - - if let Some(log_dir) = dir { - std::fs::create_dir_all(&log_dir).ok(); - let prefix = format!("accelerator-{}", std::process::id()); - let appender = tracing_appender::rolling::daily(&log_dir, prefix); - let (file_writer, guard) = tracing_appender::non_blocking(appender); - - tracing_subscriber::registry() - .with( - tracing_subscriber::fmt::layer() - .compact() - .with_target(false), - ) - .with( - tracing_subscriber::fmt::layer() - .json() - .with_writer(file_writer) - .with_target(false), - ) - .with(filter) - .try_init() - .ok(); - - LogGuard { - _file_guard: Some(guard), - } - } else { - tracing_subscriber::registry() - .with( - tracing_subscriber::fmt::layer() - .compact() - .with_target(false), - ) - .with(filter) - .try_init() - .ok(); - - LogGuard { _file_guard: None } - } -} - -fn log_dir() -> Option<PathBuf> { - let raw = std::env::var("ACCELERATOR_LOG_DIR").unwrap_or_default(); - if raw.eq_ignore_ascii_case("none") { - return None; - } - if !raw.is_empty() { - return Some(raw.into()); - } - let home = std::env::var("HOME").ok()?; - Some(PathBuf::from(home).join(".accelerator").join("logs")) -} diff --git a/crates/accelerator/src/lsp/client.rs b/crates/accelerator/src/lsp/client.rs index e89c68b1..45e9e657 100644 --- a/crates/accelerator/src/lsp/client.rs +++ b/crates/accelerator/src/lsp/client.rs @@ -63,7 +63,7 @@ impl LspClient { Self::start_with_command(server, root, server.command, server.args).await } - async fn start_with_command( + pub async fn start_with_command( server: ServerSpec, root: PathBuf, command_path: impl AsRef<OsStr>, @@ -380,10 +380,9 @@ impl LspClient { } if message.get("method").and_then(|value| value.as_str()) == Some("textDocument/publishDiagnostics") + && let Some(params) = message.get("params") { - if let Some(params) = message.get("params") { - self.handle_diagnostics(params.clone()).await; - } + self.handle_diagnostics(params.clone()).await; } } @@ -411,86 +410,3 @@ impl LspClient { } } } - -#[cfg(test)] -mod tests { - use super::*; - - const FAKE_SERVER: &str = r#" -import json, sys - -def read_msg(): - length = None - while True: - line = sys.stdin.buffer.readline() - if not line: - return None - if line in (b'\r\n', b'\n'): - break - name, value = line.decode().split(':', 1) - if name.lower() == 'content-length': - length = int(value.strip()) - return json.loads(sys.stdin.buffer.read(length).decode()) - -def write_msg(msg): - body = json.dumps(msg, separators=(',', ':')).encode() - sys.stdout.buffer.write(b'Content-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body) - sys.stdout.buffer.flush() - -while True: - msg = read_msg() - if msg is None: - break - if msg.get('method') == 'initialize': - write_msg({'jsonrpc':'2.0','id':msg['id'],'result':{'capabilities':{'textDocumentSync':1}}}) - elif msg.get('method') == 'textDocument/didOpen': - doc = msg['params']['textDocument'] - write_msg({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':doc['uri'],'version':doc['version'],'diagnostics':[{'range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}},'severity':1,'message':'fake error'}]}}) - elif msg.get('method') == 'textDocument/didChange': - doc = msg['params']['textDocument'] - write_msg({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':doc['uri'],'version':doc['version'],'diagnostics':[{'range':{'start':{'line':1,'character':0},'end':{'line':1,'character':1}},'severity':1,'message':'changed error'}]}}) -"#; - - #[tokio::test] - async fn fake_lsp_server_reports_versioned_diagnostics() { - if std::process::Command::new("python3") - .arg("--version") - .output() - .is_err() - { - eprintln!("skipping fake LSP test: python3 not found"); - return; - } - let root = std::env::temp_dir().join(format!("rcm_fake_lsp_{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("lib.rs"); - std::fs::write(&path, "fn main() {}\n").unwrap(); - let server = ServerSpec { - id: "fake-lsp", - language_id: "rust", - extensions: &["rs"], - root_markers: &["Cargo.toml"], - command: "python3", - args: &[], - }; - let client = LspClient::start_with_command( - server, - root.clone(), - "python3", - &["-u", "-c", FAKE_SERVER], - ) - .await - .unwrap(); - let first = client - .touch_file_with_text(&path, "fn main() {}\n", true) - .await - .unwrap(); - assert_eq!(first[0].message, "fake error"); - let second = client - .touch_file_with_text(&path, "fn main() {}\nlet x = 1;\n", true) - .await - .unwrap(); - assert_eq!(second[0].message, "changed error"); - std::fs::remove_dir_all(root).ok(); - } -} diff --git a/crates/accelerator/src/lsp/diagnostics.rs b/crates/accelerator/src/lsp/diagnostics.rs index 7b652d9f..54f1207e 100644 --- a/crates/accelerator/src/lsp/diagnostics.rs +++ b/crates/accelerator/src/lsp/diagnostics.rs @@ -75,10 +75,10 @@ impl DiagnosticStore { diagnostics: Vec<Diagnostic>, current_version: Option<i32>, ) -> bool { - if let (Some(published), Some(current)) = (version, current_version) { - if published != current { - return false; - } + if let (Some(published), Some(current)) = (version, current_version) + && published != current + { + return false; } { diff --git a/crates/accelerator/src/lsp/mod.rs b/crates/accelerator/src/lsp/mod.rs index 9ee82963..c9dee635 100644 --- a/crates/accelerator/src/lsp/mod.rs +++ b/crates/accelerator/src/lsp/mod.rs @@ -7,5 +7,7 @@ mod servers; mod transport; mod uri; +pub use client::LspClient; pub use diagnostics::{DiagnosticSnapshot, format_file_diagnostics, new_error_diagnostics}; pub use pool::{query, snapshot, touch_file_from_disk, touch_file_with_text}; +pub use servers::ServerSpec; diff --git a/crates/accelerator/src/lsp/pool.rs b/crates/accelerator/src/lsp/pool.rs index 8a0fff91..a174c79b 100644 --- a/crates/accelerator/src/lsp/pool.rs +++ b/crates/accelerator/src/lsp/pool.rs @@ -219,21 +219,3 @@ async fn remove_client(key: &ClientKey) { states.remove(key); } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn client_key_hashes_root_and_server() { - let first = ClientKey { - server_id: "rust-analyzer", - root: PathBuf::from("/a"), - }; - let second = ClientKey { - server_id: "rust-analyzer", - root: PathBuf::from("/b"), - }; - assert_ne!(first, second); - } -} diff --git a/crates/accelerator/src/mcp/http.rs b/crates/accelerator/src/mcp/http.rs index eb280072..21e1c0d6 100644 --- a/crates/accelerator/src/mcp/http.rs +++ b/crates/accelerator/src/mcp/http.rs @@ -1,32 +1,32 @@ use std::sync::atomic::{AtomicU64, Ordering}; use serde_json::{Value, json}; +use tokio::sync::Mutex; -use super::transport::CALL_TIMEOUT; +use super::transport::{CALL_TIMEOUT, PROTOCOL_VERSION, find_sse_response}; -/// JSON-RPC 2.0 transport over HTTP POST (Streamable HTTP). -/// -/// Sends JSON-RPC requests as HTTP POST to the configured endpoint. -/// No subprocess, no background reader — each call is a simple -/// request-response cycle. Supports custom headers (Authorization, etc.). pub(crate) struct HttpTransport { url: String, headers: Vec<(String, String)>, client: reqwest::Client, next_id: AtomicU64, + session_id: Mutex<Option<String>>, } impl HttpTransport { - pub(crate) fn new(url: String, headers: Vec<(String, String)>) -> Self { - Self { + pub(crate) fn new(url: String, headers: Vec<(String, String)>) -> Result<Self, String> { + let client = reqwest::Client::builder() + .timeout(CALL_TIMEOUT) + .pool_max_idle_per_host(0) + .build() + .map_err(|error| format!("failed to build HTTP MCP client: {error}"))?; + Ok(Self { url, headers, - client: reqwest::Client::builder() - .timeout(CALL_TIMEOUT) - .build() - .expect("reqwest Client::builder"), + client, next_id: AtomicU64::new(1), - } + session_id: Mutex::new(None), + }) } pub(crate) async fn initialize(&self) -> Result<Value, String> { @@ -34,29 +34,39 @@ impl HttpTransport { .call_raw( "initialize", json!({ - "protocolVersion": "2025-03-26", + "protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": { "name": "rcm", "version": "0.1.0" } }), ) .await?; - let _ = self - .post(&json!({ - "jsonrpc": "2.0", - "method": "notifications/initialized" - })) - .await; - + self.notify("notifications/initialized", json!({})).await?; Ok(result) } pub(crate) async fn list_tools(&self) -> Result<Vec<Value>, String> { - let result: Value = self.call_raw("tools/list", json!({})).await?; - let tools = result["tools"] - .as_array() - .ok_or_else(|| "tools/list returned non-array 'tools'".to_string())?; - Ok(tools.clone()) + let mut cursor = None; + let mut tools = Vec::new(); + loop { + let params = cursor + .take() + .map(|cursor| json!({ "cursor": cursor })) + .unwrap_or_else(|| json!({})); + let result = self.call_raw("tools/list", params).await?; + let page = result["tools"] + .as_array() + .ok_or_else(|| "tools/list returned non-array 'tools'".to_string())?; + tools.extend(page.iter().cloned()); + cursor = result + .get("nextCursor") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + if cursor.is_none() { + break; + } + } + Ok(tools) } pub(crate) async fn call(&self, method: &str, params: Value) -> Result<Value, String> { @@ -71,39 +81,80 @@ impl HttpTransport { "method": method, "params": params }); + self.post(&request, Some(id)) + .await? + .ok_or_else(|| format!("MCP request '{method}' returned no response body")) + } - let response: Value = self.post(&request).await?; - - if let Some(error) = response.get("error") { - return Err(format!( - "MCP error ({}): {}", - error["code"].as_i64().unwrap_or(0), - error["message"].as_str().unwrap_or("unknown") - )); - } - - Ok(response["result"].clone()) + async fn notify(&self, method: &str, params: Value) -> Result<(), String> { + let request = json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + }); + self.post(&request, None).await.map(|_| ()) } - async fn post(&self, body: &Value) -> Result<Value, String> { - let mut req = self.client.post(&self.url).json(body); + async fn post(&self, body: &Value, expected_id: Option<u64>) -> Result<Option<Value>, String> { + let mut request = self + .client + .post(&self.url) + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", PROTOCOL_VERSION) + .json(body); for (key, value) in &self.headers { - req = req.header(key.as_str(), value.as_str()); + request = request.header(key.as_str(), value.as_str()); + } + if let Some(session_id) = self.session_id.lock().await.as_deref() { + request = request.header("Mcp-Session-Id", session_id); } - let resp = req + let response = request .send() .await - .map_err(|e| format!("HTTP request failed: {e}"))?; - - let status = resp.status(); + .map_err(|error| format!("HTTP request failed: {error}"))?; + if let Some(session_id) = response.headers().get("Mcp-Session-Id") { + let session_id = session_id + .to_str() + .map_err(|error| format!("invalid MCP session id header: {error}"))? + .to_string(); + *self.session_id.lock().await = Some(session_id); + } + let status = response.status(); + if status == reqwest::StatusCode::ACCEPTED { + return Ok(None); + } if !status.is_success() { - let text = resp.text().await.unwrap_or_default(); + let text = response.text().await.unwrap_or_default(); return Err(format!("HTTP {status}: {text}")); } - resp.json::<Value>() + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + let text = response + .text() .await - .map_err(|e| format!("JSON parse error: {e}")) + .map_err(|error| format!("HTTP body read failed: {error}"))?; + if text.trim().is_empty() { + return Ok(None); + } + let response = if content_type.starts_with("text/event-stream") { + find_sse_response(&text, expected_id)? + } else { + serde_json::from_str::<Value>(&text) + .map_err(|error| format!("JSON parse error: {error}"))? + }; + if let Some(error) = response.get("error") { + return Err(format!( + "MCP error ({}): {}", + error["code"].as_i64().unwrap_or(0), + error["message"].as_str().unwrap_or("unknown") + )); + } + Ok(Some(response["result"].clone())) } } diff --git a/crates/accelerator/src/mcp/mod.rs b/crates/accelerator/src/mcp/mod.rs index db397ae1..19624b8a 100644 --- a/crates/accelerator/src/mcp/mod.rs +++ b/crates/accelerator/src/mcp/mod.rs @@ -1,7 +1,8 @@ mod http; mod registry; +mod sse; mod stdio; pub(crate) mod tool; mod transport; -pub use registry::{McpRegistry, McpServerConfig}; +pub use registry::{McpRegistry, McpServerConfig, McpTransportConfig}; diff --git a/crates/accelerator/src/mcp/registry.rs b/crates/accelerator/src/mcp/registry.rs index 266b70e6..7b6764bd 100644 --- a/crates/accelerator/src/mcp/registry.rs +++ b/crates/accelerator/src/mcp/registry.rs @@ -1,25 +1,22 @@ +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::sync::Arc; use tracing::{debug, info}; use super::http::HttpTransport; +use super::sse::SseTransport; use super::stdio::StdioTransport; use super::tool::McpTool; use super::transport::Transport; -/// Lifecycle manager for MCP servers. pub struct McpRegistry { - tools: Vec<Arc<dyn machine::Tool>>, + tools_by_server: HashMap<String, Vec<Arc<dyn machine::Tool>>>, } impl McpRegistry { - /// Start all configured MCP servers and discover their tools. - /// - /// Detects the transport type automatically: - /// - URLs starting with `http://` or `https://` → Streamable HTTP - /// - Anything else → stdio subprocess pub async fn start(configs: &[McpServerConfig]) -> Result<Self, String> { - let mut all_tools: Vec<Arc<dyn machine::Tool>> = Vec::new(); + let mut tools_by_server = HashMap::new(); for config in configs { info!(target: "mcp", server = %config.label, "starting MCP server"); @@ -28,148 +25,137 @@ impl McpRegistry { let transport = Arc::new(tokio::sync::Mutex::new(transport)); { - let t = transport.lock().await; - t.initialize().await?; + let server = transport.lock().await; + server.initialize().await?; } let tool_defs = { - let t = transport.lock().await; - t.list_tools().await? + let server = transport.lock().await; + server.list_tools().await? }; + let mut server_tools: Vec<Arc<dyn machine::Tool>> = Vec::new(); + let mut public_names = HashSet::new(); for def in &tool_defs { - let name = def["name"] + let server_name = def["name"] .as_str() .ok_or_else(|| format!("MCP tool missing 'name': {def}"))? .to_string(); + let public_name = public_tool_name(&config.label, &server_name); + if !public_names.insert(public_name.clone()) { + return Err(format!( + "duplicate MCP tool name after namespacing: {public_name}" + )); + } let description = def["description"].as_str().unwrap_or("").to_string(); let input_schema = def["inputSchema"].clone(); let parameters = if input_schema.is_null() { - serde_json::json!({ - "type": "object", - "properties": {} - }) + serde_json::json!({ "type": "object", "properties": {} }) } else { input_schema }; - debug!( - target: "mcp", - server = %config.label, - tool = %name, - "discovered tool" - ); + debug!(target: "mcp", server = %config.label, tool = %public_name, "discovered tool"); - all_tools.push(Arc::new(McpTool { - name, + server_tools.push(Arc::new(McpTool { + public_name, + server_name, description, parameters, transport: Arc::clone(&transport), })); } - info!( - target: "mcp", - server = %config.label, - tool_count = tool_defs.len(), - "MCP server ready" - ); + info!(target: "mcp", server = %config.label, tool_count = server_tools.len(), "MCP server ready"); + + if tools_by_server + .insert(config.label.clone(), server_tools) + .is_some() + { + return Err(format!("duplicate MCP server label: {}", config.label)); + } } - Ok(Self { tools: all_tools }) + Ok(Self { tools_by_server }) } - /// All discovered MCP tools, flattened across all servers. - pub fn tools(&self) -> Vec<Arc<dyn machine::Tool>> { - self.tools.clone() + pub fn tools_for(&self, label: &str) -> Option<Vec<Arc<dyn machine::Tool>>> { + self.tools_by_server.get(label).cloned() } } -/// Configuration for launching an MCP server. -/// -/// Supports two transport types, auto-detected from the value: -/// - `label=command arg1 arg2` → stdio subprocess -/// - `label=https://example.com/path` → Streamable HTTP -/// -/// For HTTP servers, use `--mcp-header` to add custom headers. #[derive(Debug, Clone)] pub struct McpServerConfig { pub label: String, - pub command: Option<String>, - pub args: Vec<String>, - pub url: Option<String>, - pub headers: Vec<(String, String)>, + pub transport: McpTransportConfig, } -impl McpServerConfig { - /// Parse a `label=value` string where value is either a command line - /// or an HTTP(S) URL. - pub fn parse(raw: &str) -> Result<Self, String> { - let (label, rest) = raw.split_once('=').ok_or_else(|| { - format!( - "invalid MCP server format '{raw}' — expected 'label=command' or 'label=https://...'" - ) - })?; - - if label.is_empty() { - return Err("MCP server label cannot be empty".to_string()); - } - if rest.is_empty() { - return Err(format!("MCP server '{label}' has no value")); - } +#[derive(Debug, Clone)] +pub enum McpTransportConfig { + Stdio { + command: String, + args: Vec<String>, + env: HashMap<String, String>, + cwd: Option<PathBuf>, + }, + Http { + url: String, + headers: Vec<(String, String)>, + }, + Sse { + url: String, + headers: Vec<(String, String)>, + }, +} - let trimmed = rest.trim(); - if trimmed.starts_with("http://") || trimmed.starts_with("https://") { - // Format: url|HeaderName:Value|HeaderName:Value - // `|` is used instead of `,` because neither URLs nor - // typical header values contain pipe characters. - let mut headers = Vec::new(); - let url = if let Some((url_part, header_part)) = trimmed.split_once('|') { - for h in header_part.split('|') { - let h = h.trim(); - if let Some((key, value)) = h.split_once(':') { - headers.push((key.trim().to_string(), value.trim().to_string())); - } - } - url_part.to_string() - } else { - trimmed.to_string() - }; +fn public_tool_name(label: &str, server_name: &str) -> String { + format!( + "{}__{}", + sanitize_tool_name(label), + sanitize_tool_name(server_name) + ) +} - Ok(Self { - label: label.to_string(), - command: None, - args: Vec::new(), - url: Some(url), - headers, - }) - } else { - let parts: Vec<&str> = trimmed.split_whitespace().collect(); - if parts.is_empty() { - return Err(format!("MCP server '{label}' has no command or URL")); +fn sanitize_tool_name(name: &str) -> String { + let mut sanitized = name + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || character == '_' || character == '-' { + character + } else { + '_' } - Ok(Self { - label: label.to_string(), - command: Some(parts[0].to_string()), - args: parts[1..].iter().map(|s| s.to_string()).collect(), - url: None, - headers: Vec::new(), - }) - } + }) + .collect::<String>(); + if sanitized.is_empty() { + sanitized.push_str("tool"); + } + if sanitized.len() > 64 { + sanitized.truncate(64); } + sanitized +} - /// Create the transport for this configuration. +impl McpServerConfig { async fn create_transport(&self) -> Result<Transport, String> { - match (&self.command, &self.url) { - (Some(cmd), None) => { - let transport = StdioTransport::spawn(cmd, &self.args).await?; + match &self.transport { + McpTransportConfig::Stdio { + command, + args, + env, + cwd, + } => { + let transport = StdioTransport::spawn(command, args, env, cwd.as_deref()).await?; Ok(Transport::Stdio(Box::new(transport))) } - (None, Some(url)) => { - let t = HttpTransport::new(url.clone(), self.headers.clone()); - Ok(Transport::Http(t)) + McpTransportConfig::Http { url, headers } => Ok(Transport::Http(HttpTransport::new( + url.clone(), + headers.clone(), + )?)), + McpTransportConfig::Sse { url, headers } => { + let transport = SseTransport::connect(url.clone(), headers.clone()).await?; + Ok(Transport::Sse(Box::new(transport))) } - _ => Err("McpServerConfig has neither command nor url".to_string()), } } } diff --git a/crates/accelerator/src/mcp/sse.rs b/crates/accelerator/src/mcp/sse.rs new file mode 100644 index 00000000..7e0eb800 --- /dev/null +++ b/crates/accelerator/src/mcp/sse.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures_util::StreamExt; +use serde_json::{Value, json}; +use tokio::sync::oneshot; +use tracing::warn; +use url::Url; + +use super::transport::{CALL_TIMEOUT, parse_sse_events}; + +pub(crate) struct SseTransport { + client: reqwest::Client, + endpoint: String, + headers: Vec<(String, String)>, + pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>>, + next_id: AtomicU64, + _reader_handle: tokio::task::JoinHandle<()>, +} + +impl SseTransport { + pub(crate) async fn connect( + url: String, + headers: Vec<(String, String)>, + ) -> Result<Self, String> { + let client = reqwest::Client::builder() + .timeout(CALL_TIMEOUT) + .build() + .map_err(|error| format!("failed to build SSE MCP client: {error}"))?; + let pending: Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>> = + Arc::new(Mutex::new(HashMap::new())); + let mut request = client.get(&url).header("Accept", "text/event-stream"); + for (key, value) in &headers { + request = request.header(key.as_str(), value.as_str()); + } + let response = request + .send() + .await + .map_err(|error| format!("SSE connection failed: {error}"))?; + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(format!("SSE HTTP {status}: {text}")); + } + + let mut stream = response.bytes_stream(); + let mut buffer = String::new(); + let endpoint = loop { + let Some(chunk) = stream.next().await else { + return Err("SSE stream closed before endpoint event".to_string()); + }; + let chunk = chunk.map_err(|error| format!("SSE read failed: {error}"))?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + if let Some(endpoint) = take_endpoint(&mut buffer, &url)? { + break endpoint; + } + }; + + let reader_pending = Arc::clone(&pending); + let reader_handle = tokio::spawn(async move { + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + buffer.push_str(&String::from_utf8_lossy(&bytes)); + dispatch_events(&mut buffer, &reader_pending); + } + Err(error) => { + warn!(target: "mcp", ?error, "SSE stream read failed"); + break; + } + } + } + }); + + Ok(Self { + client, + endpoint, + headers, + pending, + next_id: AtomicU64::new(1), + _reader_handle: reader_handle, + }) + } + + pub(crate) async fn initialize(&self) -> Result<Value, String> { + let result = self + .call_raw( + "initialize", + json!({ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "rcm", "version": "0.1.0" } + }), + ) + .await?; + self.notify("notifications/initialized", json!({})).await?; + Ok(result) + } + + pub(crate) async fn list_tools(&self) -> Result<Vec<Value>, String> { + let mut cursor = None; + let mut tools = Vec::new(); + loop { + let params = cursor + .take() + .map(|cursor| json!({ "cursor": cursor })) + .unwrap_or_else(|| json!({})); + let result = self.call_raw("tools/list", params).await?; + let page = result["tools"] + .as_array() + .ok_or_else(|| "tools/list returned non-array 'tools'".to_string())?; + tools.extend(page.iter().cloned()); + cursor = result + .get("nextCursor") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + if cursor.is_none() { + break; + } + } + Ok(tools) + } + + pub(crate) async fn call(&self, method: &str, params: Value) -> Result<Value, String> { + self.call_raw(method, params).await + } + + async fn call_raw(&self, method: &str, params: Value) -> Result<Value, String> { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (response_tx, response_rx) = oneshot::channel(); + self.pending + .lock() + .unwrap_or_else(|error| error.into_inner()) + .insert(id, response_tx); + let request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }); + self.post(&request).await?; + let response = tokio::time::timeout(CALL_TIMEOUT, response_rx) + .await + .map_err(|_| format!("MCP '{method}' timed out after 120s"))? + .map_err(|_| format!("MCP '{method}' cancelled (server closed)"))?; + if let Some(error) = response.get("error") { + return Err(format!( + "MCP error ({}): {}", + error["code"].as_i64().unwrap_or(0), + error["message"].as_str().unwrap_or("unknown") + )); + } + Ok(response["result"].clone()) + } + + async fn notify(&self, method: &str, params: Value) -> Result<(), String> { + self.post(&json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + })) + .await + } + + async fn post(&self, body: &Value) -> Result<(), String> { + let mut request = self.client.post(&self.endpoint).json(body); + for (key, value) in &self.headers { + request = request.header(key.as_str(), value.as_str()); + } + let response = request + .send() + .await + .map_err(|error| format!("SSE POST failed: {error}"))?; + if response.status().is_success() { + Ok(()) + } else { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + Err(format!("SSE POST HTTP {status}: {text}")) + } + } +} + +fn take_endpoint(buffer: &mut String, base_url: &str) -> Result<Option<String>, String> { + let Some(split) = buffer.find("\n\n") else { + return Ok(None); + }; + let chunk = buffer[..split + 2].to_string(); + buffer.replace_range(..split + 2, ""); + for event in parse_sse_events(&chunk) { + if event.event.as_deref() == Some("endpoint") { + let data = event + .data + .ok_or_else(|| "SSE endpoint event missing data".to_string())?; + return Ok(Some(resolve_endpoint(base_url, data.trim())?)); + } + } + Ok(None) +} + +fn dispatch_events( + buffer: &mut String, + pending: &Arc<Mutex<HashMap<u64, oneshot::Sender<Value>>>>, +) { + while let Some(split) = buffer.find("\n\n") { + let chunk = buffer[..split + 2].to_string(); + buffer.replace_range(..split + 2, ""); + for event in parse_sse_events(&chunk) { + if event.event.as_deref() != Some("message") { + continue; + } + let Some(data) = event.data else { + continue; + }; + match serde_json::from_str::<Value>(&data) { + Ok(value) => { + if let Some(id) = value.get("id").and_then(|value| value.as_u64()) + && let Ok(mut pending) = pending.lock() + && let Some(sender) = pending.remove(&id) + { + let _ = sender.send(value); + } + } + Err(error) => warn!(target: "mcp", ?error, "failed to parse SSE message"), + } + } + } +} + +fn resolve_endpoint(base_url: &str, endpoint: &str) -> Result<String, String> { + if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + return Ok(endpoint.to_string()); + } + let base = Url::parse(base_url).map_err(|error| format!("invalid SSE URL: {error}"))?; + base.join(endpoint) + .map(|url| url.to_string()) + .map_err(|error| format!("invalid SSE endpoint: {error}")) +} diff --git a/crates/accelerator/src/mcp/stdio.rs b/crates/accelerator/src/mcp/stdio.rs index e59b2ec4..93f23753 100644 --- a/crates/accelerator/src/mcp/stdio.rs +++ b/crates/accelerator/src/mcp/stdio.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -24,13 +25,24 @@ pub(crate) struct StdioTransport { } impl StdioTransport { - pub(crate) async fn spawn(command: &str, args: &[String]) -> Result<Self, String> { - let mut child = tokio::process::Command::new(command) + pub(crate) async fn spawn( + command: &str, + args: &[String], + env: &HashMap<String, String>, + cwd: Option<&Path>, + ) -> Result<Self, String> { + let mut process = tokio::process::Command::new(command); + process .args(args) + .envs(env) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) + .kill_on_drop(true); + if let Some(cwd) = cwd { + process.current_dir(cwd); + } + let mut child = process .spawn() .map_err(|e| format!("failed to spawn MCP server '{}': {}", command, e))?; @@ -139,11 +151,27 @@ impl StdioTransport { } pub(crate) async fn list_tools(&self) -> Result<Vec<Value>, String> { - let result: Value = self.call_raw("tools/list", json!({})).await?; - let tools = result["tools"] - .as_array() - .ok_or_else(|| "tools/list returned non-array 'tools'".to_string())?; - Ok(tools.clone()) + let mut cursor = None; + let mut tools = Vec::new(); + loop { + let params = cursor + .take() + .map(|cursor| json!({ "cursor": cursor })) + .unwrap_or_else(|| json!({})); + let result: Value = self.call_raw("tools/list", params).await?; + let page = result["tools"] + .as_array() + .ok_or_else(|| "tools/list returned non-array 'tools'".to_string())?; + tools.extend(page.iter().cloned()); + cursor = result + .get("nextCursor") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + if cursor.is_none() { + break; + } + } + Ok(tools) } pub(crate) async fn call(&self, method: &str, params: Value) -> Result<Value, String> { diff --git a/crates/accelerator/src/mcp/tool.rs b/crates/accelerator/src/mcp/tool.rs index ac9e3b7a..04dbe7c4 100644 --- a/crates/accelerator/src/mcp/tool.rs +++ b/crates/accelerator/src/mcp/tool.rs @@ -8,13 +8,9 @@ use tokio::sync::Mutex; use super::transport::Transport; -/// A tool discovered from an MCP server. -/// -/// Wraps the server's tool description and delegates `execute` to the -/// `tools/call` JSON-RPC method. The transport is shared — all tools -/// from the same server use the same connection. pub(crate) struct McpTool { - pub(crate) name: String, + pub(crate) public_name: String, + pub(crate) server_name: String, pub(crate) description: String, pub(crate) parameters: Value, pub(crate) transport: Arc<Mutex<Transport>>, @@ -22,7 +18,7 @@ pub(crate) struct McpTool { impl Tool for McpTool { fn name(&self) -> &str { - &self.name + &self.public_name } fn description(&self) -> &str { @@ -44,56 +40,98 @@ impl Tool for McpTool { .call( "tools/call", serde_json::json!({ - "name": self.name, + "name": self.server_name, "arguments": args }), ) .await?; - let mut output = String::new(); - if let Some(contents) = result.get("content").and_then(|c| c.as_array()) { - for entry in contents { - if let Some(text) = entry.get("text").and_then(|t| t.as_str()) { - if !output.is_empty() { - output.push('\n'); - } - output.push_str(text); - } - match entry.get("type").and_then(|t| t.as_str()) { - Some("image") => output.push_str("[MCP image result]"), - Some("resource") => { - if let Some(uri) = entry.get("uri").and_then(|u| u.as_str()) { - output.push_str(&format!("[MCP resource: {uri}]")); - } else { - output.push_str("[MCP resource result]"); - } - } - _ => {} - } - } - } - - if result - .get("isError") - .and_then(|value| value.as_bool()) - .unwrap_or(false) - { - return Err(if output.is_empty() { - "MCP tool returned an error with no text content".to_string() - } else { - output - }); - } - - if output.is_empty() { - output = "[tool returned no content]".to_string(); - } - + let output = format_tool_result(&result)?; Ok(ToolResult { call_id: String::new(), content: output, - title: Some(self.name.clone()), + title: Some(self.public_name.clone()), }) }) } } + +fn format_tool_result(result: &Value) -> Result<String, String> { + let mut output = String::new(); + if let Some(contents) = result.get("content").and_then(|content| content.as_array()) { + for entry in contents { + append_content(&mut output, entry); + } + } + if let Some(structured) = result.get("structuredContent") { + if !output.is_empty() { + output.push('\n'); + } + output.push_str(&structured.to_string()); + } + if result + .get("isError") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Err(if output.is_empty() { + "MCP tool returned an error with no content".to_string() + } else { + output + }); + } + if output.is_empty() { + output = "[tool returned no content]".to_string(); + } + Ok(output) +} + +fn append_content(output: &mut String, entry: &Value) { + if !output.is_empty() { + output.push('\n'); + } + match entry.get("type").and_then(|value| value.as_str()) { + Some("text") => output.push_str( + entry + .get("text") + .and_then(|value| value.as_str()) + .unwrap_or(""), + ), + Some("image") | Some("audio") => { + // Emit as data URL when base64 data is available. + let mime = entry + .get("mimeType") + .and_then(|v| v.as_str()) + .unwrap_or("application/octet-stream"); + // Only allow image/* and audio/* MIME types to prevent injection. + let is_valid_mime = mime.starts_with("image/") || mime.starts_with("audio/"); + if let Some(data) = entry.get("data").and_then(|v| v.as_str()) { + if is_valid_mime { + output.push_str(&format!("data:{mime};base64,{data}")); + } else { + output.push_str(&format!( + "[MCP {} result: invalid mime type]", + entry.get("type").and_then(|v| v.as_str()).unwrap_or("") + )); + } + } else if let Some(url) = entry.get("url").and_then(|v| v.as_str()) { + output.push_str(url); + } else { + output.push_str(&format!( + "[MCP {} result]", + entry.get("type").and_then(|v| v.as_str()).unwrap_or("") + )); + } + } + Some("resource_link") => { + let uri = entry + .get("uri") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + output.push_str(&format!("[MCP resource link: {uri}]")); + } + Some("resource") => output.push_str("[MCP embedded resource]"), + Some(other) => output.push_str(&format!("[MCP {other} result]")), + None => output.push_str("[MCP result]"), + } +} diff --git a/crates/accelerator/src/mcp/transport.rs b/crates/accelerator/src/mcp/transport.rs index a3fbcdaa..1b13b3ac 100644 --- a/crates/accelerator/src/mcp/transport.rs +++ b/crates/accelerator/src/mcp/transport.rs @@ -3,35 +3,85 @@ use std::time::Duration; use serde_json::Value; use super::http::HttpTransport; +use super::sse::SseTransport; use super::stdio::StdioTransport; pub(crate) const CALL_TIMEOUT: Duration = Duration::from_secs(120); +pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18"; -/// MCP transport dispatching to stdio or HTTP. pub(crate) enum Transport { Stdio(Box<StdioTransport>), Http(HttpTransport), + Sse(Box<SseTransport>), } impl Transport { pub(crate) async fn initialize(&self) -> Result<Value, String> { match self { - Self::Stdio(t) => t.initialize().await, - Self::Http(t) => t.initialize().await, + Self::Stdio(transport) => transport.initialize().await, + Self::Http(transport) => transport.initialize().await, + Self::Sse(transport) => transport.initialize().await, } } pub(crate) async fn list_tools(&self) -> Result<Vec<Value>, String> { match self { - Self::Stdio(t) => t.list_tools().await, - Self::Http(t) => t.list_tools().await, + Self::Stdio(transport) => transport.list_tools().await, + Self::Http(transport) => transport.list_tools().await, + Self::Sse(transport) => transport.list_tools().await, } } pub(crate) async fn call(&self, method: &str, params: Value) -> Result<Value, String> { match self { - Self::Stdio(t) => t.call(method, params).await, - Self::Http(t) => t.call(method, params).await, + Self::Stdio(transport) => transport.call(method, params).await, + Self::Http(transport) => transport.call(method, params).await, + Self::Sse(transport) => transport.call(method, params).await, } } } + +pub(crate) fn find_sse_response(stream: &str, expected_id: Option<u64>) -> Result<Value, String> { + for event in parse_sse_events(stream) { + let Some(data) = event.data else { + continue; + }; + let value = serde_json::from_str::<Value>(&data) + .map_err(|error| format!("SSE JSON parse error: {error}"))?; + if expected_id.is_none() || value.get("id").and_then(|id| id.as_u64()) == expected_id { + return Ok(value); + } + } + Err("SSE stream did not contain the expected JSON-RPC response".to_string()) +} + +pub(crate) fn parse_sse_events(stream: &str) -> Vec<SseEvent> { + let mut events = Vec::new(); + let mut event = SseEvent::default(); + for line in stream.lines() { + let line = line.trim_end_matches('\r'); + if line.is_empty() { + if event.event.is_some() || event.data.is_some() { + events.push(event); + event = SseEvent::default(); + } + continue; + } + if let Some(value) = line.strip_prefix("event:") { + event.event = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("data:") { + let value = value.trim_start(); + event.data.get_or_insert_with(String::new).push_str(value); + } + } + if event.event.is_some() || event.data.is_some() { + events.push(event); + } + events +} + +#[derive(Default)] +pub(crate) struct SseEvent { + pub event: Option<String>, + pub data: Option<String>, +} diff --git a/crates/accelerator/src/policy/captain.rs b/crates/accelerator/src/policy/captain.rs index a1bc18d0..44607cb2 100644 --- a/crates/accelerator/src/policy/captain.rs +++ b/crates/accelerator/src/policy/captain.rs @@ -1,30 +1,60 @@ use std::future::Future; use std::pin::Pin; +use std::sync::atomic::{AtomicU8, Ordering}; -use machine::{Action, Context, Environment, Inbox, Phase, Policy, Purpose, Resources, Role}; -use tracing::trace; +use machine::{Action, Context, Environment, Policy, PolicyView, Purpose, Resources}; -use super::phases::{self, Bootstrap, Env, Instructions}; +use super::retry::Retry; +use super::{Step, moves}; +use moves::react::ReactDecision; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Phase { + Agent = 0, + Instruction = 1, + Environment = 2, + Purpose = 3, + Resources = 4, + Respond = 5, + Running = 6, +} + +impl Phase { + fn from_u8(value: u8) -> Self { + match value { + 0 => Self::Agent, + 1 => Self::Instruction, + 2 => Self::Environment, + 3 => Self::Purpose, + 4 => Self::Resources, + 5 => Self::Respond, + _ => Self::Running, + } + } + + fn next(self) -> Self { + match self { + Self::Agent => Self::Instruction, + Self::Instruction => Self::Environment, + Self::Environment => Self::Purpose, + Self::Purpose => Self::Resources, + Self::Resources => Self::Respond, + Self::Respond => Self::Running, + Self::Running => Self::Running, + } + } +} -/// Captain — a simple single-agent Policy. -/// -/// Decides purely based on inbox state and the last fragment in context: -/// -/// Inbox not empty → Take (drain one fragment into context) -/// Inbox empty: -/// last in context is Tool → Halt (tool was just run, show LLM the result) -/// last in context is not Tool, first call already happened → Done -/// first call ever → Halt (kick off the LLM) pub struct Captain { - started: std::sync::atomic::AtomicBool, + phase: AtomicU8, + retry: Retry, } impl Clone for Captain { fn clone(&self) -> Self { Self { - started: std::sync::atomic::AtomicBool::new( - self.started.load(std::sync::atomic::Ordering::Relaxed), - ), + phase: AtomicU8::new(Phase::Agent as u8), + retry: self.retry.clone(), } } } @@ -32,7 +62,8 @@ impl Clone for Captain { impl Default for Captain { fn default() -> Self { Self { - started: std::sync::atomic::AtomicBool::new(false), + phase: AtomicU8::new(Phase::Agent as u8), + retry: Retry::default(), } } } @@ -43,6 +74,58 @@ impl Captain { } } +impl Captain { + fn phase(&self) -> Phase { + Phase::from_u8(self.phase.load(Ordering::Relaxed)) + } + + fn enter(&self, phase: Phase) { + self.phase.store(phase as u8, Ordering::Relaxed); + } + + fn advance(&self) { + self.enter(self.phase().next()); + } + + fn drive(&self, step: Step) -> Option<Action> { + match step { + Step::Emit(action) => Some(action), + Step::Ready => { + self.advance(); + None + } + } + } + + fn setup( + &self, + ctx: &Context, + env: &Environment, + resources: &Resources, + purpose: &Purpose, + ) -> Option<Action> { + loop { + let step = match self.phase() { + Phase::Agent => moves::agent::prepare(ctx, resources, "captain"), + Phase::Instruction => moves::instruction::load(ctx), + Phase::Environment => moves::env::refresh(ctx, env), + Phase::Purpose => moves::purpose::append(ctx, purpose), + Phase::Resources => moves::resources::activate(resources), + _ => return None, + }; + + if let Some(action) = self.drive(step) { + return Some(action); + } + } + } + + fn respond(&self) -> Action { + self.enter(Phase::Running); + Action::Halt + } +} + impl Policy for Captain { fn clone_box(&self) -> Box<dyn Policy> { Box::new(self.clone()) @@ -52,48 +135,29 @@ impl Policy for Captain { "captain" } - fn pre(&self) -> Vec<Box<dyn Phase>> { - vec![ - Box::new(Bootstrap::new("captain")), - Box::new(Instructions), - Box::new(phases::Purpose), - ] - } - - fn pre_halt(&self) -> Vec<Box<dyn Phase>> { - vec![Box::new(Env)] - } - fn decide<'a>( &'a self, - _purpose: &'a Purpose, - ctx: &'a Context, - _env: &'a Environment, - _resources: &'a Resources, - inbox: &'a Inbox, + view: PolicyView<'a>, ) -> Pin<Box<dyn Future<Output = Action> + Send + 'a>> { Box::pin(async move { - if inbox.peek().is_some() { - return Action::Take; + if let Some(action) = self.setup( + &view.run.context, + &view.run.environment, + &view.run.resources, + &view.run.purpose, + ) { + return action; } - // Inbox is empty. - let not_started = !self - .started - .swap(true, std::sync::atomic::Ordering::Relaxed); - if not_started { - trace!("decide: first call, halting"); - return Action::Halt; + if self.phase() == Phase::Respond { + return self.respond(); } - match ctx.fragments().last().map(|f| f.role) { - Some(Role::Tool) => { - trace!("decide: last is Tool, halting"); - Action::Halt - } - _ => { - trace!("decide: done"); - Action::Done + match moves::react::decide(&view.run.context, view.inbox, &self.retry).await { + ReactDecision::Action(action) => action, + ReactDecision::Respond => { + self.enter(Phase::Respond); + self.respond() } } }) diff --git a/crates/accelerator/src/policy/mod.rs b/crates/accelerator/src/policy/mod.rs index f63d9ba0..da65d8a9 100644 --- a/crates/accelerator/src/policy/mod.rs +++ b/crates/accelerator/src/policy/mod.rs @@ -1,4 +1,20 @@ mod captain; -pub mod phases; +pub mod moves; +pub mod retry; pub use captain::Captain; + +use machine::Action; + +use crate::catalog::Catalog; + +pub(crate) enum Step { + Emit(Action), + Ready, +} + +pub fn register(catalog: &mut Catalog) { + catalog + .register_policy("captain", || Box::new(Captain::new())) + .expect("built-in policy names must be unique"); +} diff --git a/crates/accelerator/src/policy/moves/agent.rs b/crates/accelerator/src/policy/moves/agent.rs new file mode 100644 index 00000000..c84c2da6 --- /dev/null +++ b/crates/accelerator/src/policy/moves/agent.rs @@ -0,0 +1,45 @@ +use machine::{Action, Context, Fragment, Resources, Role}; + +use super::super::Step; + +const AGENT_TAG: &str = "agent"; + +pub(crate) fn prepare(ctx: &Context, resources: &Resources, prompt_key: &str) -> Step { + let desired = resources + .prompts + .get(prompt_key) + .cloned() + .unwrap_or_default(); + let fragment = Fragment::system(desired.clone()).with_tag(AGENT_TAG); + let fragments = ctx.fragments(); + + let Some(first) = fragments.first() else { + return Step::Emit(Action::Append(fragment)); + }; + + if is_agent(first) { + if let Some(extra) = fragments.iter().skip(1).find(|fragment| is_agent(fragment)) { + return Step::Emit(Action::Remove(extra.id())); + } + if first.as_text() != Some(&desired) { + return Step::Emit(Action::Replace { + id: first.id(), + fragment, + }); + } + return Step::Ready; + } + + if let Some(existing) = fragments.iter().skip(1).find(|fragment| is_agent(fragment)) { + return Step::Emit(Action::Swap(first.id(), existing.id())); + } + + Step::Emit(Action::Insert { + after: first.id(), + fragment, + }) +} + +fn is_agent(fragment: &Fragment) -> bool { + fragment.role == Role::System && fragment.tag == AGENT_TAG +} diff --git a/crates/accelerator/src/policy/moves/env.rs b/crates/accelerator/src/policy/moves/env.rs new file mode 100644 index 00000000..0acd493c --- /dev/null +++ b/crates/accelerator/src/policy/moves/env.rs @@ -0,0 +1,36 @@ +use chrono::Local; +use machine::{Action, Context, Environment, Fragment, Role}; + +use super::super::Step; + +const ENV_TAG: &str = "env"; + +pub(crate) fn refresh(ctx: &Context, env: &Environment) -> Step { + let now = Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, false); + let mut text = format!( + "cwd: {}\nplatform: {}\ntime: {}", + env.cwd.display(), + env.platform, + now, + ); + if let Some(ref run_dir) = env.run_dir { + text.push_str(&format!("\nrun_dir: {}", run_dir.display())); + } + let fragment = Fragment::system(text.clone()).with_tag(ENV_TAG); + + if let Some(existing) = ctx + .fragments() + .iter() + .find(|fragment| fragment.role == Role::System && fragment.tag == ENV_TAG) + { + if existing.as_text() == Some(&text) { + return Step::Ready; + } + return Step::Emit(Action::Replace { + id: existing.id(), + fragment, + }); + } + + Step::Emit(Action::Append(fragment)) +} diff --git a/crates/accelerator/src/policy/moves/instruction.rs b/crates/accelerator/src/policy/moves/instruction.rs new file mode 100644 index 00000000..777a1bfb --- /dev/null +++ b/crates/accelerator/src/policy/moves/instruction.rs @@ -0,0 +1,101 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use machine::{Action, Context, Fragment, Role}; + +use super::super::Step; + +const FILE_NAMES: [&str; 3] = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]; +const INSTRUCTION_TAG: &str = "instruction"; + +pub(crate) fn load(ctx: &Context) -> Step { + if ctx + .fragments() + .iter() + .any(|fragment| fragment.role == Role::System && fragment.tag == INSTRUCTION_TAG) + { + return Step::Ready; + } + + let files = find_instruction_files(); + if files.is_empty() { + return Step::Ready; + } + + let parts: Vec<String> = files + .iter() + .filter(|(_, content)| !content.trim().is_empty()) + .map(|(path, content)| { + let name = path + .file_name() + .unwrap_or(path.as_os_str()) + .to_string_lossy(); + format!( + "=== {name} (from {}) ===\n{}", + path.display(), + content.trim() + ) + }) + .collect(); + + if parts.is_empty() { + return Step::Ready; + } + + Step::Emit(Action::Append( + Fragment::system(parts.join("\n\n")).with_tag(INSTRUCTION_TAG), + )) +} + +fn find_instruction_files() -> Vec<(PathBuf, String)> { + let mut seen = HashSet::new(); + let mut results = Vec::new(); + + if let Ok(cwd) = std::env::current_dir() { + let mut directory = Some(cwd); + while let Some(current) = directory { + if !seen.insert(current.clone()) { + break; + } + let mut found_here = false; + for name in &FILE_NAMES { + let path = current.join(name); + if path.is_file() + && let Ok(content) = std::fs::read_to_string(&path) + { + results.push((path, content)); + found_here = true; + } + } + // Stop at the nearest directory that has instruction files: a local + // AGENTS.md is meant to override ancestors, not stack on top of them. + // This lets an example carry its own instructions without inheriting + // the host repo's development guide. + if found_here { + break; + } + directory = current.parent().map(|parent| parent.to_path_buf()); + } + } + + for path in global_paths() { + if path.is_file() + && !results.iter().any(|(existing, _)| *existing == path) + && let Ok(content) = std::fs::read_to_string(&path) + { + results.push((path, content)); + } + } + + results +} + +fn global_paths() -> Vec<PathBuf> { + let Some(home) = std::env::var("HOME").ok() else { + return Vec::new(); + }; + vec![ + Path::new(&home).join(".synergy/config/AGENTS.md"), + Path::new(&home).join(".claude/CLAUDE.md"), + ] +} diff --git a/crates/accelerator/src/policy/moves/mod.rs b/crates/accelerator/src/policy/moves/mod.rs new file mode 100644 index 00000000..950a667e --- /dev/null +++ b/crates/accelerator/src/policy/moves/mod.rs @@ -0,0 +1,6 @@ +pub mod agent; +pub mod env; +pub mod instruction; +pub mod purpose; +pub mod react; +pub mod resources; diff --git a/crates/accelerator/src/policy/moves/purpose.rs b/crates/accelerator/src/policy/moves/purpose.rs new file mode 100644 index 00000000..8ca04711 --- /dev/null +++ b/crates/accelerator/src/policy/moves/purpose.rs @@ -0,0 +1,23 @@ +use machine::{Action, Context, Fragment, Purpose, Role}; + +use super::super::Step; + +const PURPOSE_TAG: &str = "purpose"; + +pub(crate) fn append(ctx: &Context, purpose: &Purpose) -> Step { + if purpose.is_empty() { + return Step::Ready; + } + + if ctx.fragments().last().is_some_and(|fragment| { + fragment.role == Role::User + && fragment.tag == PURPOSE_TAG + && fragment.as_text() == Some(&purpose.text) + }) { + return Step::Ready; + } + + Step::Emit(Action::Append( + Fragment::user(purpose.text.clone()).with_tag(PURPOSE_TAG), + )) +} diff --git a/crates/accelerator/src/policy/moves/react.rs b/crates/accelerator/src/policy/moves/react.rs new file mode 100644 index 00000000..b87cba65 --- /dev/null +++ b/crates/accelerator/src/policy/moves/react.rs @@ -0,0 +1,47 @@ +use machine::{Action, Content, Context, Inbox, Role}; +use tracing::{trace, warn}; + +use crate::policy::retry::{HTTP_FORBIDDEN, HTTP_UNAUTHORIZED, Retry}; + +pub enum ReactDecision { + Action(Action), + Respond, +} + +pub async fn decide(ctx: &Context, inbox: &Inbox, retry: &Retry) -> ReactDecision { + if inbox.peek().is_some() { + return ReactDecision::Action(Action::Take); + } + + let last_fragment = ctx.fragments().last(); + + if let Some(fragment) = last_fragment + && let Content::Hitch { code, .. } = &fragment.content + { + if let Some(status_code) = code + && (*status_code == HTTP_UNAUTHORIZED || *status_code == HTTP_FORBIDDEN) + { + warn!(code = *status_code, "decide: permanent hitch, done"); + return ReactDecision::Action(Action::Done); + } + + if retry.backoff().await { + let attempts = retry.count(); + trace!(attempts, "decide: hitched, retrying"); + return ReactDecision::Respond; + } + + warn!("decide: retry budget exhausted, done"); + return ReactDecision::Action(Action::Done); + } + + retry.reset(); + + if last_fragment.is_some_and(|fragment| fragment.role == Role::Tool) { + trace!("decide: last is Tool, halting"); + return ReactDecision::Respond; + } + + trace!("decide: done"); + ReactDecision::Action(Action::Done) +} diff --git a/crates/accelerator/src/policy/moves/resources.rs b/crates/accelerator/src/policy/moves/resources.rs new file mode 100644 index 00000000..0b3db4c5 --- /dev/null +++ b/crates/accelerator/src/policy/moves/resources.rs @@ -0,0 +1,21 @@ +use machine::{Action, Resources}; + +use super::super::Step; + +pub(crate) fn activate(resources: &Resources) -> Step { + if resources.active_model.is_empty() + && let Some(model_name) = resources.model_order.first() + { + return Step::Emit(Action::Model(model_name.clone())); + } + + if let Some(tool_name) = resources + .tool_definitions + .keys() + .find(|tool_name| !resources.active_tools.contains(*tool_name)) + { + return Step::Emit(Action::Activate(tool_name.clone())); + } + + Step::Ready +} diff --git a/crates/accelerator/src/policy/phases/bootstrap.rs b/crates/accelerator/src/policy/phases/bootstrap.rs deleted file mode 100644 index 4f776f24..00000000 --- a/crates/accelerator/src/policy/phases/bootstrap.rs +++ /dev/null @@ -1,58 +0,0 @@ -use machine::{ - Action, Context, Environment, Fragment, Phase, PhaseOutcome, Purpose, Resources, Role, -}; - -/// Ensure the `tag == "agent"` system prompt is present and up to date. -pub struct Bootstrap { - prompt_name: String, -} - -impl Bootstrap { - pub fn new(prompt_name: impl Into<String>) -> Self { - Self { - prompt_name: prompt_name.into(), - } - } -} - -impl Phase for Bootstrap { - fn clone_box(&self) -> Box<dyn Phase> { - Box::new(Self { - prompt_name: self.prompt_name.clone(), - }) - } - - fn name(&self) -> &str { - "bootstrap" - } - - fn decide( - &self, - _purpose: &Purpose, - ctx: &Context, - _env: &Environment, - resources: &Resources, - ) -> PhaseOutcome { - let desired = resources - .prompts - .get(&self.prompt_name) - .cloned() - .unwrap_or_default(); - - if let Some(existing) = ctx - .fragments() - .iter() - .find(|f| f.role == Role::System && f.tag == "agent") - { - if existing.as_text() == Some(&desired) { - return PhaseOutcome::Done; - } - return PhaseOutcome::Action(Action::Replace { - id: existing.id(), - fragment: Fragment::system(desired).with_tag("agent"), - }); - } - - PhaseOutcome::Action(Action::Append(Fragment::system(desired).with_tag("agent"))) - } -} diff --git a/crates/accelerator/src/policy/phases/env.rs b/crates/accelerator/src/policy/phases/env.rs deleted file mode 100644 index a0e27d4e..00000000 --- a/crates/accelerator/src/policy/phases/env.rs +++ /dev/null @@ -1,50 +0,0 @@ -use chrono::Local; -use machine::{ - Action, Context, Environment, Fragment, Phase, PhaseOutcome, Purpose, Resources, Role, -}; - -/// Inject or replace a `System` fragment tagged `"env"` with current working -/// directory, platform, and timestamp. Runs before every LLM call. -pub struct Env; - -impl Phase for Env { - fn clone_box(&self) -> Box<dyn Phase> { - Box::new(Self) - } - - fn name(&self) -> &str { - "env" - } - - fn decide( - &self, - _purpose: &Purpose, - ctx: &Context, - env: &Environment, - _resources: &Resources, - ) -> PhaseOutcome { - let now = Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, false); - let text = format!( - "cwd: {}\nplatform: {}\ntime: {}", - env.cwd.display(), - env.platform, - now, - ); - - if let Some(existing) = ctx - .fragments() - .iter() - .find(|f| f.role == Role::System && f.tag == "env") - { - if existing.as_text() == Some(&text) { - return PhaseOutcome::Done; - } - return PhaseOutcome::Action(Action::Replace { - id: existing.id(), - fragment: Fragment::system(text).with_tag("env"), - }); - } - - PhaseOutcome::Action(Action::Append(Fragment::system(text).with_tag("env"))) - } -} diff --git a/crates/accelerator/src/policy/phases/instruct.rs b/crates/accelerator/src/policy/phases/instruct.rs deleted file mode 100644 index 2795d134..00000000 --- a/crates/accelerator/src/policy/phases/instruct.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::collections::HashSet; -use std::path::{Path, PathBuf}; - -use machine::{ - Action, Context, Environment, Fragment, Phase, PhaseOutcome, Purpose, Resources, Role, -}; - -/// Discover and inject project-level instruction files into the context. -/// -/// Searches upward from the working directory for `AGENTS.md`, `CLAUDE.md`, -/// and `CONTEXT.md`, then checks global locations (`~/.synergy/config/AGENTS.md`, -/// `~/.claude/CLAUDE.md`). All found files are merged into a single `System` -/// fragment tagged `"instruction"`, placed after the system prompt and before -/// the user purpose. -/// -/// Runs once per session — the `"instruction"` tag acts as a guard. -pub struct Instructions; - -impl Phase for Instructions { - fn clone_box(&self) -> Box<dyn Phase> { - Box::new(Self) - } - - fn name(&self) -> &str { - "instructions" - } - - fn decide( - &self, - _purpose: &Purpose, - ctx: &Context, - _env: &Environment, - _resources: &Resources, - ) -> PhaseOutcome { - if ctx - .fragments() - .iter() - .any(|f| f.role == Role::System && f.tag == "instruction") - { - return PhaseOutcome::Done; - } - - let files = find_instruction_files(); - if files.is_empty() { - return PhaseOutcome::Done; - } - - let parts: Vec<String> = files - .iter() - .filter(|(_, content)| !content.trim().is_empty()) - .map(|(path, content)| { - let name = path - .file_name() - .unwrap_or(path.as_os_str()) - .to_string_lossy(); - format!( - "=== {name} (from {}) ===\n{}", - path.display(), - content.trim() - ) - }) - .collect(); - - if parts.is_empty() { - return PhaseOutcome::Done; - } - - PhaseOutcome::Action(Action::Append( - Fragment::system(parts.join("\n\n")).with_tag("instruction"), - )) - } -} - -const FILE_NAMES: [&str; 3] = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]; - -fn global_paths() -> Vec<PathBuf> { - let Some(home) = std::env::var("HOME").ok() else { - return Vec::new(); - }; - vec![ - Path::new(&home).join(".synergy/config/AGENTS.md"), - Path::new(&home).join(".claude/CLAUDE.md"), - ] -} - -/// Collect all instruction files from the project tree and global locations. -fn find_instruction_files() -> Vec<(PathBuf, String)> { - let mut seen = HashSet::new(); - let mut results = Vec::new(); - - if let Ok(cwd) = std::env::current_dir() { - let mut dir = Some(cwd); - while let Some(d) = dir { - if !seen.insert(d.clone()) { - break; - } - for name in &FILE_NAMES { - let path = d.join(name); - if path.is_file() - && let Ok(content) = std::fs::read_to_string(&path) - { - results.push((path, content)); - } - } - dir = d.parent().map(|p| p.to_path_buf()); - } - } - - for path in global_paths() { - if path.is_file() - && !results.iter().any(|(p, _)| *p == path) - && let Ok(content) = std::fs::read_to_string(&path) - { - results.push((path, content)); - } - } - - results -} diff --git a/crates/accelerator/src/policy/phases/mod.rs b/crates/accelerator/src/policy/phases/mod.rs deleted file mode 100644 index 57557afc..00000000 --- a/crates/accelerator/src/policy/phases/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod bootstrap; -mod env; -mod instruct; -mod purpose; - -pub use bootstrap::Bootstrap; -pub use env::Env; -pub use instruct::Instructions; -pub use purpose::Purpose; diff --git a/crates/accelerator/src/policy/phases/purpose.rs b/crates/accelerator/src/policy/phases/purpose.rs deleted file mode 100644 index ec6f55d4..00000000 --- a/crates/accelerator/src/policy/phases/purpose.rs +++ /dev/null @@ -1,41 +0,0 @@ -use machine::{Action, Context, Environment, Fragment, Phase, PhaseOutcome, Resources, Role}; - -/// Inject the user's purpose into the context. -/// -/// Appends a `User` fragment tagged `"purpose"` when the purpose is non-empty. -/// Runs once — the `"purpose"` tag acts as a guard. -pub struct Purpose; - -impl Phase for Purpose { - fn clone_box(&self) -> Box<dyn Phase> { - Box::new(Self) - } - - fn name(&self) -> &str { - "purpose" - } - - fn decide( - &self, - purpose: &machine::Purpose, - ctx: &Context, - _env: &Environment, - _resources: &Resources, - ) -> PhaseOutcome { - if purpose.is_empty() { - return PhaseOutcome::Done; - } - - if ctx - .fragments() - .iter() - .any(|f| f.role == Role::User && f.tag == "purpose") - { - return PhaseOutcome::Done; - } - - PhaseOutcome::Action(Action::Append( - Fragment::user(&purpose.text).with_tag("purpose"), - )) - } -} diff --git a/crates/accelerator/src/policy/retry.rs b/crates/accelerator/src/policy/retry.rs new file mode 100644 index 00000000..d34d4a11 --- /dev/null +++ b/crates/accelerator/src/policy/retry.rs @@ -0,0 +1,90 @@ +use std::sync::atomic::{AtomicU32, Ordering}; +use tokio::time::{Duration, sleep}; + +pub const DEFAULT_BACKOFF_INITIAL: Duration = Duration::from_secs(1); +pub const DEFAULT_BACKOFF_MAX: Duration = Duration::from_secs(60); +pub const DEFAULT_BACKOFF_MULTIPLIER: u64 = 2; +pub const DEFAULT_MAX_RETRIES: u32 = 3; + +pub const HTTP_UNAUTHORIZED: u16 = 401; +pub const HTTP_FORBIDDEN: u16 = 403; +pub const HTTP_RATE_LIMITED: u16 = 429; +pub const HTTP_SERVER_ERROR: u16 = 500; +pub const HTTP_BAD_GATEWAY: u16 = 502; +pub const HTTP_SERVICE_UNAVAILABLE: u16 = 503; +pub const HTTP_GATEWAY_TIMEOUT: u16 = 504; + +/// Retry budget with exponential backoff. +pub struct Retry { + attempts: AtomicU32, + backoff_initial: Duration, + backoff_max: Duration, + backoff_multiplier: u64, + max_retries: u32, +} + +impl Retry { + pub fn new( + backoff_initial: Duration, + backoff_max: Duration, + backoff_multiplier: u64, + max_retries: u32, + ) -> Self { + Self { + attempts: AtomicU32::new(0), + backoff_initial, + backoff_max, + backoff_multiplier, + max_retries, + } + } + + /// Returns `false` when the retry budget is exhausted. + pub async fn backoff(&self) -> bool { + let attempt = self.attempts.fetch_add(1, Ordering::Relaxed); + if attempt >= self.max_retries { + return false; + } + + let multiplier = self.backoff_multiplier.saturating_pow(attempt) as u32; + let delay = self + .backoff_initial + .checked_mul(multiplier) + .unwrap_or(self.backoff_max) + .min(self.backoff_max); + + sleep(delay).await; + true + } + + pub fn reset(&self) { + self.attempts.store(0, Ordering::Relaxed); + } + + pub fn count(&self) -> u32 { + self.attempts.load(Ordering::Relaxed) + } +} + +impl Default for Retry { + fn default() -> Self { + Self::new( + DEFAULT_BACKOFF_INITIAL, + DEFAULT_BACKOFF_MAX, + DEFAULT_BACKOFF_MULTIPLIER, + DEFAULT_MAX_RETRIES, + ) + } +} + +impl Clone for Retry { + fn clone(&self) -> Self { + Self { + attempts: AtomicU32::new(0), + backoff_initial: self.backoff_initial, + backoff_max: self.backoff_max, + backoff_multiplier: self.backoff_multiplier, + max_retries: self.max_retries, + } + } +} diff --git a/crates/accelerator/src/prompts/mod.rs b/crates/accelerator/src/prompts/mod.rs new file mode 100644 index 00000000..03b31bbd --- /dev/null +++ b/crates/accelerator/src/prompts/mod.rs @@ -0,0 +1,7 @@ +use crate::catalog::Catalog; + +pub fn register(catalog: &mut Catalog) { + catalog + .register_prompt("captain", include_str!("captain.txt")) + .expect("built-in prompt names must be unique"); +} diff --git a/crates/accelerator/src/state.rs b/crates/accelerator/src/state.rs deleted file mode 100644 index 32232e5e..00000000 --- a/crates/accelerator/src/state.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::path::PathBuf; - -use machine::{Context, Environment, Policy, Resources}; - -/// The runtime state of an agent — both input and output. -/// Fully cloneable: policy is cloned before being consumed by Machine. -#[derive(Clone)] -pub struct State { - pub purpose: String, - pub ctx: Context, - pub env: Environment, - pub policy: Box<dyn Policy>, - pub res: Resources, -} - -impl Default for State { - fn default() -> Self { - Self { - purpose: String::new(), - ctx: Context::new(), - env: local(), - policy: Box::new(crate::policy::Captain::new()), - res: kit(), - } - } -} - -pub fn local() -> Environment { - let mut env = Environment::named( - "local", - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - ); - env.root = Some(env.cwd.clone()); - env -} - -pub fn kit() -> Resources { - use crate::tools::builtin_tools; - - let mut resources = Resources::named("kit"); - - for tool in builtin_tools() { - let name = tool.name().to_string(); - resources = resources.with_tool(tool); - resources.enable(name); - } - - resources.prompts.insert( - "captain".to_string(), - include_str!("prompts/captain.txt").to_string(), - ); - - resources -} diff --git a/crates/accelerator/src/tools/arxiv.rs b/crates/accelerator/src/tools/arxiv.rs new file mode 100644 index 00000000..af2ff819 --- /dev/null +++ b/crates/accelerator/src/tools/arxiv.rs @@ -0,0 +1,383 @@ +use std::future::Future; +use std::path::Path; +use std::pin::Pin; +use std::time::Duration; + +use machine::{Environment, Tool, ToolResult}; +use serde::Deserialize; +use serde_json::Value; +use tracing::info; + +const SEARCH_TIMEOUT_SECS: u64 = 30; +const DOWNLOAD_TIMEOUT_SECS: u64 = 60; +const MAX_RESULTS: usize = 100; +const DEFAULT_RESULTS: usize = 10; +const ARXIV_API_BASE: &str = "https://arxivsearch.synergy.holosai.io"; +const ARXIV_PDF_BASE: &str = "https://arxiv.org/pdf"; + +// ── arxiv_search ── + +pub struct ArxivSearchTool; + +impl Tool for ArxivSearchTool { + fn name(&self) -> &str { + "arxiv_search" + } + + fn description(&self) -> &str { + "Search the arXiv database for academic papers using semantic search and filters.\n\n\ + Use this tool to find research papers on arXiv. You can search using:\n\ + - Natural language queries for semantic search\n\ + - Author names (OR logic between multiple authors)\n\ + - arXiv categories like 'cs.AI', 'hep-ph', 'math.AG' (OR logic)\n\ + - Date ranges (YYYY-MM-DD format)\n\ + - Title keywords (AND logic between keywords)\n\n\ + Returns paper metadata including title, authors, abstract, categories, and arXiv ID." + } + + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Natural language search query for semantic search" + }, + "authors": { + "type": "array", + "items": { "type": "string" }, + "description": "Filter by author names (OR logic)" + }, + "categories": { + "type": "array", + "items": { "type": "string" }, + "description": "Filter by arXiv categories like 'cs.AI', 'hep-ph' (OR logic)" + }, + "startDate": { + "type": "string", + "description": "Start date (YYYY-MM-DD, inclusive)" + }, + "endDate": { + "type": "string", + "description": "End date (YYYY-MM-DD, inclusive)" + }, + "titleKeywords": { + "type": "array", + "items": { "type": "string" }, + "description": "Keywords in title (AND logic)" + }, + "topK": { + "type": "integer", + "default": 10, + "description": "Number of results (1-100, default: 10)" + } + } + }) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(SEARCH_TIMEOUT_SECS) + } + + fn execute<'a>( + &'a self, + args: Value, + _env: &'a Environment, + ) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>> { + Box::pin(async move { execute_search(args).await }) + } +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Paper { + id: String, + title: String, + authors: Vec<String>, + categories: Vec<String>, + published_date: String, + summary: String, + pdf_url: String, + arxiv_url: String, + score: Option<f64>, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct SearchResponse { + papers: Vec<Paper>, + total: u32, + query: Option<String>, + mode: String, + reranked: bool, +} + +async fn execute_search(args: Value) -> Result<ToolResult, String> { + let top_k = args["topK"] + .as_u64() + .unwrap_or(DEFAULT_RESULTS as u64) + .min(MAX_RESULTS as u64) as usize; + + let body = serde_json::json!({ + "query": args["query"].as_str(), + "authors": args.get("authors").and_then(|v| v.as_array().cloned()), + "categories": args.get("categories").and_then(|v| v.as_array().cloned()), + "start_date": args["startDate"].as_str(), + "end_date": args["endDate"].as_str(), + "title_keywords": args.get("titleKeywords").and_then(|v| v.as_array().cloned()), + "top_k": top_k, + "mode": "hybrid", + "rerank": true, + "include_summary": true, + }); + + info!(target: "arxiv_search", %body, "searching arXiv via Holos API"); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(SEARCH_TIMEOUT_SECS)) + .build() + .map_err(|e| format!("failed to create HTTP client: {e}"))?; + + let response = client + .post(format!("{}/search", ARXIV_API_BASE)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| format!("arXiv search request failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(format!("arXiv search failed: HTTP {status} — {text}")); + } + + let data: SearchResponse = response + .json() + .await + .map_err(|e| format!("arXiv search response parse failed: {e}"))?; + + let papers = data.papers; + + if papers.is_empty() { + return Ok(ToolResult { + call_id: String::new(), + content: "No papers found matching your search criteria.".to_string(), + title: Some("No results found".to_string()), + }); + } + + info!( + target: "arxiv_search", + total = data.total, + shown = papers.len(), + "arXiv search complete" + ); + + let mut lines = vec![ + format!("Found {} papers (showing {}):", data.total, papers.len()), + String::new(), + "| # | arXiv ID | Title | Authors | Categories | Published |".into(), + "|---|----------|-------|---------|------------|-----------|".into(), + ]; + + for (i, paper) in papers.iter().enumerate() { + let title_safe = paper.title.replace('|', "\\|").replace('\n', " "); + let title_trim: String = title_safe.chars().take(60).collect(); + let authors = if paper.authors.len() > 3 { + format!("{} et al.", paper.authors[..3].join(", ")) + } else { + paper.authors.join(", ") + }; + let cats: Vec<&str> = paper + .categories + .iter() + .take(2) + .map(|s| s.as_str()) + .collect(); + let published = paper.published_date.chars().take(10).collect::<String>(); + lines.push(format!( + "| {} | {} | {} | {} | {} | {} |", + i + 1, + paper.id, + title_trim, + authors, + cats.join(", "), + published + )); + } + + lines.push(String::new()); + lines.push("**Paper Details:**".into()); + lines.push(String::new()); + + for paper in &papers { + lines.push(format!("### {}: {}", paper.id, paper.title)); + lines.push(format!("**Authors:** {}", paper.authors.join(", "))); + lines.push(format!("**Categories:** {}", paper.categories.join(", "))); + lines.push(format!("**Published:** {}", paper.published_date)); + lines.push(format!("**PDF:** {}", paper.pdf_url)); + lines.push(String::new()); + lines.push(format!("**Abstract:** {}", paper.summary)); + lines.push(String::new()); + lines.push("---".into()); + lines.push(String::new()); + } + + Ok(ToolResult { + call_id: String::new(), + content: lines.join("\n"), + title: Some(format!("{} papers found", papers.len())), + }) +} + +// ── arxiv_download ── + +pub struct ArxivDownloadTool; + +impl Tool for ArxivDownloadTool { + fn name(&self) -> &str { + "arxiv_download" + } + + fn description(&self) -> &str { + "Download an arXiv paper as a PDF file.\n\n\ + Use this tool to download a paper from arXiv given its ID. The paper will be saved as a PDF file to the specified path.\n\n\ + Examples of valid arXiv IDs:\n\ + - 2401.12345\n\ + - 2401.12345v1\n\ + - hep-th/9901001" + } + + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "arxivId": { + "type": "string", + "description": "The arXiv paper ID (e.g., '2401.12345' or '2401.12345v1')" + }, + "outputPath": { + "type": "string", + "description": "The output file path (must end with .pdf)" + }, + "overwrite": { + "type": "boolean", + "default": false, + "description": "Whether to overwrite if file exists" + } + }, + "required": ["arxivId", "outputPath"] + }) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(DOWNLOAD_TIMEOUT_SECS) + } + + fn execute<'a>( + &'a self, + args: Value, + env: &'a Environment, + ) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>> { + Box::pin(async move { execute_download(args, env).await }) + } +} + +async fn execute_download(args: Value, env: &Environment) -> Result<ToolResult, String> { + let id = args["arxivId"] + .as_str() + .ok_or("arxiv_download requires 'arxivId' parameter")?; + + let output_path_raw = args["outputPath"] + .as_str() + .ok_or("arxiv_download requires 'outputPath' parameter")?; + + if !output_path_raw.to_lowercase().ends_with(".pdf") { + return Err("Output path must end with .pdf".to_string()); + } + + let output_path = super::resolve_path(output_path_raw, env)?; + + if output_path.exists() && !args["overwrite"].as_bool().unwrap_or(false) { + return Ok(ToolResult { + call_id: String::new(), + content: format!( + "File already exists at {}. Set overwrite=true to replace it.", + output_path.display() + ), + title: Some("File exists".to_string()), + }); + } + + let url = format!("{}/{}.pdf", ARXIV_PDF_BASE, id); + + info!(target: "arxiv_download", id, url = %url, path = ?output_path, "downloading arXiv paper"); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS)) + .build() + .map_err(|e| format!("failed to create HTTP client: {e}"))?; + + let response = client + .get(&url) + .header("User-Agent", "RCM/0.1 (compatible; Synergy/1.0)") + .send() + .await + .map_err(|e| format!("arXiv download failed: {e}"))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to download paper: HTTP {}", + response.status() + )); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("arXiv download read failed: {e}"))?; + + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("failed to create download directory: {e}"))?; + } + + tokio::fs::write(&output_path, &bytes) + .await + .map_err(|e| format!("failed to write PDF: {e}"))?; + + let size_str = if bytes.len() > 1024 * 1024 { + format!("{:.2} MB", bytes.len() as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.1} KB", bytes.len() as f64 / 1024.0) + }; + + let relative = relative_path(&output_path, &env.cwd); + + info!( + target: "arxiv_download", + bytes = bytes.len(), + path = ?output_path, + "arXiv paper downloaded" + ); + + Ok(ToolResult { + call_id: String::new(), + content: format!("Successfully downloaded arXiv paper {id} to {relative} ({size_str})"), + title: Some(format!("Downloaded {id}")), + }) +} + +fn relative_path(path: &Path, cwd: &Path) -> String { + path.strip_prefix(cwd) + .map(|relative| { + if relative.as_os_str().is_empty() { + ".".to_string() + } else { + relative.display().to_string() + } + }) + .unwrap_or_else(|_| path.display().to_string()) +} diff --git a/crates/accelerator/src/tools/arxiv.txt b/crates/accelerator/src/tools/arxiv.txt new file mode 100644 index 00000000..302450ef --- /dev/null +++ b/crates/accelerator/src/tools/arxiv.txt @@ -0,0 +1,23 @@ +Search academic papers on arXiv or download a paper PDF. + +### search — Search arXiv papers + +Returns paper metadata: title, authors, categories, abstract, arXiv ID, links. + +Parameters: +- `query`: Natural language search query. Required. +- `authors`: Optional, comma-separated author names (OR logic). +- `categories`: Optional, comma-separated categories like `cs.AI,cs.LG` (OR logic). +- `startDate`: Optional start date (YYYY-MM-DD, inclusive). +- `endDate`: Optional end date (YYYY-MM-DD, inclusive). +- `titleKeywords`: Optional, comma-separated title keywords (AND logic). +- `topK`: Number of results (1-100, default 10). + +### download — Download an arXiv paper PDF + +Downloads the paper PDF to a local file and returns the file path. + +Parameters: +- `id`: arXiv paper ID, e.g. `2401.12345` or `hep-th/9901001`. Required. +- `downloadDir`: Directory to save into (default: project root). +- `overwrite`: Overwrite if exists (default: false). diff --git a/crates/accelerator/src/tools/find/ast.rs b/crates/accelerator/src/tools/find/ast.rs index 598a8e81..0cda78b8 100644 --- a/crates/accelerator/src/tools/find/ast.rs +++ b/crates/accelerator/src/tools/find/ast.rs @@ -33,13 +33,13 @@ fn find_sg_binary() -> Option<std::path::PathBuf> { continue; } // PATH lookup. - if let Ok(output) = std::process::Command::new("which").arg(name).output() { - if output.status.success() { - let found = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let found_path = std::path::PathBuf::from(&found); - if found_path.is_file() { - return Some(found_path); - } + if let Ok(output) = std::process::Command::new("which").arg(name).output() + && output.status.success() + { + let found = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let found_path = std::path::PathBuf::from(&found); + if found_path.is_file() { + return Some(found_path); } } } @@ -83,16 +83,19 @@ pub(crate) fn execute<'a>( .ok_or("missing required parameter 'lang' (ast mode requires a language)")?; let search_path = if let Some(path) = args["path"].as_str() { - resolve_path(path, &env.cwd) + resolve_path(path, env)? } else { env.cwd.clone() }; let context = args["context"].as_u64().unwrap_or(0); - let sg = find_sg_binary().ok_or( - "ast-grep CLI not found. Install it with: brew install ast-grep, or cargo install ast-grep --locked", - )?; + let sg = tokio::task::spawn_blocking(find_sg_binary) + .await + .map_err(|_| "ast-grep binary discovery failed unexpectedly".to_string())? + .ok_or( + "ast-grep CLI not found. Install it with: brew install ast-grep, or cargo install ast-grep --locked", + )?; let mut cmd = tokio::process::Command::new(&sg); cmd.args(["run", "-p", pattern, "--lang", lang, "--json=compact"]); @@ -117,9 +120,7 @@ pub(crate) fn execute<'a>( if stdout.trim().is_empty() { return Ok(ToolResult { call_id: String::new(), - content: format!( - "No matches found.\n\nCheck:\n - Pattern must be a complete AST node.\n - Python: no trailing colons (use `def foo($$$)` not `def foo($$$):`).\n - Functions: include params and body (use `function $N($$$) {{ $$$ }}` not `function $N`)." - ), + content: "No matches found.\n\nCheck:\n - Pattern must be a complete AST node.\n - Python: no trailing colons (use `def foo($$$)` not `def foo($$$):`).\n - Functions: include params and body (use `function $N($$$) { $$$ }` not `function $N`).".to_string(), title: Some(format!("ast {lang}: {pattern}")), }); } diff --git a/crates/accelerator/src/tools/find/files.rs b/crates/accelerator/src/tools/find/files.rs index 90d27024..f78327f1 100644 --- a/crates/accelerator/src/tools/find/files.rs +++ b/crates/accelerator/src/tools/find/files.rs @@ -23,7 +23,7 @@ pub(crate) fn execute<'a>( .ok_or("missing required parameter 'pattern'")?; let search_path = if let Some(path) = args["path"].as_str() { - resolve_path(path, &env.cwd) + resolve_path(path, env)? } else { env.cwd.clone() }; @@ -37,7 +37,7 @@ pub(crate) fn execute<'a>( let walker = WalkBuilder::new(&search_path) .hidden(!show_hidden) - .follow_links(true) + .follow_links(false) .git_ignore(true) .git_global(true) .git_exclude(true) diff --git a/crates/accelerator/src/tools/find/text.rs b/crates/accelerator/src/tools/find/text.rs index 5c53fbef..36b09ca6 100644 --- a/crates/accelerator/src/tools/find/text.rs +++ b/crates/accelerator/src/tools/find/text.rs @@ -31,7 +31,7 @@ pub(crate) fn execute<'a>( .ok_or("missing required parameter 'pattern'")?; let search_path = if let Some(path) = args["path"].as_str() { - resolve_path(path, &env.cwd) + resolve_path(path, env)? } else { env.cwd.clone() }; @@ -43,7 +43,7 @@ pub(crate) fn execute<'a>( let walker = WalkBuilder::new(&search_path) .hidden(!show_hidden) - .follow_links(true) + .follow_links(false) .git_ignore(true) .git_global(true) .git_exclude(true) diff --git a/crates/accelerator/src/tools/fs/edit.rs b/crates/accelerator/src/tools/fs/edit.rs index 6914eecc..f036ab28 100644 --- a/crates/accelerator/src/tools/fs/edit.rs +++ b/crates/accelerator/src/tools/fs/edit.rs @@ -293,12 +293,12 @@ fn block_anchor_replacer(content: &str, find: &str) -> Vec<String> { // Collect candidates where both anchors match in content. // The end anchor is searched after the start anchor + 2 lines. let mut candidates: Vec<(usize, usize)> = Vec::new(); - for i in 0..content_lines.len() { - if content_lines[i].trim() != first { + for (i, line_i) in content_lines.iter().enumerate() { + if line_i.trim() != first { continue; } - for j in (i + 2)..content_lines.len() { - if content_lines[j].trim() == last { + for (j, line_j) in content_lines.iter().enumerate().skip(i + 2) { + if line_j.trim() == last { candidates.push((i, j)); break; // first occurrence of last anchor after this start } @@ -405,11 +405,11 @@ fn whitespace_normalized_replacer(content: &str, find: &str) -> Vec<String> { /// than the actual file has. fn indentation_flexible_replacer(content: &str, find: &str) -> Vec<String> { let find_lines: Vec<&str> = find.lines().collect(); - if find_lines.is_empty() { + let content_lines: Vec<&str> = content.lines().collect(); + if find_lines.is_empty() || find_lines.len() > content_lines.len() { return vec![]; } let dedented_find = strip_common_indent(&find_lines); - let content_lines: Vec<&str> = content.lines().collect(); let mut results = Vec::new(); for start in 0..=content_lines.len().saturating_sub(find_lines.len()) { @@ -568,7 +568,8 @@ fn similar_lines_hint(content: &str, find: &str) -> String { let content_lines: Vec<&str> = content.lines().collect(); let find_lines: Vec<&str> = find.lines().collect(); - if content_lines.len() > 5000 || find_lines.is_empty() { + if content_lines.len() > 5000 || find_lines.is_empty() || find_lines.len() > content_lines.len() + { return "No match found.".to_string(); } @@ -595,14 +596,12 @@ fn similar_lines_hint(content: &str, find: &str) -> String { let display_end = (best_start + find_lines.len() + context_after).min(content_lines.len()); let mut result = String::from("No match found. Similar lines:\n\n"); - for i in display_start..display_end { + for (i, line) in content_lines[display_start..display_end].iter().enumerate() { + let i = display_start + i; let line_num = i + 1; // 1-based let in_match = i >= best_start && i < best_start + find_lines.len(); let marker = if in_match { ">" } else { " " }; - result.push_str(&format!( - "{marker} {:05} | {}\n", - line_num, content_lines[i] - )); + result.push_str(&format!("{marker} {:05} | {line}\n", line_num)); } result } @@ -688,7 +687,7 @@ pub(crate) fn execute<'a>( return Err("oldString and newString must differ".to_string()); } - let resolved = resolve_path(file_path_str, &env.cwd); + let resolved = resolve_path(file_path_str, env)?; // Guard: existing files must have been read first. // (old_string.is_empty() creates a new file, skip guard.) diff --git a/crates/accelerator/src/tools/fs/list.rs b/crates/accelerator/src/tools/fs/list.rs index 8698a785..15681582 100644 --- a/crates/accelerator/src/tools/fs/list.rs +++ b/crates/accelerator/src/tools/fs/list.rs @@ -33,7 +33,7 @@ pub(crate) fn execute<'a>( .as_str() .ok_or("missing required parameter 'filePath'")?; - let resolved = resolve_path(file_path, &env.cwd); + let resolved = resolve_path(file_path, env)?; let metadata = tokio::fs::metadata(&resolved) .await diff --git a/crates/accelerator/src/tools/fs/mod.rs b/crates/accelerator/src/tools/fs/mod.rs index b05c7b2f..fa824dac 100644 --- a/crates/accelerator/src/tools/fs/mod.rs +++ b/crates/accelerator/src/tools/fs/mod.rs @@ -7,8 +7,8 @@ mod edit; mod guard; mod list; +mod pdf; mod read; -mod stat; mod write; use std::pin::Pin; @@ -61,8 +61,8 @@ impl Tool for FsTool { "properties": { "action": { "type": "string", - "enum": ["read", "write", "edit", "list", "stat"], - "description": "Which filesystem operation to perform: read (read a file with line numbers), write (overwrite or create a file), edit (targeted string replacement), list (list directory contents), stat (file metadata)." + "enum": ["read", "write", "edit", "list"], + "description": "Which filesystem operation to perform: read (read a file with line numbers and metadata), write (overwrite or create a file), edit (targeted string replacement), list (list directory contents)." }, "filePath": { "type": "string", @@ -128,8 +128,9 @@ impl Tool for FsTool { "write" => write::execute(&args, env).await, "edit" => edit::execute(&args, env).await, "list" => list::execute(&args, env).await, - "stat" => stat::execute(&args, env).await, - other => Err(format!("unknown action '{other}'")), + other => Err(format!( + "unknown action '{other}'. Valid actions: read, write, edit, list" + )), } }) } diff --git a/crates/accelerator/src/tools/fs/mod.txt b/crates/accelerator/src/tools/fs/mod.txt index 512b6f55..c80f7119 100644 --- a/crates/accelerator/src/tools/fs/mod.txt +++ b/crates/accelerator/src/tools/fs/mod.txt @@ -8,7 +8,7 @@ Usage: - The filePath parameter should be absolute whenever possible; relative paths are resolved against the current working directory. - By default, reads up to {DEFAULT_READ_LIMIT} lines starting from the beginning. For large files, use `offset` and `limit` to read specific sections. - Lines longer than {MAX_LINE_LENGTH} characters are truncated with "...". -- Results are returned with line numbers in `00001| content` format. +- Results include file metadata (type, size, modification time) followed by line-numbered content in `00001| content` format. - Use `offset` (0-based) to skip lines, and `limit` to control how many lines are returned. - Output is capped at {OUTPUT_CAP_KB} KB; use offset+limit to narrow the range when reading very large files. @@ -45,8 +45,4 @@ Usage: - `maxDepth` controls how deep to traverse. Default: 4. - `perDirectoryLimit` prevents one large directory from consuming the entire output. When a directory has more entries, the output shows an omitted-count line for that directory. - Output uses 📁 for directories and 📄 for files, indented by depth. -- For codebase exploration, use list for a quick structural overview and `find` for targeted file/text/AST search. - -### Stat - -Returns metadata for a file or directory: path relative to working directory, type (file/directory/symlink), size in human-readable and raw bytes, and last modification time in UTC. \ No newline at end of file +- For codebase exploration, use list for a quick structural overview and `find` for targeted file/text/AST search. \ No newline at end of file diff --git a/crates/accelerator/src/tools/fs/pdf.rs b/crates/accelerator/src/tools/fs/pdf.rs new file mode 100644 index 00000000..1674429f --- /dev/null +++ b/crates/accelerator/src/tools/fs/pdf.rs @@ -0,0 +1,145 @@ +//! Safe PDF text extraction for the `fs` read tool. +//! +//! PDF parsing needs an external tool (`pdftotext`, from poppler-utils). We wrap +//! it safely rather than exposing the raw `shell` tool to the model: the binary +//! is invoked with parameterized arguments via `Command` (never through a shell), +//! so nothing in the path is ever interpreted. As defense in depth we also reject +//! any path containing a shell metacharacter, require a `.pdf` extension, and +//! bound both runtime and output size. + +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use tokio::process::Command; + +/// How long `pdftotext` may run before we give up. +const PDFTOTEXT_TIMEOUT: Duration = Duration::from_secs(60); + +/// Cap on extracted text, to keep tool output bounded. +const MAX_PDF_TEXT_BYTES: usize = 500_000; + +/// Extract text from a local PDF using `pdftotext`. The path must already be a +/// resolved filesystem path (the `fs` tool confines it to the workspace). +pub(crate) async fn extract_pdf_text(path: &Path) -> Result<String, String> { + let path_str = path.to_str().ok_or("PDF path is not valid UTF-8")?; + if let Some(bad) = path_str.chars().find(|c| is_shell_metacharacter(*c)) { + return Err(format!( + "refusing to extract: PDF path contains an unsafe character {bad:?}" + )); + } + if !path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("pdf")) + { + return Err("extract_pdf_text expects a .pdf path".to_string()); + } + + let child = Command::new("pdftotext") + .arg("-q") // quiet — no progress noise on stderr + .arg(path) // input path — parameterized, never shell-interpolated + .arg("-") // write extracted text to stdout + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|error| match error.kind() { + std::io::ErrorKind::NotFound => { + "pdftotext is not installed (install poppler-utils to read PDFs)".to_string() + } + _ => format!("failed to launch pdftotext: {error}"), + })?; + + let output = match tokio::time::timeout(PDFTOTEXT_TIMEOUT, child.wait_with_output()).await { + Err(_) => return Err("pdftotext timed out".to_string()), + Ok(Err(error)) => return Err(format!("pdftotext failed: {error}")), + Ok(Ok(output)) => output, + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "pdftotext exited unsuccessfully: {}", + stderr.trim() + )); + } + + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + if text.trim().is_empty() { + return Err( + "pdftotext produced no text (the PDF may be scanned or image-only)".to_string(), + ); + } + if text.len() > MAX_PDF_TEXT_BYTES { + text.truncate(MAX_PDF_TEXT_BYTES); + text.push_str("\n\n[... truncated: extracted PDF text exceeded the size cap ...]"); + } + Ok(text) +} + +/// Shell-significant characters refused in a PDF path. The path is never passed +/// through a shell, so this is purely defense in depth; it still allows ordinary +/// path characters (`/`, `.`, `-`, `_`, `:`, spaces, alphanumerics). +fn is_shell_metacharacter(c: char) -> bool { + matches!( + c, + ';' | '|' + | '&' + | '$' + | '`' + | '(' + | ')' + | '<' + | '>' + | '*' + | '?' + | '{' + | '}' + | '[' + | ']' + | '!' + | '\\' + | '"' + | '\'' + | '\n' + | '\r' + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_shell_metacharacters() { + for bad in [ + "/tmp/a;rm -rf b.pdf", + "/tmp/$(whoami).pdf", + "/tmp/a`b`.pdf", + "/tmp/a|b.pdf", + "/tmp/a>b.pdf", + "/tmp/a*.pdf", + ] { + assert!( + bad.chars().any(is_shell_metacharacter), + "{bad} should be flagged as unsafe" + ); + } + } + + #[test] + fn allows_ordinary_pdf_paths() { + for ok in [ + "examples/autoresearch-survey/runs/20260603T123058Z/pdfs/2401.12345.pdf", + "/Users/Jane Doe/RCM/runs/pdfs/hep-th_9901001.pdf", + "runs/pdfs/2210.03310.pdf", + ] { + assert!( + !ok.chars().any(is_shell_metacharacter), + "{ok} should be allowed" + ); + } + } +} diff --git a/crates/accelerator/src/tools/fs/read.rs b/crates/accelerator/src/tools/fs/read.rs index 08d495ce..9ed46247 100644 --- a/crates/accelerator/src/tools/fs/read.rs +++ b/crates/accelerator/src/tools/fs/read.rs @@ -8,6 +8,8 @@ use super::{ DEFAULT_READ_LIMIT, MAX_LINE_LENGTH, OUTPUT_CAP_BYTES, guard, relative_path, resolve_path, }; +use chrono::{TimeZone, Utc}; + /// File extensions known to be binary — no content is readable. const BINARY_EXTENSIONS: &[&str] = &[ "zip", "tar", "gz", "exe", "dll", "so", "class", "jar", "war", "7z", "doc", "xls", "ppt", @@ -29,7 +31,7 @@ pub(crate) fn execute<'a>( .as_str() .ok_or("missing required parameter 'filePath'")?; - let resolved = resolve_path(raw_path, &env.cwd); + let resolved = resolve_path(raw_path, env)?; // Stat the file (handles not-found with suggestions). let metadata = match tokio::fs::metadata(&resolved).await { @@ -52,6 +54,23 @@ pub(crate) fn execute<'a>( if BINARY_EXTENSIONS.contains(&lower.as_str()) { return Err(format!("Cannot read binary file: {}", resolved.display())); } + if lower == "pdf" { + // PDFs are read by extracting text with a safe pdftotext wrapper + // (no shell is exposed to the model). + let text = super::pdf::extract_pdf_text(&resolved).await?; + let offset = args["offset"].as_u64().unwrap_or(0) as usize; + let limit = args["limit"].as_u64().unwrap_or(DEFAULT_READ_LIMIT as u64) as usize; + let path_str = relative_path(&resolved, &env.cwd); + let mut output = stat_header(&metadata, &path_str); + output.push('\n'); + output.push_str(&format_lines(&text, offset, limit, &path_str)); + guard::mark_read(env.name.as_str(), &resolved); + return Ok(ToolResult { + call_id: String::new(), + content: output, + title: Some(path_str), + }); + } if DOCUMENT_EXTENSIONS.contains(&lower.as_str()) { return Err(format!( "Cannot read document file: {}. {} files require external extraction.", @@ -80,7 +99,10 @@ pub(crate) fn execute<'a>( let limit = args["limit"].as_u64().unwrap_or(DEFAULT_READ_LIMIT as u64) as usize; let path_str = relative_path(&resolved, &env.cwd); - let output = format_lines(&text, offset, limit, &path_str); + let mut output = stat_header(&metadata, &path_str); + output.push('\n'); + let content = format_lines(&text, offset, limit, &path_str); + output.push_str(&content); guard::mark_read(env.name.as_str(), &resolved); let lsp_env = env.clone(); @@ -158,6 +180,39 @@ async fn build_not_found_message(resolved: &Path) -> String { message } +/// Build the stat header for a file. +fn stat_header(metadata: &std::fs::Metadata, title: &str) -> String { + let file_type = if metadata.is_dir() { + "directory" + } else if metadata.is_symlink() { + "symlink" + } else { + "file" + }; + let bytes = metadata.len(); + let size = if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + }; + let modified = metadata.modified().ok().map(|t| { + let dur = t.duration_since(std::time::UNIX_EPOCH).ok(); + dur.and_then(|d| { + Utc.timestamp_opt(d.as_secs() as i64, d.subsec_nanos()) + .single() + }) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| "unknown".to_string()) + }); + let modified = modified.as_deref().unwrap_or("unknown"); + + format!( + "Path: {title}\nType: {file_type}\nSize: {size} ({bytes} bytes)\nModified: {modified}\n\n" + ) +} + /// Build the `<file>`-wrapped line-numbered output. fn format_lines(text: &str, offset: usize, limit: usize, title: &str) -> String { let lines: Vec<&str> = text.lines().collect(); diff --git a/crates/accelerator/src/tools/fs/stat.rs b/crates/accelerator/src/tools/fs/stat.rs deleted file mode 100644 index 2319b5ef..00000000 --- a/crates/accelerator/src/tools/fs/stat.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::pin::Pin; - -use chrono::{TimeZone, Utc}; -use machine::{Environment, ToolResult}; -use serde_json::Value; - -use super::{relative_path, resolve_path}; - -pub(crate) fn execute<'a>( - args: &'a Value, - env: &'a Environment, -) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>> { - Box::pin(async move { - let file_path = args["filePath"] - .as_str() - .ok_or("missing required parameter 'filePath'")?; - - let resolved = resolve_path(file_path, &env.cwd); - - let metadata = tokio::fs::metadata(&resolved).await.map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - format!("File not found: {}", resolved.display()) - } else { - format!( - "failed to read metadata for {}: {error}", - resolved.display() - ) - } - })?; - - let relative = relative_path(&resolved, &env.cwd); - - let file_type = if metadata.is_dir() { - "directory" - } else if metadata.is_symlink() { - "symlink" - } else { - "file" - }; - - let size_human = human_size(metadata.len()); - let raw_bytes = metadata.len(); - - let modified_str = format_modified(metadata.modified().ok()); - - let content = format!( - "Path: {}\nType: {}\nSize: {} ({} bytes)\nModified: {modified_str}", - relative, file_type, size_human, raw_bytes, - ); - - Ok(ToolResult { - call_id: String::new(), - content, - title: Some(format!("stat {relative}")), - }) - }) -} - -fn human_size(bytes: u64) -> String { - const UNITS: &[&str] = &["bytes", "KB", "MB", "GB"]; - let mut size = bytes as f64; - let mut unit_index = 0; - - while size >= 1024.0 && unit_index < UNITS.len() - 1 { - size /= 1024.0; - unit_index += 1; - } - - format!("{size:.1} {}", UNITS[unit_index]) -} - -fn format_modified(modified: Option<std::time::SystemTime>) -> String { - let sys_time = match modified { - Some(st) => st, - None => return "unknown".to_string(), - }; - - let duration = match sys_time.duration_since(std::time::UNIX_EPOCH) { - Ok(dur) => dur, - Err(_) => return "unknown".to_string(), - }; - - match Utc.timestamp_opt(duration.as_secs() as i64, duration.subsec_nanos()) { - chrono::LocalResult::Single(dt) => dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(), - _ => "unknown".to_string(), - } -} diff --git a/crates/accelerator/src/tools/fs/write.rs b/crates/accelerator/src/tools/fs/write.rs index 6b639c54..ae53b2f7 100644 --- a/crates/accelerator/src/tools/fs/write.rs +++ b/crates/accelerator/src/tools/fs/write.rs @@ -18,7 +18,7 @@ pub(crate) fn execute<'a>( .as_str() .ok_or("missing required parameter 'content'")?; - let path = resolve_path(file_path, &env.cwd); + let path = resolve_path(file_path, env)?; let relative = relative_path(&path, &env.cwd); let _lock = guard::acquire_write_lock(&path).await; diff --git a/crates/accelerator/src/tools/git.rs b/crates/accelerator/src/tools/git.rs new file mode 100644 index 00000000..3230f135 --- /dev/null +++ b/crates/accelerator/src/tools/git.rs @@ -0,0 +1,358 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::LazyLock; +use std::time::Duration; + +use machine::{Environment, Tool, ToolResult}; +use serde_json::Value; +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +const MAX_TIMEOUT_SECS: u64 = 120; +const DEFAULT_TIMEOUT_SECS: u64 = 60; +const OUTPUT_CAP_BYTES: usize = 256 * 1024; + +const PROTECTED_BRANCHES: &[&str] = &["main", "master"]; + +static GIT_DESC: LazyLock<String> = LazyLock::new(|| { + include_str!("git.txt") + .replace("{MAX_TIMEOUT_SECS}", &MAX_TIMEOUT_SECS.to_string()) + .replace("{DEFAULT_TIMEOUT_SECS}", &DEFAULT_TIMEOUT_SECS.to_string()) +}); + +pub struct GitTool; + +impl Tool for GitTool { + fn name(&self) -> &str { + "git" + } + + fn description(&self) -> &str { + &GIT_DESC + } + + fn parameters(&self) -> Value { + let mut props = serde_json::Map::new(); + props.insert( + "command".into(), + serde_json::json!({ + "type": "string", + "description": "Git subcommand and arguments (without the leading 'git'). Example: \"commit -m 'fix: handle empty input'\"" + }), + ); + props.insert( + "timeout_secs".into(), + serde_json::json!({ + "type": "integer", + "description": format!( + "Per-call timeout in seconds (default {}, max {}).", + DEFAULT_TIMEOUT_SECS, MAX_TIMEOUT_SECS + ) + }), + ); + serde_json::json!({ + "type": "object", + "properties": props, + "required": ["command"] + }) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(MAX_TIMEOUT_SECS) + } + + fn execute<'a>( + &'a self, + args: Value, + env: &'a Environment, + ) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>> { + Box::pin(async move { + let raw = args["command"] + .as_str() + .ok_or("missing required parameter 'command'")? + .to_string(); + + let tokens = match tokenize(&raw) { + Ok(tokens) => tokens, + Err(reason) => return Ok(deny_result(&raw, &reason)), + }; + + if let Err(reason) = check_safety(&tokens) { + return Ok(deny_result(&raw, &reason)); + } + + let per_call_timeout = args["timeout_secs"] + .as_u64() + .unwrap_or(DEFAULT_TIMEOUT_SECS) + .min(MAX_TIMEOUT_SECS); + + let mut child = Command::new("git") + .args(&tokens) + .current_dir(&env.cwd) + .env_clear() + .envs(&env.vars) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|spawn_err| format!("failed to spawn git: {spawn_err}"))?; + + match tokio::time::timeout( + Duration::from_secs(per_call_timeout), + collect_output(&mut child), + ) + .await + { + Ok((stdout, stderr, exit_code)) => build_result(&raw, stdout, stderr, exit_code), + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + Ok(ToolResult { + call_id: String::new(), + content: format!( + "[timeout] git command killed after {per_call_timeout}s:\n$ git {raw}" + ), + title: Some("timeout".to_string()), + }) + } + } + }) + } +} + +pub fn tokenize(input: &str) -> Result<Vec<String>, String> { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut in_double = false; + let mut in_single = false; + let mut chars = input.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '"' if !in_single => in_double = !in_double, + '\'' if !in_double => in_single = !in_single, + ' ' | '\t' | '\n' if !in_double && !in_single => { + if !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + } + '\\' if !in_single => { + if let Some(next) = chars.next() { + current.push(next); + } + } + other => current.push(other), + } + } + + if in_double { + return Err("unbalanced double quote".into()); + } + if in_single { + return Err("unbalanced single quote".into()); + } + if !current.is_empty() { + tokens.push(current); + } + Ok(tokens) +} + +pub fn check_safety(tokens: &[String]) -> Result<(), String> { + if tokens.is_empty() { + return Err("empty command".into()); + } + + let subcommand = tokens[0].as_str(); + let rest: Vec<&str> = tokens[1..].iter().map(String::as_str).collect(); + + match subcommand { + "push" => check_push(&rest), + "reset" => check_reset(&rest), + "checkout" => check_checkout(&rest), + "switch" => check_switch(&rest), + "restore" => check_restore(&rest), + "branch" => check_branch(&rest), + "stash" => check_stash(&rest), + "clean" => check_clean(&rest), + "commit" | "rebase" | "merge" | "cherry-pick" | "revert" => check_hook_skip(&rest), + "config" => { + Err("git config changes are denied; do not modify repo or global config".into()) + } + _ => Ok(()), + } +} + +fn check_push(args: &[&str]) -> Result<(), String> { + for arg in args { + if *arg == "-f" || *arg == "--force" || arg.starts_with("--force-with-lease") { + return Err("force push is denied".into()); + } + } + + for arg in args.iter().filter(|arg| !arg.starts_with('-')) { + if let Some(branch) = arg.strip_prefix(':') + && PROTECTED_BRANCHES.contains(&branch) + { + return Err(format!("deleting remote branch '{branch}' is denied")); + } + let target = arg.split(':').next_back().unwrap_or(arg); + let target = target.trim_start_matches('+'); + if PROTECTED_BRANCHES.contains(&target) { + return Err(format!("push to protected branch '{target}' is denied")); + } + } + + Ok(()) +} + +fn check_reset(args: &[&str]) -> Result<(), String> { + if args.contains(&"--hard") { + return Err("reset --hard discards working tree changes; denied".into()); + } + Ok(()) +} + +fn check_checkout(args: &[&str]) -> Result<(), String> { + if args.contains(&"-f") || args.contains(&"--force") { + return Err("force checkout discards local changes; denied".into()); + } + if args.contains(&"--") { + return Err("checkout -- <path> discards working-tree changes; denied".into()); + } + if args.iter().any(|arg| PROTECTED_BRANCHES.contains(arg)) { + return Err("checkout to a protected default branch is denied".into()); + } + if args.first().map(|arg| *arg == ".").unwrap_or(false) { + return Err("checkout . discards all working-tree changes; denied".into()); + } + Ok(()) +} + +fn check_switch(args: &[&str]) -> Result<(), String> { + if args.contains(&"-f") || args.contains(&"--force") || args.contains(&"--discard-changes") { + return Err("force switch discards local changes; denied".into()); + } + if args.iter().any(|arg| PROTECTED_BRANCHES.contains(arg)) { + return Err("switch to a protected default branch is denied".into()); + } + Ok(()) +} + +fn check_restore(args: &[&str]) -> Result<(), String> { + let touches_worktree = args.contains(&"--worktree") + || args.contains(&"-W") + || !(args.contains(&"--staged") || args.contains(&"-S")); + if touches_worktree { + return Err("git restore against the working tree discards changes; denied".into()); + } + Ok(()) +} + +fn check_branch(args: &[&str]) -> Result<(), String> { + if args.contains(&"-D") || args.contains(&"--delete") || args.contains(&"-d") { + return Err("branch deletion is denied".into()); + } + Ok(()) +} + +fn check_stash(args: &[&str]) -> Result<(), String> { + if let Some(first) = args.first() + && (*first == "drop" || *first == "clear") + { + return Err(format!("stash {first} discards stashed work; denied")); + } + Ok(()) +} + +fn check_clean(args: &[&str]) -> Result<(), String> { + let dangerous = args.iter().any(|arg| { + if arg.starts_with("--") { + *arg == "--force" + } else if let Some(flags) = arg.strip_prefix('-') { + flags.contains('f') + } else { + false + } + }); + if dangerous { + return Err("git clean -f deletes untracked files; denied".into()); + } + Ok(()) +} + +fn check_hook_skip(args: &[&str]) -> Result<(), String> { + if args.contains(&"--no-verify") || args.contains(&"--no-gpg-sign") { + return Err("--no-verify / --no-gpg-sign skips required hooks; denied".into()); + } + Ok(()) +} + +fn deny_result(raw: &str, reason: &str) -> ToolResult { + ToolResult { + call_id: String::new(), + content: format!("[denied] {reason}\n\nattempted: git {raw}"), + title: Some(format!("denied: {reason}")), + } +} + +async fn collect_output(child: &mut tokio::process::Child) -> (Vec<u8>, Vec<u8>, Option<i32>) { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + if let Some(out) = child.stdout.take() { + let mut reader = tokio::io::BufReader::new(out); + let _ = reader.read_to_end(&mut stdout).await; + } + if let Some(err) = child.stderr.take() { + let mut reader = tokio::io::BufReader::new(err); + let _ = reader.read_to_end(&mut stderr).await; + } + + let exit_code = child.wait().await.ok().and_then(|status| status.code()); + (stdout, stderr, exit_code) +} + +fn build_result( + raw: &str, + stdout: Vec<u8>, + stderr: Vec<u8>, + exit_code: Option<i32>, +) -> Result<ToolResult, String> { + let stdout = String::from_utf8_lossy(&stdout).to_string(); + let stderr = String::from_utf8_lossy(&stderr).to_string(); + let code = exit_code.unwrap_or(-1); + + let mut output = String::with_capacity(stdout.len() + stderr.len() + 64); + output.push_str(&stdout); + if !stderr.is_empty() { + if !output.is_empty() && !output.ends_with('\n') { + output.push('\n'); + } + output.push_str(&stderr); + } + + let truncated = output.len() > OUTPUT_CAP_BYTES; + if truncated { + output.truncate(OUTPUT_CAP_BYTES); + output.push_str(&format!( + "\n\n[output truncated at {} KB]", + OUTPUT_CAP_BYTES / 1024 + )); + } + + let title = if code == 0 { + let short = if raw.len() > 60 { + format!("{}…", &raw[..60]) + } else { + raw.to_string() + }; + format!("✓ git {short}") + } else { + format!("exit {code}") + }; + + Ok(ToolResult { + call_id: String::new(), + content: output, + title: Some(title), + }) +} diff --git a/crates/accelerator/src/tools/git.txt b/crates/accelerator/src/tools/git.txt new file mode 100644 index 00000000..47e473fd --- /dev/null +++ b/crates/accelerator/src/tools/git.txt @@ -0,0 +1,34 @@ +# git + +Run a git command. Output is returned to you. Default timeout {DEFAULT_TIMEOUT_SECS}s, max {MAX_TIMEOUT_SECS}s. + +## Common operations you can use + +- `status` / `log -n 10` / `diff` / `diff --staged` / `show <ref>` — inspection +- `branch <name>` / `switch -c feature/<name>` — create branches +- `add <path>` / `add -A` — stage changes +- `commit -m "<msg>"` — commit (HEREDOC for multi-line: pass the full -m argument inline) +- `push origin <feature-branch>` — push your feature branch +- `fetch origin` / `pull --rebase` — sync with remote + +## Denied operations (these will return `[denied]` and not execute) + +- `push --force` / `push -f` / `push --force-with-lease` — never force-push +- `push <remote> main` / `push <remote> master` — never push directly to default branches +- `reset --hard` — destroys working tree changes +- `checkout main` / `switch main` / `checkout master` / `switch master` — never switch to default branch +- `checkout -f` / `checkout --force` — discards local changes +- `checkout -- <path>` / `checkout .` / `restore <path>` — discards working-tree changes +- `branch -D` / `branch --delete` — branch deletion +- `stash drop` / `stash clear` — discards stashed work +- `clean -f` / `clean -fd` — deletes untracked files +- `commit --no-verify` / `commit --no-gpg-sign` / same on rebase/merge — never skip hooks +- `config <anything>` — never modify repo or global git config + +## When you need a denied operation + +You don't. If a denied operation seems necessary, the situation is exceptional and a human maintainer must handle it. Stop, leave a comment on the issue/PR explaining what you needed, and exit. + +## Quoting + +Tokens are split on whitespace with basic quote handling. Wrap commit messages and paths with spaces in double quotes: `commit -m "fix: handle empty input"`. diff --git a/crates/accelerator/src/tools/image_gen.rs b/crates/accelerator/src/tools/image_gen.rs new file mode 100644 index 00000000..d2481f79 --- /dev/null +++ b/crates/accelerator/src/tools/image_gen.rs @@ -0,0 +1,748 @@ +//! Image generation tool — text-to-image via the OpenAI images API. +//! +//! Generates an image from a prompt and writes it to disk, returning the +//! saved path. Used by pipelines that need a generated figure (e.g. a survey's +//! opening "global picture") as a real artifact rather than inline data. + +use std::future::Future; +use std::net::{IpAddr, SocketAddr}; +use std::pin::Pin; +use std::time::Duration; + +use base64::Engine; +use machine::{DEFAULT_TOOL_TIMEOUT_SECS, Environment, Tool, ToolResult}; +use reqwest::{Response, StatusCode, redirect::Policy}; +use serde_json::Value; +use tracing::info; +use url::Url; + +use super::{relative_path, resolve_path}; + +const API_URL: &str = "https://llm.yuzhu.tech/v1/images/generations"; +const DEFAULT_MODEL: &str = "gpt-image-2"; +const API_KEY_ENV: &str = "IMAGE_GEN_API_KEY"; +const API_URL_ENV: &str = "IMAGE_GEN_API_URL"; +const TRUSTED_HOSTS_ENV: &str = "IMAGE_GEN_TRUSTED_HOSTS"; +pub const IMAGE_GEN_DIAGNOSTIC_ENV: &str = "IMAGE_GEN_DIAGNOSTIC"; +const IMAGE_MAX_BYTES: usize = 20 * 1024 * 1024; +const JSON_MAX_BYTES: usize = (IMAGE_MAX_BYTES * 4 / 3) + (1024 * 1024); +const ERROR_BODY_MAX_BYTES: usize = 64 * 1024; +const IMAGE_OPERATION_BUDGET: Duration = Duration::from_secs(12 * 60); +const RETRY_DELAY: Duration = Duration::from_millis(250); + +fn sanitized_provider_code(body: &[u8]) -> Option<String> { + let payload: Value = serde_json::from_slice(body).ok()?; + let value = payload["error"]["code"] + .as_str() + .or_else(|| payload["error"]["type"].as_str())?; + let sanitized: String = value + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.') + }) + .take(64) + .collect(); + (!sanitized.is_empty()).then_some(sanitized) +} + +fn sanitized_provider_reason(body: &[u8]) -> Option<&'static str> { + let payload: Value = serde_json::from_slice(body).ok()?; + let message = payload["detail"] + .as_str() + .or_else(|| payload["error"]["message"].as_str()) + .or_else(|| payload["message"].as_str())? + .to_ascii_lowercase(); + + if message.contains("invalid jwt") { + Some("invalid_jwt") + } else if message.contains("quota") + || message.contains("balance") + || message.contains("credit") + || message.contains("billing") + { + Some("quota_or_balance") + } else if message.contains("permission") + || message.contains("forbidden") + || message.contains("access denied") + || message.contains("not allowed") + || message.contains("entitlement") + { + Some("access_denied") + } else if message.contains("credential") + || message.contains("api key") + || message.contains("token") + || message.contains("unauthorized") + || message.contains("not authenticated") + { + Some("invalid_credentials") + } else if message.contains("model") && message.contains("not") { + Some("model_unavailable") + } else { + Some("unclassified") + } +} + +fn tool_error( + code: &str, + status: Option<StatusCode>, + retryable: bool, + provider_code: Option<&str>, +) -> String { + let mut fields = vec![format!("code={code}"), format!("retryable={retryable}")]; + if let Some(status) = status { + fields.push(format!("http_status={}", status.as_u16())); + } + if let Some(provider_code) = provider_code { + fields.push(format!("provider_code={provider_code}")); + } + format!("image_gen_error {}", fields.join(" ")) +} + +fn status_error(status: StatusCode, body: &[u8], include_provider_reason: bool) -> String { + let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error(); + let code = if status == StatusCode::TOO_MANY_REQUESTS { + "image_rate_limited" + } else if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + "image_authentication_failed" + } else if status.is_server_error() { + "image_provider_unavailable" + } else { + "image_request_rejected" + }; + let provider_code = sanitized_provider_code(body); + let mut error = tool_error(code, Some(status), retryable, provider_code.as_deref()); + if include_provider_reason && let Some(reason) = sanitized_provider_reason(body) { + error.push_str(" provider_reason="); + error.push_str(reason); + } + error +} + +async fn response_bytes_bounded( + mut response: Response, + limit: usize, + deadline: tokio::time::Instant, +) -> Result<Vec<u8>, String> { + if response + .content_length() + .is_some_and(|content_length| content_length > limit as u64) + { + return Err(tool_error( + "image_response_too_large", + Some(response.status()), + false, + None, + )); + } + let mut body = Vec::new(); + loop { + let chunk = match tokio::time::timeout_at(deadline, response.chunk()).await { + Ok(Ok(chunk)) => chunk, + Ok(Err(_)) => { + return Err(tool_error( + "image_provider_unavailable", + Some(response.status()), + true, + Some("network_error"), + )); + } + Err(_) => { + return Err(tool_error( + "image_provider_unavailable", + Some(response.status()), + true, + Some("timeout"), + )); + } + }; + let Some(chunk) = chunk else { + break; + }; + if body.len().saturating_add(chunk.len()) > limit { + return Err(tool_error( + "image_response_too_large", + Some(response.status()), + false, + None, + )); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +async fn send_before_deadline( + request: reqwest::RequestBuilder, + deadline: tokio::time::Instant, +) -> Result<Response, String> { + match tokio::time::timeout_at(deadline, request.send()).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => Err(tool_error( + "image_provider_unavailable", + None, + true, + Some("network_error"), + )), + Err(_) => Err(tool_error( + "image_provider_unavailable", + None, + true, + Some("timeout"), + )), + } +} + +fn image_mime(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("image/png") + } else if bytes.starts_with(b"\xff\xd8\xff") { + Some("image/jpeg") + } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { + Some("image/webp") + } else { + None + } +} + +fn validate_image(bytes: Vec<u8>, declared_mime: Option<&str>) -> Result<Vec<u8>, String> { + if bytes.is_empty() || bytes.len() > IMAGE_MAX_BYTES { + return Err(tool_error("image_response_too_large", None, false, None)); + } + let detected = image_mime(&bytes).ok_or_else(|| { + tool_error( + "image_response_invalid", + None, + false, + Some("invalid_image_signature"), + ) + })?; + if let Some(declared) = declared_mime { + let normalized = declared + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if normalized != detected { + return Err(tool_error( + "image_response_invalid", + None, + false, + Some("invalid_content_type"), + )); + } + } + Ok(bytes) +} + +fn ip_is_forbidden(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + address.is_private() + || address.is_loopback() + || address.is_link_local() + || address.is_broadcast() + || address.is_documentation() + || address.is_unspecified() + || address.is_multicast() + } + IpAddr::V6(address) => { + address.is_loopback() + || address.is_unspecified() + || address.is_unique_local() + || address.is_unicast_link_local() + || address.is_multicast() + } + } +} + +fn trusted_hosts(api_url: &str, configured: Option<&String>) -> Result<Vec<String>, String> { + let mut hosts = Vec::new(); + if let Some(host) = Url::parse(api_url) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + { + hosts.push(host); + } + if let Some(configured) = configured { + hosts.extend( + configured + .split(',') + .map(str::trim) + .filter(|host| !host.is_empty()) + .map(str::to_ascii_lowercase), + ); + } + hosts.sort(); + hosts.dedup(); + if hosts.is_empty() { + return Err(tool_error("image_configuration_invalid", None, false, None)); + } + Ok(hosts) +} + +fn validated_download_host<'a>(url: &'a Url, trusted: &[String]) -> Result<&'a str, String> { + if url.scheme() != "https" || url.username() != "" || url.password().is_some() { + return Err(tool_error( + "image_url_rejected", + None, + false, + Some("invalid_url"), + )); + } + let host = url + .host_str() + .ok_or_else(|| tool_error("image_url_rejected", None, false, Some("missing_host")))?; + let normalized_host = host.to_ascii_lowercase(); + if !trusted.iter().any(|trusted| trusted == &normalized_host) { + return Err(tool_error( + "image_url_rejected", + None, + false, + Some("untrusted_host"), + )); + } + if host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::<IpAddr>() + .is_ok_and(ip_is_forbidden) + { + return Err(tool_error( + "image_url_rejected", + None, + false, + Some("private_address"), + )); + } + Ok(host) +} + +async fn pinned_download_client(url: &Url, trusted: &[String]) -> Result<reqwest::Client, String> { + let host = validated_download_host(url, trusted)?; + let port = url.port_or_known_default().unwrap_or(443); + let addresses: Vec<SocketAddr> = tokio::net::lookup_host((host, port)) + .await + .map_err(|_| { + tool_error( + "image_provider_unavailable", + None, + true, + Some("dns_failure"), + ) + })? + .collect(); + if addresses.is_empty() + || addresses + .iter() + .any(|address| ip_is_forbidden(address.ip())) + { + return Err(tool_error( + "image_url_rejected", + None, + false, + Some("private_address"), + )); + } + reqwest::Client::builder() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(30)) + .resolve(host, addresses[0]) + .build() + .map_err(|_| { + tool_error( + "image_configuration_invalid", + None, + false, + Some("http_client"), + ) + }) +} + +async fn download_image( + url: &str, + trusted: &[String], + deadline: tokio::time::Instant, + retry_used: &mut bool, + include_provider_reason: bool, +) -> Result<Vec<u8>, String> { + let url = Url::parse(url) + .map_err(|_| tool_error("image_url_rejected", None, false, Some("invalid_url")))?; + let client = pinned_download_client(&url, trusted).await?; + loop { + let response = match send_before_deadline(client.get(url.clone()), deadline).await { + Ok(response) => response, + Err(_) if !*retry_used => { + *retry_used = true; + tokio::time::sleep(RETRY_DELAY).await; + continue; + } + Err(error) => return Err(error), + }; + let status = response.status(); + if !status.is_success() { + let body = response_bytes_bounded(response, ERROR_BODY_MAX_BYTES, deadline) + .await + .unwrap_or_default(); + let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error(); + if retryable && !*retry_used { + *retry_used = true; + tokio::time::sleep(RETRY_DELAY).await; + continue; + } + return Err(status_error(status, &body, include_provider_reason)); + } + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let Some(content_type) = content_type else { + return Err(tool_error( + "image_response_invalid", + None, + false, + Some("invalid_content_type"), + )); + }; + let bytes = match response_bytes_bounded(response, IMAGE_MAX_BYTES, deadline).await { + Ok(bytes) => bytes, + Err(error) if error.contains("retryable=true") && !*retry_used => { + *retry_used = true; + tokio::time::sleep(RETRY_DELAY).await; + continue; + } + Err(error) => return Err(error), + }; + return validate_image(bytes, Some(&content_type)); + } +} + +pub struct ImageGenTool; + +impl Tool for ImageGenTool { + fn name(&self) -> &str { + "image_gen" + } + + fn description(&self) -> &str { + "Generate an image from a text prompt and save it to a file. Provide a vivid, \ + self-contained prompt and an output filePath (e.g. run_dir/08_global_picture.png). \ + Returns the saved path." + } + + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Full text-to-image prompt. Be concrete and self-contained; the image model sees only this text." + }, + "filePath": { + "type": "string", + "description": "Where to write the PNG, relative to the working directory (e.g. runs/<ts>/08_global_picture.png). Use the path as given; do not add extra directory prefixes." + }, + "size": { + "type": "string", + "default": "1536x1024", + "description": "Image size, e.g. 1024x1024, 1536x1024, or 1024x1536. Default 1536x1024 (landscape, good for a banner figure)." + } + }, + "required": ["prompt", "filePath"] + }) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(DEFAULT_TOOL_TIMEOUT_SECS) + } + + fn execute<'a>( + &'a self, + args: Value, + env: &'a Environment, + ) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>> { + Box::pin(async move { + let prompt = args["prompt"] + .as_str() + .ok_or("missing required parameter 'prompt'")?; + let file_path = args["filePath"] + .as_str() + .ok_or("missing required parameter 'filePath'")?; + let size = args["size"].as_str().unwrap_or("1536x1024"); + + let api_key = env + .vars + .get(API_KEY_ENV) + .filter(|key| !key.is_empty()) + .ok_or_else(|| { + tool_error("image_configuration_missing", None, false, Some("api_key")) + })?; + let api_url = env + .vars + .get(API_URL_ENV) + .filter(|url| !url.is_empty()) + .map(String::as_str) + .unwrap_or(API_URL); + let include_provider_reason = env + .vars + .get(IMAGE_GEN_DIAGNOSTIC_ENV) + .is_some_and(|value| value == "true"); + + let output_path = resolve_path(file_path, env)?; + + info!(target: "image_gen", model = DEFAULT_MODEL, size, path = ?output_path, "generating image"); + + let client = reqwest::Client::builder() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(30)) + .build() + .map_err(|_| { + tool_error( + "image_configuration_invalid", + None, + false, + Some("http_client"), + ) + })?; + let deadline = tokio::time::Instant::now() + IMAGE_OPERATION_BUDGET; + let mut retry_used = false; + let response_body = loop { + let response = match send_before_deadline( + client + .post(api_url) + .bearer_auth(api_key) + .json(&serde_json::json!({ + "model": DEFAULT_MODEL, + "prompt": prompt, + "size": size, + "n": 1, + })), + deadline, + ) + .await + { + Ok(response) => response, + Err(_) if !retry_used => { + retry_used = true; + tokio::time::sleep(RETRY_DELAY).await; + continue; + } + Err(error) => return Err(error), + }; + let status = response.status(); + let body_limit = if status.is_success() { + JSON_MAX_BYTES + } else { + ERROR_BODY_MAX_BYTES + }; + let body = match response_bytes_bounded(response, body_limit, deadline).await { + Ok(body) => body, + Err(error) if error.contains("retryable=true") && !retry_used => { + retry_used = true; + tokio::time::sleep(RETRY_DELAY).await; + continue; + } + Err(error) => return Err(error), + }; + if status.is_success() { + break body; + } + let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error(); + if retryable && !retry_used { + retry_used = true; + tokio::time::sleep(RETRY_DELAY).await; + continue; + } + return Err(status_error(status, &body, include_provider_reason)); + }; + + let body: Value = serde_json::from_slice(&response_body).map_err(|_| { + tool_error("image_response_invalid", None, false, Some("invalid_json")) + })?; + let data = body["data"].as_array().and_then(|items| items.first()); + let bytes = if let Some(b64) = data.and_then(|item| item["b64_json"].as_str()) { + let encoded_limit = IMAGE_MAX_BYTES.saturating_mul(4) / 3 + 4; + if b64.len() > encoded_limit { + return Err(tool_error("image_response_too_large", None, false, None)); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|_| { + tool_error( + "image_response_invalid", + None, + false, + Some("invalid_base64"), + ) + })?; + validate_image(decoded, None)? + } else if let Some(url) = data.and_then(|item| item["url"].as_str()) { + let trusted = trusted_hosts(api_url, env.vars.get(TRUSTED_HOSTS_ENV))?; + download_image( + url, + &trusted, + deadline, + &mut retry_used, + include_provider_reason, + ) + .await? + } else { + return Err(tool_error( + "image_response_invalid", + None, + false, + Some("missing_image_data"), + )); + }; + + if let Some(parent) = output_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| format!("failed to create output directory: {error}"))?; + } + tokio::fs::write(&output_path, &bytes) + .await + .map_err(|error| format!("failed to write image: {error}"))?; + + let relative = relative_path(&output_path, &env.cwd); + let size_str = format!("{:.1} KB", bytes.len() as f64 / 1024.0); + info!(target: "image_gen", path = %relative, size = %size_str, "image written"); + + Ok(ToolResult { + call_id: String::new(), + content: format!("Wrote image to {relative} ({size_str})"), + title: Some(format!("image {relative}")), + }) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + + const PNG: &[u8] = b"\x89PNG\r\n\x1a\nfixture"; + + #[test] + fn trusted_https_download_url_is_accepted() { + let url = Url::parse("https://images.example.test/result.png").unwrap(); + let trusted = vec!["images.example.test".to_owned()]; + + assert_eq!( + validated_download_host(&url, &trusted).unwrap(), + "images.example.test" + ); + } + + #[test] + fn private_or_loopback_download_url_is_rejected() { + for value in [ + "https://127.0.0.1/result.png", + "https://10.0.0.8/result.png", + "https://[::1]/result.png", + ] { + let url = Url::parse(value).unwrap(); + let trusted = vec![url.host_str().unwrap().to_ascii_lowercase()]; + let error = validated_download_host(&url, &trusted).unwrap_err(); + assert!(error.contains("code=image_url_rejected")); + assert!(error.contains("provider_code=private_address")); + } + } + + #[test] + fn insecure_or_untrusted_download_url_is_rejected() { + let trusted = vec!["images.example.test".to_owned()]; + let insecure = Url::parse("http://images.example.test/result.png").unwrap(); + let untrusted = Url::parse("https://attacker.example/result.png").unwrap(); + + assert!( + validated_download_host(&insecure, &trusted) + .unwrap_err() + .contains("provider_code=invalid_url") + ); + assert!( + validated_download_host(&untrusted, &trusted) + .unwrap_err() + .contains("provider_code=untrusted_host") + ); + } + + #[test] + fn image_content_type_must_match_its_signature() { + assert_eq!( + validate_image(PNG.to_vec(), Some("image/png; charset=binary")).unwrap(), + PNG + ); + let error = validate_image(PNG.to_vec(), Some("image/jpeg")).unwrap_err(); + assert!(error.contains("code=image_response_invalid")); + assert!(error.contains("provider_code=invalid_content_type")); + } + + #[test] + fn oversized_or_invalid_image_is_rejected() { + let oversized = vec![0_u8; IMAGE_MAX_BYTES + 1]; + assert!( + validate_image(oversized, None) + .unwrap_err() + .contains("code=image_response_too_large") + ); + assert!( + validate_image(b"not an image".to_vec(), None) + .unwrap_err() + .contains("provider_code=invalid_image_signature") + ); + } + + #[tokio::test] + async fn expired_request_deadline_is_reported_as_retryable_timeout() { + let client = reqwest::Client::new(); + let deadline = tokio::time::Instant::now() - Duration::from_millis(1); + + let error = send_before_deadline( + client.get("http://192.0.2.1/never-contact-this-test-address"), + deadline, + ) + .await + .unwrap_err(); + + assert!(error.contains("code=image_provider_unavailable")); + assert!(error.contains("retryable=true")); + assert!(error.contains("provider_code=timeout")); + } + + #[tokio::test] + async fn expired_response_body_deadline_is_reported_as_retryable_timeout() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1\r\n\r\n", + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_secs(1)).await; + }); + let response = reqwest::Client::new() + .get(format!("http://{address}")) + .send() + .await + .unwrap(); + + let error = response_bytes_bounded( + response, + JSON_MAX_BYTES, + tokio::time::Instant::now() - Duration::from_millis(1), + ) + .await + .unwrap_err(); + + assert!(error.contains("code=image_provider_unavailable")); + assert!(error.contains("retryable=true")); + assert!(error.contains("provider_code=timeout")); + server.abort(); + } +} diff --git a/crates/accelerator/src/tools/ledger.rs b/crates/accelerator/src/tools/ledger.rs new file mode 100644 index 00000000..ea6ebb32 --- /dev/null +++ b/crates/accelerator/src/tools/ledger.rs @@ -0,0 +1,385 @@ +//! Persistent structured working ledger (state-layer Blueprint P5). +//! +//! The model writes structure; this code guards the state machine: +//! `pending → running → completed | failed`, nothing else. When an entry +//! completes, pending entries whose deps are all completed are promoted +//! to running by code, so a stuck planner cannot skip the gate. +//! +//! Memory is the source of truth within a process; every mutation is +//! written through to `<run_dir>/ledger.json` when a run directory exists. +//! Without one, the ledger is ephemeral for the process. Tool results are +//! JSON carrying `"tool": "ledger"` and a `transitions` array so +//! `machine::ledger_transitions_in` can lift them into trajectory records. + +use std::collections::HashMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::{Mutex, OnceLock}; + +use machine::{Environment, LedgerDigest, LedgerDigestEntry, Tool, ToolResult}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tracing::warn; + +const LEDGER_FILE_NAME: &str = "ledger.json"; +const STATUS_PENDING: &str = "pending"; +const STATUS_RUNNING: &str = "running"; +const STATUS_COMPLETED: &str = "completed"; +const STATUS_FAILED: &str = "failed"; + +/// One work item. The schema is deliberately flat: the ledger is read by +/// policies projecting result-level content, not by a query engine. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LedgerEntry { + pub id: String, + pub title: String, + pub status: String, + #[serde(default)] + pub deps: Vec<String>, + #[serde(default)] + pub result: String, + pub updated_at: String, +} + +/// Recorded state migration, mirrored into tool results. +#[derive(Debug, Clone, Serialize)] +struct TransitionRecord { + id: String, + from: String, + to: String, +} + +/// Per-run ledger book. The empty path keys the ephemeral (no run_dir) +/// ledger so concurrent graph components sharing a run_dir also share +/// one book — exactly the semantics a ledger exists for. +fn ledgers() -> &'static Mutex<HashMap<PathBuf, HashMap<String, LedgerEntry>>> { + static LEDGERS: OnceLock<Mutex<HashMap<PathBuf, HashMap<String, LedgerEntry>>>> = + OnceLock::new(); + LEDGERS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn cache_key(run_dir: Option<&Path>) -> PathBuf { + run_dir.map(Path::to_path_buf).unwrap_or_default() +} +/// Load the book for one run. Without a run directory the ledger is +/// process-ephemeral: no file is read or written, so a bare `accelerate +/// run` never pollutes the working directory. +fn load_book(run_dir: Option<&Path>) -> HashMap<String, LedgerEntry> { + let Some(dir) = run_dir else { + return HashMap::new(); + }; + let path = dir.join(LEDGER_FILE_NAME); + match std::fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|error| { + warn!( + path = %path.display(), + ?error, + "ledger file unreadable; starting from an empty ledger" + ); + HashMap::new() + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => HashMap::new(), + Err(error) => { + warn!( + path = %path.display(), + ?error, + "ledger file access failed; starting from an empty ledger" + ); + HashMap::new() + } + } +} + +fn persist_book(run_dir: Option<&Path>, book: &HashMap<String, LedgerEntry>) { + let Some(dir) = run_dir else { + return; + }; + let path = dir.join(LEDGER_FILE_NAME); + match serde_json::to_string_pretty(book) { + Ok(raw) => { + if let Err(error) = std::fs::write(&path, raw) { + warn!( + path = %path.display(), + ?error, + "ledger persist failed; in-memory state stays authoritative" + ); + } + } + Err(error) => warn!(?error, "ledger serialization failed"), + } +} + +fn with_book<T>( + run_dir: Option<&Path>, + operation: impl FnOnce(&mut HashMap<String, LedgerEntry>) -> T, +) -> T { + let mut ledgers = ledgers().lock().expect("ledger lock poisoned"); + let key = cache_key(run_dir); + let book = ledgers.entry(key).or_insert_with(|| load_book(run_dir)); + operation(book) +} + +/// Digest for the observation channel. `None` means the run has never +/// touched a ledger — no phantom "empty ledger" in every observation. +pub fn ledger_digest_for(run_dir: &Path) -> Option<LedgerDigest> { + let mut ledgers = ledgers().lock().expect("ledger lock poisoned"); + let book = ledgers.get_mut(&cache_key(Some(run_dir)))?; + Some(digest_of(book)) +} + +fn digest_of(book: &HashMap<String, LedgerEntry>) -> LedgerDigest { + let mut by_status: HashMap<String, u64> = HashMap::new(); + for entry in book.values() { + *by_status.entry(entry.status.clone()).or_default() += 1; + } + let mut ordered: Vec<&LedgerEntry> = book.values().collect(); + ordered.sort_by(|left, right| left.id.cmp(&right.id)); + let current = ordered + .iter() + .find(|entry| entry.status == STATUS_RUNNING) + .or_else(|| ordered.iter().find(|entry| entry.status == STATUS_PENDING)) + .map(|entry| LedgerDigestEntry { + id: entry.id.clone(), + title: entry.title.clone(), + status: entry.status.clone(), + }); + LedgerDigest { + total: book.len() as u64, + by_status, + current_entry: current, + } +} + +fn now_timestamp() -> String { + chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, false) +} + +fn find_entry<'a>( + book: &'a HashMap<String, LedgerEntry>, + id: &str, +) -> Result<&'a LedgerEntry, String> { + book.get(id) + .ok_or_else(|| format!("ledger entry '{id}' not found")) +} + +fn guard_transition(entry: &mut LedgerEntry, to: &str) -> Result<TransitionRecord, String> { + let allowed = match entry.status.as_str() { + STATUS_PENDING => to == STATUS_RUNNING, + STATUS_RUNNING => to == STATUS_COMPLETED || to == STATUS_FAILED, + _ => false, + }; + if !allowed { + return Err(format!( + "illegal ledger transition: '{}' entry '{}' cannot move to '{to}'", + entry.status, entry.id + )); + } + let from = entry.status.clone(); + entry.status = to.to_string(); + entry.updated_at = now_timestamp(); + Ok(TransitionRecord { + id: entry.id.clone(), + from, + to: to.to_string(), + }) +} + +fn deps_are_completed(book: &HashMap<String, LedgerEntry>, entry: &LedgerEntry) -> bool { + entry.deps.iter().all(|dep| { + book.get(dep) + .is_some_and(|dep_entry| dep_entry.status == STATUS_COMPLETED) + }) +} + +/// Promote pending entries whose deps are all completed. Runs after any +/// completion because that is the only status change that can unblock. +fn promote_ready(book: &mut HashMap<String, LedgerEntry>) -> Vec<TransitionRecord> { + let mut promoted = Vec::new(); + loop { + let ready: Vec<String> = book + .values() + .filter(|entry| entry.status == STATUS_PENDING && deps_are_completed(book, entry)) + .map(|entry| entry.id.clone()) + .collect(); + if ready.is_empty() { + break; + } + for id in ready { + if let Some(entry) = book.get_mut(&id) + && let Ok(record) = guard_transition(entry, STATUS_RUNNING) + { + promoted.push(record); + } + } + } + promoted +} + +fn entry_json(entry: &LedgerEntry) -> Value { + serde_json::to_value(entry).unwrap_or_else(|error| { + warn!(?error, "ledger entry serialization failed"); + Value::Null + }) +} + +pub struct LedgerTool; + +impl Tool for LedgerTool { + fn name(&self) -> &str { + "ledger" + } + + fn description(&self) -> &str { + "Structured working ledger that survives context edits. Add entries for planned work \ + (optionally with deps on other entry ids), start/complete/fail them; entries whose deps \ + are all completed are promoted automatically. Use list/get to read. Statuses move only \ + pending→running→completed|failed." + } + + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": ["add", "start", "complete", "fail", "list", "get"], + "description": "Ledger operation." + }, + "id": {"type": "string", "description": "Entry id (add/start/complete/fail/get)."}, + "title": {"type": "string", "description": "Entry title (add)."}, + "deps": {"type": "array", "items": {"type": "string"}, "description": "Entry ids this work depends on (add)."}, + "result": {"type": "string", "description": "Outcome text (complete/fail)."} + }, + "required": ["op"] + }) + } + + fn execute<'a>( + &'a self, + args: Value, + env: &'a Environment, + ) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>> { + let run_dir = env.run_dir.clone(); + Box::pin(async move { + let operation = args["op"] + .as_str() + .ok_or("missing required parameter 'op'")?; + let id = args["id"].as_str().unwrap_or_default().to_string(); + + let (transitions, payload) = with_book(run_dir.as_deref(), |book| match operation { + "add" => { + if id.is_empty() { + return Err("ledger add requires an 'id'".to_string()); + } + if book.contains_key(&id) { + return Err(format!("ledger entry '{id}' already exists")); + } + let entry = LedgerEntry { + id: id.clone(), + title: args["title"].as_str().unwrap_or_default().to_string(), + status: STATUS_PENDING.to_string(), + deps: args["deps"] + .as_array() + .map(|deps| { + deps.iter() + .filter_map(|dep| dep.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + result: String::new(), + updated_at: now_timestamp(), + }; + let payload = json!({"op": "add", "entry": entry_json(&entry)}); + book.insert(id, entry); + Ok((Vec::new(), payload)) + } + "start" => { + let entry = find_entry(book, &id)?; + if !entry.deps.is_empty() && !deps_are_completed(book, entry) { + let unmet: Vec<String> = entry + .deps + .iter() + .filter(|dep| { + book.get(*dep) + .is_none_or(|dep_entry| dep_entry.status != STATUS_COMPLETED) + }) + .cloned() + .collect(); + return Err(format!( + "entry '{id}' has uncompleted deps: {}", + unmet.join(", ") + )); + } + let entry = book.get_mut(&id).expect("entry existence checked above"); + let record = guard_transition(entry, STATUS_RUNNING)?; + Ok(( + vec![record], + json!({"op": "start", "entry": entry_json(entry)}), + )) + } + "complete" | "fail" => { + let target = if operation == "complete" { + STATUS_COMPLETED + } else { + STATUS_FAILED + }; + let entry = book + .get_mut(&id) + .ok_or(format!("ledger entry '{id}' not found"))?; + let record = guard_transition(entry, target)?; + if let Some(result_text) = args["result"].as_str() { + entry.result = result_text.to_string(); + } + let mut transitions = vec![record]; + transitions.extend(promote_ready(book)); + let entry = book.get(&id).expect("entry existence checked above"); + Ok(( + transitions, + json!({"op": operation, "entry": entry_json(entry)}), + )) + } + "get" => { + let entry = find_entry(book, &id)?; + Ok((Vec::new(), json!({"op": "get", "entry": entry_json(entry)}))) + } + "list" => { + let mut ordered: Vec<&LedgerEntry> = book.values().collect(); + ordered.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(( + Vec::new(), + json!({ + "op": "list", + "entries": ordered.iter().map(|entry| entry_json(entry)).collect::<Vec<_>>(), + }), + )) + } + other => Err(format!("unknown ledger op '{other}'")), + })?; + + persist_book( + run_dir.as_deref(), + &with_book(run_dir.as_deref(), |book| book.clone()), + ); + + let transition_values: Vec<Value> = transitions + .iter() + .map(|record| json!({"id": record.id, "from": record.from, "to": record.to})) + .collect(); + let content = json!({ + "tool": "ledger", + "op": operation, + "transitions": transition_values, + "digest": with_book(run_dir.as_deref(), |book| digest_of(book)), + "result": payload, + }) + .to_string(); + + Ok(ToolResult { + call_id: String::new(), + content, + title: Some(format!("ledger {operation}")), + }) + }) + } +} diff --git a/crates/accelerator/src/tools/lsp.rs b/crates/accelerator/src/tools/lsp.rs index c18660ab..8fb9b2f3 100644 --- a/crates/accelerator/src/tools/lsp.rs +++ b/crates/accelerator/src/tools/lsp.rs @@ -75,7 +75,7 @@ impl Tool for LspTool { let file_path = args["filePath"] .as_str() .ok_or("missing required parameter 'filePath'")?; - let resolved = resolve_path(file_path, &env.cwd); + let resolved = resolve_path(file_path, env)?; match operation { "diagnostics" => query_diagnostics(&resolved, env).await, @@ -218,9 +218,9 @@ fn format_hover_contents(contents: &lsp_types::HoverContents) -> String { lsp_types::HoverContents::Markup(markup) => markup.value.clone(), lsp_types::HoverContents::Array(parts) => parts .iter() - .filter_map(|part| match part { - lsp_types::MarkedString::String(s) => Some(s.as_str()), - lsp_types::MarkedString::LanguageString(ls) => Some(ls.value.as_str()), + .map(|part| match part { + lsp_types::MarkedString::String(s) => s.as_str(), + lsp_types::MarkedString::LanguageString(ls) => ls.value.as_str(), }) .collect::<Vec<_>>() .join("\n"), diff --git a/crates/accelerator/src/tools/mod.rs b/crates/accelerator/src/tools/mod.rs index 359836ff..35b9b1a3 100644 --- a/crates/accelerator/src/tools/mod.rs +++ b/crates/accelerator/src/tools/mod.rs @@ -1,30 +1,124 @@ //! Built-in tools for the accelerator. -//! -//! Each tool implements the [`Tool`] trait from the `machine` crate. -//! Tools are registered into [`Resources`] before the machine runs. +mod arxiv; mod find; mod fs; +mod git; +mod image_gen; +mod ledger; mod lsp; -mod shell; +pub mod shell; +mod spawn; mod wait; +mod webfetch; +pub use arxiv::{ArxivDownloadTool, ArxivSearchTool}; pub use find::FindTool; pub use fs::FsTool; +pub use git::{GitTool, check_safety as check_git_safety, tokenize as tokenize_git}; +pub use image_gen::{IMAGE_GEN_DIAGNOSTIC_ENV, ImageGenTool}; +pub use ledger::{LedgerTool, ledger_digest_for}; pub use lsp::LspTool; -pub use shell::ShellTool; +pub use shell::{OUTPUT_CAP_BYTES, ShellTool, build_result, collect_output}; +pub use spawn::SpawnTool; pub use wait::WaitTool; +pub use webfetch::WebFetchTool; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; -/// Resolve a path string against a working directory. -pub(crate) fn resolve_path(raw: &str, cwd: &Path) -> PathBuf { +use crate::catalog::Catalog; + +pub fn register(catalog: &mut Catalog) { + for tool in builtin_tools() { + catalog + .register_tool(tool) + .expect("built-in tool names must be unique"); + } +} + +/// All built-in tools. +pub fn builtin_tools() -> Vec<std::sync::Arc<dyn machine::Tool>> { + vec![ + std::sync::Arc::new(ArxivSearchTool), + std::sync::Arc::new(ArxivDownloadTool), + std::sync::Arc::new(FindTool), + std::sync::Arc::new(FsTool), + std::sync::Arc::new(GitTool), + std::sync::Arc::new(ImageGenTool), + std::sync::Arc::new(LedgerTool), + std::sync::Arc::new(LspTool), + std::sync::Arc::new(ShellTool), + std::sync::Arc::new(WaitTool), + std::sync::Arc::new(WebFetchTool), + ] +} + +/// Resolve a tool path within the run-directory capability, when present. +/// +/// `Environment::root` is the capability boundary established by the CLI for +/// `--run-dir`. Every existing path component is checked with +/// `symlink_metadata`, so a model cannot escape through `..`, an absolute path, +/// or a symlink prepared inside the workspace. Generic RCM runs without a root +/// retain the historical cwd-relative behavior. +pub(crate) fn resolve_path(raw: &str, env: &machine::Environment) -> Result<PathBuf, String> { let path = Path::new(raw); - if path.is_absolute() { - path.to_path_buf() + let Some(root) = env.root.as_deref() else { + return Ok(if path.is_absolute() { + path.to_path_buf() + } else { + env.cwd.join(path) + }); + }; + + if path + .components() + .any(|component| component == Component::ParentDir) + { + return Err("sandbox violation: parent-directory traversal is not allowed".to_string()); + } + + let relative = if path.is_absolute() { + path.strip_prefix(root).map_err(|_| { + "sandbox violation: absolute path is outside the run directory".to_string() + })? } else { - cwd.join(path) + path + }; + if relative + .components() + .any(|component| matches!(component, Component::RootDir | Component::Prefix(_))) + { + return Err("sandbox violation: invalid workspace path".to_string()); + } + + let mut candidate = root.to_path_buf(); + for component in relative.components() { + match component { + Component::CurDir => continue, + Component::Normal(segment) => candidate.push(segment), + _ => return Err("sandbox violation: invalid workspace path".to_string()), + } + match std::fs::symlink_metadata(&candidate) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("sandbox violation: symbolic links are not allowed".to_string()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("cannot inspect workspace path: {error}")), + } + } + + let existing = candidate + .ancestors() + .find(|ancestor| ancestor.exists()) + .ok_or_else(|| "sandbox violation: workspace root is unavailable".to_string())?; + let canonical_existing = existing + .canonicalize() + .map_err(|error| format!("cannot resolve workspace path: {error}"))?; + if !canonical_existing.starts_with(root) { + return Err("sandbox violation: path escapes the run directory".to_string()); } + Ok(candidate) } /// Compute the display path relative to a working directory. @@ -40,13 +134,65 @@ pub(crate) fn relative_path(path: &Path, cwd: &Path) -> String { .unwrap_or_else(|_| path.display().to_string()) } -/// All built-in tools registered by default. -pub fn builtin_tools() -> Vec<std::sync::Arc<dyn machine::Tool>> { - vec![ - std::sync::Arc::new(FindTool), - std::sync::Arc::new(FsTool), - std::sync::Arc::new(LspTool), - std::sync::Arc::new(ShellTool), - std::sync::Arc::new(WaitTool), - ] +#[cfg(test)] +mod path_tests { + use std::fs; + use std::path::PathBuf; + + use machine::Environment; + + use super::resolve_path; + + fn workspace(name: &str) -> (PathBuf, Environment) { + let root = std::env::temp_dir().join(format!( + "rcm-sandbox-{name}-{}-{}", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("workspace"); + let root = root.canonicalize().expect("canonical workspace"); + let mut env = Environment::empty(&root); + env.root = Some(root.clone()); + env.run_dir = Some(root.clone()); + (root, env) + } + + #[test] + fn resolves_relative_path_inside_workspace() { + let (root, env) = workspace("inside"); + assert_eq!( + resolve_path("reports/final.md", &env).expect("inside path"), + root.join("reports/final.md") + ); + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn rejects_parent_directory_escape() { + let (root, env) = workspace("parent"); + let error = resolve_path("../outside", &env).expect_err("escape must fail"); + assert!(error.contains("sandbox violation")); + fs::remove_dir_all(root).expect("cleanup"); + } + + #[test] + fn rejects_absolute_path_outside_workspace() { + let (root, env) = workspace("absolute"); + let error = resolve_path("/proc/self/environ", &env).expect_err("escape must fail"); + assert!(error.contains("sandbox violation")); + fs::remove_dir_all(root).expect("cleanup"); + } + + #[cfg(unix)] + #[test] + fn rejects_symlink_escape() { + use std::os::unix::fs::symlink; + + let (root, env) = workspace("symlink"); + symlink("/etc", root.join("link")).expect("symlink"); + let error = resolve_path("link/passwd", &env).expect_err("symlink must fail"); + assert!(error.contains("symbolic links")); + fs::remove_dir_all(root).expect("cleanup"); + } } diff --git a/crates/accelerator/src/tools/shell.rs b/crates/accelerator/src/tools/shell.rs index b2267cf1..fc4e1eff 100644 --- a/crates/accelerator/src/tools/shell.rs +++ b/crates/accelerator/src/tools/shell.rs @@ -5,12 +5,12 @@ use std::time::Duration; use machine::{Environment, Tool, ToolResult}; use serde_json::Value; -use tokio::io::AsyncReadExt; use tokio::process::Command; +use tracing::warn; const MAX_TIMEOUT_SECS: u64 = 180; const DEFAULT_TIMEOUT_SECS: u64 = 120; -const OUTPUT_CAP_BYTES: usize = 512 * 1024; +pub const OUTPUT_CAP_BYTES: usize = 512 * 1024; static SHELL_DESC: LazyLock<String> = LazyLock::new(|| { include_str!("shell.txt") @@ -76,6 +76,19 @@ impl Tool for ShellTool { .unwrap_or(DEFAULT_TIMEOUT_SECS) .min(MAX_TIMEOUT_SECS); + #[cfg(windows)] + let mut child = Command::new("cmd") + .arg("/c") + .arg(&command) + .current_dir(&env.cwd) + .env_clear() + .envs(&env.vars) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn: {e}"))?; + + #[cfg(not(windows))] let mut child = Command::new("sh") .arg("-c") .arg(&command) @@ -110,24 +123,30 @@ impl Tool for ShellTool { } } -async fn collect_output(child: &mut tokio::process::Child) -> (Vec<u8>, Vec<u8>, Option<i32>) { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); +pub async fn collect_output(child: &mut tokio::process::Child) -> (Vec<u8>, Vec<u8>, Option<i32>) { + use tokio::io::AsyncReadExt; + + let mut stdout = Vec::with_capacity(OUTPUT_CAP_BYTES); + let mut stderr = Vec::with_capacity(OUTPUT_CAP_BYTES); if let Some(out) = child.stdout.take() { - let mut r = tokio::io::BufReader::new(out); - let _ = r.read_to_end(&mut stdout).await; + let mut reader = tokio::io::BufReader::new(out).take(OUTPUT_CAP_BYTES as u64); + if let Err(error) = reader.read_to_end(&mut stdout).await { + warn!(?error, stream = "stdout", "failed to read shell output"); + } } if let Some(err) = child.stderr.take() { - let mut r = tokio::io::BufReader::new(err); - let _ = r.read_to_end(&mut stderr).await; + let mut reader = tokio::io::BufReader::new(err).take(OUTPUT_CAP_BYTES as u64); + if let Err(error) = reader.read_to_end(&mut stderr).await { + warn!(?error, stream = "stderr", "failed to read shell output"); + } } let exit_code = child.wait().await.ok().and_then(|s| s.code()); (stdout, stderr, exit_code) } -fn build_result( +pub fn build_result( command: String, stdout: Vec<u8>, stderr: Vec<u8>, diff --git a/crates/accelerator/src/tools/spawn.rs b/crates/accelerator/src/tools/spawn.rs new file mode 100644 index 00000000..83fe7ec6 --- /dev/null +++ b/crates/accelerator/src/tools/spawn.rs @@ -0,0 +1,271 @@ +//! `SpawnTool` — fan out a worker accelerator over a runtime list of items. +//! +//! A planner accelerator declares workers via the `spawns = [...]` DSL field. +//! At compile time a `SpawnTool` is created for each worker alias and injected +//! into the planner's tool runtime. The planner LLM calls the tool explicitly, +//! controls concurrency via `max_parallel`, and receives a structured summary +//! that it can use to retry failed items. + +use std::pin::Pin; +use std::time::Duration; + +use machine::{Environment, RunState, ToolResult}; +use serde_json::Value; + +use crate::accelerator::Accelerator; + +const MAX_SPAWN_PARALLEL: usize = 32; +const MAX_SPAWN_ITEMS: usize = 1_024; +const SPAWN_TIMEOUT_SECS: u64 = 43_200; + +/// Tool that spawns many worker accelerator instances concurrently. +/// +/// Signature visible to the LLM: +/// - `items`: JSON array of work item objects (each becomes one worker input) +/// - `max_parallel`: concurrency cap (default 5) +/// +/// Returns a per-item outcome summary. Failed items are surfaced for retry. +#[derive(Clone)] +pub struct SpawnTool { + name: String, + description: String, + worker: Accelerator, +} + +impl SpawnTool { + pub fn new(name: impl Into<String>, worker: Accelerator) -> Self { + let name = name.into(); + let description = format!( + "Spawn a `{name}` worker for each item in a batch.\n\n\ + - `items`: JSON array of work item objects. Each item must contain \ + all fields the worker needs (e.g. `id`, `title`, `why`, `run_dir`).\n\ + - `max_parallel`: optional concurrency limit (default 5).\n\n\ + Returns a per-item outcome summary. Inspect failed items and retry \ + them by calling this tool again with a smaller items list." + ); + Self { + name, + description, + worker, + } + } + + /// Extract a handoff status from a worker's output. + /// Scans context fragments first, then falls back to the purpose field + /// (status text set in the worker's base purpose survives merge_input). + /// Returns one of `ok` / `partial` / `blocked` / `failed` / `unknown`. + fn extract_status(output: &RunState) -> &'static str { + // 1. Check context text fragments (handoff messages from real workers). + for fragment in output.context.fragments().iter().rev() { + let Some(text) = fragment.as_text() else { + continue; + }; + for line in text.lines().rev() { + if let Some(status) = Self::classify_status_line(line) { + return status; + } + } + } + // 2. Fallback: scan the purpose (workers with halt-only policies + // may encode status in purpose, which survives merge_input). + for line in output.purpose.text.lines().rev() { + if let Some(status) = Self::classify_status_line(line) { + return status; + } + } + "unknown" + } + + /// Classify a single line as a handoff status, if it is one. Matching is + /// lenient on purpose: handoffs come from an LLM, so we accept the + /// schema's `status: <value>` case-insensitively, ignore leading list / + /// markdown markers, and look only at the first word after the colon. This + /// keeps `- status: ok`, `Status: partial (image-only PDF)` etc. from being + /// misread as failures. + fn classify_status_line(line: &str) -> Option<&'static str> { + let trimmed = line + .trim() + .trim_start_matches(['-', '*', '•', '#', '>', ' ']) + .trim() + .to_ascii_lowercase(); + let (key, value) = trimmed.split_once(':')?; + if key.trim_matches(['*', '_', '`', ' ']) != "status" { + return None; + } + match value + .split_whitespace() + .next()? + .trim_matches(['*', '_', '`']) + { + "ok" => Some("ok"), + "partial" => Some("partial"), + "blocked" => Some("blocked"), + "failed" => Some("failed"), + _ => None, + } + } + + fn item_label(item: &Value, index: usize) -> String { + for field in ["id", "n", "slug", "title"] { + if let Some(value) = item.get(field).and_then(Value::as_str) + && !value.trim().is_empty() + { + return value.to_string(); + } + } + format!("item[{}]", index + 1) + } +} + +impl machine::Tool for SpawnTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "JSON array of work items. Each item is passed as the work item to one worker instance.", + "maxItems": MAX_SPAWN_ITEMS, + "items": { "type": "object" } + }, + "max_parallel": { + "type": "integer", + "description": "Maximum concurrent workers (default 5).", + "default": 5 + } + }, + "required": ["items"] + }) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(SPAWN_TIMEOUT_SECS) + } + + fn execute<'a>( + &'a self, + args: Value, + env: &'a Environment, + ) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>> { + Box::pin(async move { + let items = args + .get("items") + .and_then(|v| v.as_array()) + .ok_or_else(|| "spawn requires 'items' as a JSON array".to_string())?; + if items.len() > MAX_SPAWN_ITEMS { + return Err(format!( + "spawn accepts at most {MAX_SPAWN_ITEMS} items per call" + )); + } + let max_parallel = args + .get("max_parallel") + .and_then(|v| v.as_u64()) + .unwrap_or(5) + .clamp(1, MAX_SPAWN_PARALLEL as u64) as usize; + + let total = items.len(); + if total == 0 { + return Ok(ToolResult { + content: "spawn called with empty items list, nothing to do.".into(), + call_id: String::new(), + title: Some("spawn: empty".into()), + }); + } + + // Pre-extract item IDs for the summary report. + let item_ids: Vec<String> = items + .iter() + .enumerate() + .map(|(index, item)| Self::item_label(item, index)) + .collect(); + + // Run workers in bounded waves. Use chunks so we never run more + // than max_parallel futures concurrently. join_all works on any + // tokio runtime (no tokio::spawn needed). + let mut outcomes = vec![None; total]; + let mut start = 0; + for chunk in items.chunks(max_parallel) { + let wave: Vec<_> = chunk + .iter() + .enumerate() + .map(|(offset, item)| { + let worker = self.worker.clone(); + let item_text = + serde_json::to_string_pretty(item).unwrap_or_else(|_| item.to_string()); + let mut worker_state = RunState { + environment: env.clone(), + ..RunState::default() + }; + worker_state.context.append( + machine::Fragment::user(format!( + "Your assigned work item:\n{item_text}" + )) + .with_tag("item"), + ); + let idx = start + offset; + async move { + let output = worker.run_with(worker_state).await; + let status = SpawnTool::extract_status(&output); + (idx, status.to_string()) + } + }) + .collect(); + for (idx, status) in futures_util::future::join_all(wave).await { + outcomes[idx] = Some(status); + } + start += chunk.len(); + } + + // Build summary report. `ok` and `partial` both mean the worker + // produced its artifact, so neither should be retried; only + // blocked / failed / unknown are surfaced as failures. + let mut ok = 0u32; + let mut partial = 0u32; + let mut failures = Vec::new(); + for (i, slot) in outcomes.iter().enumerate() { + match slot.as_deref() { + Some("ok") => ok += 1, + Some("partial") => partial += 1, + _ => { + if let Some(id) = item_ids.get(i) { + failures.push(id.clone()); + } + } + } + } + + let tool_name = self.name.strip_prefix("spawn_").unwrap_or(&self.name); + let mut report = format!( + "spawn_{tool_name} results ({total} items, max_parallel={max_parallel}):\nok={ok}" + ); + if partial > 0 { + report.push_str(&format!("\npartial={partial}")); + } + if failures.is_empty() { + report.push_str("\nAll items completed successfully."); + } else { + report.push_str(&format!("\nfailed={}", failures.len())); + for id in &failures { + report.push_str(&format!("\n ✗ {id}")); + } + report.push_str( + "\n\nYou may retry failed items by calling this tool again with only those items.", + ); + } + + Ok(ToolResult { + content: report, + call_id: String::new(), + title: Some(format!("spawn_{tool_name}: {}/{} ok", ok + partial, total)), + }) + }) + } +} diff --git a/crates/accelerator/src/tools/webfetch.rs b/crates/accelerator/src/tools/webfetch.rs new file mode 100644 index 00000000..6a48ef34 --- /dev/null +++ b/crates/accelerator/src/tools/webfetch.rs @@ -0,0 +1,433 @@ +use std::collections::HashMap; +use std::future::Future; +use std::net::{IpAddr, Ipv4Addr}; +use std::pin::Pin; +use std::time::{Duration, Instant}; + +use futures_util::StreamExt; +use machine::{Environment, Tool, ToolResult}; +use reqwest::header::{ + ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, HeaderMap, HeaderName, HeaderValue, + USER_AGENT, +}; +use scraper::Selector; +use serde_json::Value; +use tokio::sync::Mutex; +use url::Url; + +const TIMEOUT_SECS: u64 = 30; +const MAX_BYTES: u64 = 1_000_000; +const DOMAIN_INTERVAL: Duration = Duration::from_secs(2); + +const BROWSER_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +const BINARY_PREFIXES: &[&str] = &[ + "image/", + "audio/", + "video/", + "application/pdf", + "application/zip", + "application/gzip", + "application/x-tar", + "application/x-rar", + "application/x-7z", + "application/vnd.", +]; + +fn is_binary(content_type: &str) -> bool { + BINARY_PREFIXES + .iter() + .any(|prefix| content_type.starts_with(prefix)) +} + +const HIDDEN_TAGS: &[&str] = &["script", "style", "noscript", "iframe"]; + +static TITLE_SELECTOR: std::sync::LazyLock<Selector> = + std::sync::LazyLock::new(|| Selector::parse("title").expect("title selector")); + +static PRIVATE_RANGES: &[(Ipv4Addr, Ipv4Addr)] = &[ + (Ipv4Addr::new(0, 0, 0, 0), Ipv4Addr::new(0, 255, 255, 255)), + (Ipv4Addr::new(10, 0, 0, 0), Ipv4Addr::new(10, 255, 255, 255)), + ( + Ipv4Addr::new(100, 64, 0, 0), + Ipv4Addr::new(100, 127, 255, 255), + ), + ( + Ipv4Addr::new(127, 0, 0, 0), + Ipv4Addr::new(127, 255, 255, 255), + ), + ( + Ipv4Addr::new(169, 254, 0, 0), + Ipv4Addr::new(169, 254, 255, 255), + ), + ( + Ipv4Addr::new(172, 16, 0, 0), + Ipv4Addr::new(172, 31, 255, 255), + ), + ( + Ipv4Addr::new(192, 168, 0, 0), + Ipv4Addr::new(192, 168, 255, 255), + ), + ( + Ipv4Addr::new(198, 18, 0, 0), + Ipv4Addr::new(198, 19, 255, 255), + ), + ( + Ipv4Addr::new(203, 0, 113, 0), + Ipv4Addr::new(203, 0, 113, 255), + ), +]; + +static RESTRICTED_HOSTNAMES: &[&str] = &[ + "localhost", + "localhost.localdomain", + "localhost6", + "metadata", + "metadata.google.internal", + "169.254.169.254", +]; + +static RESTRICTED_SUFFIXES: &[&str] = &[".localhost", ".local", ".internal"]; + +static BLOCK_ELEMENTS: &[&str] = &[ + "article", + "aside", + "blockquote", + "br", + "dd", + "div", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "ul", +]; + +// ── SSRF protection ── + +fn is_internal(domain: &str) -> bool { + let lower = domain.to_lowercase(); + if RESTRICTED_HOSTNAMES + .iter() + .any(|h| lower == *h || lower == format!("{h}.")) + { + return true; + } + RESTRICTED_SUFFIXES.iter().any(|s| lower.ends_with(s)) +} + +fn is_private(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => PRIVATE_RANGES + .iter() + .any(|&(start, end)| v4 >= start && v4 <= end), + IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified() || v6.segments()[0] == 0xfd00, + } +} + +fn validate_url(raw: &str) -> Result<Url, String> { + let url = Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?; + if url.scheme() != "http" && url.scheme() != "https" { + return Err(format!("unsupported scheme: {}", url.scheme())); + } + let Some(host) = url.host() else { + return Err("URL has no host".into()); + }; + match host { + url::Host::Ipv4(ip) if is_private(IpAddr::V4(ip)) => { + return Err(format!("blocked IP: {ip}")); + } + url::Host::Ipv6(ip) if is_private(IpAddr::V6(ip)) => { + return Err(format!("blocked IP: {ip}")); + } + url::Host::Domain(domain) if is_internal(domain) => { + return Err(format!("blocked hostname: {domain}")); + } + _ => {} + } + Ok(url) +} + +// ── WebFetch client ── + +static CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| { + let mut default_headers = HeaderMap::new(); + default_headers.insert( + ACCEPT, + HeaderValue::from_static( + "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + ), + ); + default_headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9")); + default_headers.insert(USER_AGENT, HeaderValue::from_static(BROWSER_UA)); + default_headers.insert( + ACCEPT_ENCODING, + HeaderValue::from_static("gzip, deflate, br"), + ); + default_headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + default_headers.insert( + HeaderName::from_static("sec-fetch-dest"), + HeaderValue::from_static("document"), + ); + default_headers.insert( + HeaderName::from_static("sec-fetch-mode"), + HeaderValue::from_static("navigate"), + ); + default_headers.insert( + HeaderName::from_static("sec-fetch-site"), + HeaderValue::from_static("none"), + ); + default_headers.insert( + HeaderName::from_static("sec-fetch-user"), + HeaderValue::from_static("?1"), + ); + default_headers.insert( + HeaderName::from_static("upgrade-insecure-requests"), + HeaderValue::from_static("1"), + ); + + reqwest::Client::builder() + .default_headers(default_headers) + .timeout(Duration::from_secs(TIMEOUT_SECS)) + .redirect(reqwest::redirect::Policy::custom(|attempt| { + let url_str = attempt.url().as_str(); + match validate_url(url_str) { + Ok(_) => attempt.follow(), + Err(e) => attempt.error(e), + } + })) + .build() + .expect("webfetch client") +}); + +// ── Rate limiter ── + +async fn rate_limit(host: &str) { + static STATE: std::sync::LazyLock<Mutex<HashMap<String, Instant>>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + + let should_wait = { + let map = STATE.lock().await; + map.get(host) + .is_some_and(|last| last.elapsed() < DOMAIN_INTERVAL) + }; + + if should_wait { + tokio::time::sleep(DOMAIN_INTERVAL).await; + } + + let mut map = STATE.lock().await; + if map.len() > 1_000 { + let cutoff = Instant::now() - Duration::from_secs(60); + map.retain(|_, last| *last >= cutoff); + } + map.insert(host.to_string(), Instant::now()); +} + +// ── HTML text extraction ── + +// ── HTML text extraction ── + +fn extract_page_body(doc: &scraper::Html) -> String { + use std::ops::Deref; + + fn collect(element: &scraper::ElementRef, output: &mut String) { + let node = element.deref(); + for child in node.children() { + match child.value() { + scraper::node::Node::Text(text) => { + let trimmed = text.text.trim(); + if !trimmed.is_empty() { + if !output.is_empty() && !output.ends_with('\n') { + output.push(' '); + } + output.push_str(trimmed); + } + } + scraper::node::Node::Element(el) => { + if HIDDEN_TAGS.contains(&el.name()) { + continue; + } + if BLOCK_ELEMENTS.contains(&el.name()) + && !output.is_empty() + && !output.ends_with('\n') + { + output.push('\n'); + } + if let Some(child_element) = scraper::ElementRef::wrap(child) { + collect(&child_element, output); + } + } + _ => {} + } + } + } + + let mut result = String::new(); + collect(&doc.root_element(), &mut result); + result +} + +/// Truncate a string at a character boundary, appending a notice. +fn truncate_at(text: &str, max_chars: usize) -> String { + let Some((idx, _)) = text.char_indices().nth(max_chars) else { + return text.to_string(); + }; + format!( + "{}\n\n... (truncated, full page is {} chars)", + &text[..idx], + text.chars().count(), + ) +} + +// ── Tool definition ── + +pub struct WebFetchTool; + +impl Tool for WebFetchTool { + fn name(&self) -> &str { + "webfetch" + } + + fn description(&self) -> &str { + "Fetch and read a web page. Given a URL, returns the page content as readable text." + } + + fn parameters(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "The full URL to fetch (including scheme, e.g. https://example.com)" + }, + "max_length": { + "type": "integer", + "default": 5000, + "description": "Maximum characters of text to return (default 5000, max 100000)" + } + }, + "required": ["url"] + }) + } + + fn timeout(&self) -> Duration { + Duration::from_secs(TIMEOUT_SECS) + } + + fn execute<'a>( + &'a self, + args: Value, + _env: &'a Environment, + ) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>> { + Box::pin(async move { + let url_text = args["url"] + .as_str() + .ok_or("missing required parameter 'url'")?; + let max_length = args["max_length"].as_u64().unwrap_or(5000).min(100_000) as usize; + + let validated = validate_url(url_text)?; + let host = validated.host_str().unwrap_or(""); + if !host.is_empty() { + rate_limit(host).await; + } + + let response = CLIENT + .get(url_text) + .send() + .await + .map_err(|e| format!("failed to fetch {url_text}: {e}"))?; + + let status = response.status(); + if !status.is_success() { + return Ok(ToolResult { + call_id: String::new(), + content: format!("HTTP {status}"), + title: Some(format!("HTTP {status}")), + }); + } + + let content_type = response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + + if is_binary(&content_type) { + return Ok(ToolResult { + call_id: String::new(), + content: format!("binary content ({content_type}), not readable as text"), + title: Some("binary content".into()), + }); + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("read error: {e}"))?; + if body.len() + chunk.len() > MAX_BYTES as usize { + return Ok(ToolResult { + call_id: String::new(), + content: format!("page too large (exceeds {MAX_BYTES} bytes)"), + title: Some("page too large".into()), + }); + } + body.extend_from_slice(&chunk); + } + + let is_html = content_type.contains("text/html") || content_type.is_empty(); + let text = if is_html { + let html = String::from_utf8_lossy(&body); + let doc = scraper::Html::parse_document(&html); + let mut result = String::new(); + + if let Some(t) = doc.select(&TITLE_SELECTOR).next().and_then(|el| { + let t = el.text().collect::<String>(); + (!t.trim().is_empty()).then(|| t.trim().to_string()) + }) { + result.push_str(&format!("# {t}\n\n")); + } + + let page_text = extract_page_body(&doc); + result.push_str(&if page_text.len() <= max_length { + page_text + } else { + truncate_at(&page_text, max_length) + }); + result + } else { + String::from_utf8_lossy(&body) + .chars() + .take(max_length) + .collect() + }; + + Ok(ToolResult { + call_id: String::new(), + content: text, + title: Some(url_text.to_string()), + }) + }) + } +} diff --git a/crates/accelerator/src/trajectory.rs b/crates/accelerator/src/trajectory.rs new file mode 100644 index 00000000..981e0bea --- /dev/null +++ b/crates/accelerator/src/trajectory.rs @@ -0,0 +1,66 @@ +//! Opt-in trajectory recording for primitive accelerators. +//! +//! A recorder is created only when the caller supplied a run directory; +//! without one, `fire()` behaves exactly as before. Graph components run +//! concurrently, so each recorder owns a per-machine subdirectory under +//! `<run_dir>/trajectory/` — never a shared WAL. + +use std::path::{Path, PathBuf}; + +use machine::obs::Obs; +use machine::{LedgerTransition, MachineState, StoredEvent}; +use storage::{Store, TrajectoryEvent}; +use tracing::warn; + +pub struct TrajectoryRecorder { + store: Store, + dir: PathBuf, +} + +impl TrajectoryRecorder { + /// Open (creating if needed) the trajectory directory for one machine. + /// The machine label keeps concurrent graph components in separate WALs. + pub fn open(run_dir: &Path, machine_label: &str) -> storage::WalResult<Self> { + let dir = run_dir.join("trajectory").join(machine_label); + let store = Store::open(&dir)?; + Ok(Self { store, dir }) + } + + /// Record one decision point. WAL failure is a side-channel problem: + /// warn and keep running rather than kill the run. Overlay presence is + /// captured as counts inside `obs.overlay_status`; projected content is + /// never persisted. + pub fn record_step( + &mut self, + step: u64, + obs: &Obs, + ledger_transitions: &[LedgerTransition], + event: &StoredEvent, + ) { + let trajectory = TrajectoryEvent { + step, + obs: obs.clone(), + ledger_transitions: ledger_transitions.to_vec(), + event: event.clone(), + }; + if let Err(error) = self.store.record_trajectory(&trajectory) { + warn!( + dir = %self.dir.display(), + step, + ?error, + "trajectory write failed; continuing without it" + ); + } + } + + /// Persist the final machine state so the run can be restored later. + pub fn checkpoint(&mut self, state: &MachineState) { + if let Err(error) = self.store.checkpoint(state) { + warn!( + dir = %self.dir.display(), + ?error, + "trajectory checkpoint failed; run output is unaffected" + ); + } + } +} diff --git a/crates/accelerator/src/wire.rs b/crates/accelerator/src/wire.rs new file mode 100644 index 00000000..fdf9104c --- /dev/null +++ b/crates/accelerator/src/wire.rs @@ -0,0 +1,164 @@ +use utils::AcceleratorId; + +use crate::condition::ConditionBranch; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Channel { + Purpose, + Context, + Environment, + Resources, + Pulse, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Port { + pub owner: PortOwner, + pub endpoint: Endpoint, +} + +impl Port { + pub fn input(endpoint: Endpoint) -> Self { + Self { + owner: PortOwner::BoundaryInput, + endpoint, + } + } + + pub fn output(endpoint: Endpoint) -> Self { + Self { + owner: PortOwner::BoundaryOutput, + endpoint, + } + } + + pub fn component(component: ComponentId, endpoint: Endpoint) -> Self { + Self { + owner: PortOwner::Component(component), + endpoint, + } + } + + pub fn channel(&self) -> Channel { + match self.endpoint { + Endpoint::Trigger + | Endpoint::Done + | Endpoint::ConditionIn + | Endpoint::ConditionOut(_) => Channel::Pulse, + Endpoint::State(channel) + | Endpoint::FluxSlot { channel, .. } + | Endpoint::FluxOut(channel) => channel, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum PortOwner { + BoundaryInput, + BoundaryOutput, + Component(ComponentId), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Endpoint { + Trigger, + Done, + State(Channel), + FluxSlot { slot: usize, channel: Channel }, + FluxOut(Channel), + ConditionIn, + ConditionOut(ConditionBranch), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ComponentId(usize); + +impl ComponentId { + pub fn new(index: usize) -> Self { + Self(index) + } + + pub fn index(self) -> usize { + self.0 + } +} + +#[derive(Clone, Debug)] +pub struct ComponentRef { + id: ComponentId, + accelerator_id: Option<AcceleratorId>, +} + +impl ComponentRef { + pub(crate) fn new(id: ComponentId, accelerator_id: Option<AcceleratorId>) -> Self { + Self { id, accelerator_id } + } + + pub fn id(&self) -> ComponentId { + self.id + } + + pub fn accelerator_id(&self) -> Option<&AcceleratorId> { + self.accelerator_id.as_ref() + } + + pub fn trigger(&self) -> Port { + self.port(Endpoint::Trigger) + } + + pub fn done(&self) -> Port { + self.port(Endpoint::Done) + } + + pub fn port_state(&self, channel: Channel) -> Port { + self.port(Endpoint::State(channel)) + } + + pub fn purpose(&self) -> Port { + self.port_state(Channel::Purpose) + } + + pub fn context(&self) -> Port { + self.port(Endpoint::State(Channel::Context)) + } + + pub fn environment(&self) -> Port { + self.port(Endpoint::State(Channel::Environment)) + } + + pub fn resources(&self) -> Port { + self.port(Endpoint::State(Channel::Resources)) + } + + pub fn slot(&self, slot: usize, channel: Channel) -> Port { + self.port(Endpoint::FluxSlot { slot, channel }) + } + + pub fn flux_out(&self, channel: Channel) -> Port { + self.port(Endpoint::FluxOut(channel)) + } + + pub fn condition_in(&self) -> Port { + self.port(Endpoint::ConditionIn) + } + + pub fn condition_out(&self, branch: ConditionBranch) -> Port { + self.port(Endpoint::ConditionOut(branch)) + } + + fn port(&self, endpoint: Endpoint) -> Port { + Port::component(self.id, endpoint) + } +} + +#[derive(Clone, Debug)] +pub struct Wire { + pub from: Port, + pub to: Port, +} + +impl Wire { + pub fn new(from: Port, to: Port) -> Self { + Self { from, to } + } +} diff --git a/crates/accelerator/tests/captain.rs b/crates/accelerator/tests/captain.rs new file mode 100644 index 00000000..239a55db --- /dev/null +++ b/crates/accelerator/tests/captain.rs @@ -0,0 +1,181 @@ +use std::sync::Arc; + +use accelerator::Captain; +use machine::{ + Action, Context, Environment, ExecutionMode, Fragment, Machine, MachineState, Model, Policy, + PolicyView, Purpose, Resources, Role, RunState, Tool, ToolDefinition, ToolResult, ToolRuntime, +}; +use serde_json::json; + +struct NamedTool { + name: &'static str, +} + +impl Tool for NamedTool { + fn name(&self) -> &str { + self.name + } + + fn description(&self) -> &str { + "test tool" + } + + fn parameters(&self) -> serde_json::Value { + json!({"type": "object"}) + } + + fn execute<'a>( + &'a self, + _args: serde_json::Value, + _env: &'a Environment, + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ToolResult, String>> + Send + 'a>> + { + Box::pin(async { + Ok(ToolResult { + call_id: String::new(), + content: String::new(), + title: None, + }) + }) + } +} + +fn named_tool(name: &'static str) -> Arc<dyn Tool> { + Arc::new(NamedTool { name }) +} + +fn resources() -> Resources { + let mut resources = Resources::named("test") + .with_model(Model { + name: "fast".into(), + ..Default::default() + }) + .with_model(Model { + name: "careful".into(), + ..Default::default() + }) + .with_tool_definition(ToolDefinition::from_tool(named_tool("read").as_ref())) + .with_tool_definition(ToolDefinition::from_tool(named_tool("search").as_ref())); + resources + .prompts + .insert("captain".into(), "Captain prompt".into()); + resources +} + +async fn drive_until_halt( + captain: &Captain, + ctx: &mut Context, + resources: &mut Resources, + purpose: &str, +) { + let mut state = MachineState { + run: RunState { + purpose: Purpose::new(purpose), + run_dir: None, + context: ctx.clone(), + environment: Environment::new("."), + resources: resources.clone(), + telemetry: machine::Telemetry::default(), + }, + frame: machine::MachineFrame::default(), + }; + let mut machine = Machine::new("test", "test"); + let tool_runtime = ToolRuntime::new(); + + for _ in 0..100 { + let obs = machine::obs::measure(&state.run); + let action = captain + .decide(PolicyView { + run: &state.run, + inbox: &state.frame.inbox, + step: state.frame.step, + status: state.frame.status, + obs: &obs, + }) + .await; + match action { + Action::Halt => { + *ctx = state.run.context; + *resources = state.run.resources; + return; + } + Action::Done => panic!("captain ended before first halt"), + action => { + machine + .apply( + action, + &mut state, + ExecutionMode::Live { + tool_runtime: &tool_runtime, + overlay: &machine::Overlay::default(), + }, + ) + .await; + } + } + } + + panic!("captain did not halt within step budget"); +} + +#[tokio::test] +async fn captain_prepares_prompt_first_model_and_all_tools() { + let captain = Captain::new(); + let mut ctx = Context::new(); + let mut resources = resources(); + + drive_until_halt(&captain, &mut ctx, &mut resources, "").await; + + assert_eq!(resources.active_model, "fast"); + assert_eq!(resources.active_tools.len(), 2); + assert!(resources.active_tools.contains("read")); + assert!(resources.active_tools.contains("search")); + assert_eq!(ctx.fragments()[0].as_text(), Some("Captain prompt")); +} + +#[tokio::test] +async fn captain_normalizes_agent_prompt_to_unique_first_fragment() { + let captain = Captain::new(); + let mut ctx = Context::new(); + ctx.append(Fragment::user("existing user content")); + ctx.append(Fragment::system("old prompt").with_tag("agent")); + ctx.append(Fragment::system("extra prompt").with_tag("agent")); + let mut resources = resources(); + + drive_until_halt(&captain, &mut ctx, &mut resources, "").await; + + let agent_fragments: Vec<_> = ctx + .fragments() + .iter() + .filter(|fragment| fragment.role == Role::System && fragment.tag == "agent") + .collect(); + assert_eq!(agent_fragments.len(), 1); + assert_eq!(ctx.fragments()[0].tag, "agent"); + assert_eq!(ctx.fragments()[0].as_text(), Some("Captain prompt")); +} + +#[tokio::test] +async fn captain_appends_runtime_purpose_as_user_message() { + let captain = Captain::new(); + let mut ctx = Context::new(); + ctx.append(Fragment::system("Captain prompt").with_tag("agent")); + ctx.append(Fragment::system("Instructions").with_tag("instruction")); + ctx.append(Fragment::user("initial").with_tag("purpose")); + ctx.append(Fragment::system("env").with_tag("env")); + ctx.append(Fragment::assistant("first answer")); + let mut resources = resources(); + + drive_until_halt(&captain, &mut ctx, &mut resources, "second").await; + + let purposes: Vec<_> = ctx + .fragments() + .iter() + .filter(|fragment| fragment.tag == "purpose") + .collect(); + assert_eq!(purposes.len(), 2); + assert!(purposes.iter().all(|fragment| fragment.role == Role::User)); + assert_eq!( + purposes.last().and_then(|fragment| fragment.as_text()), + Some("second") + ); +} diff --git a/crates/accelerator/tests/common/mod.rs b/crates/accelerator/tests/common/mod.rs new file mode 100644 index 00000000..1b7f2ad5 --- /dev/null +++ b/crates/accelerator/tests/common/mod.rs @@ -0,0 +1,33 @@ +use std::future::Future; +use std::pin::Pin; + +use accelerator::Accelerator; +use machine::{Action, Policy, PolicyView, Purpose, RunState, ToolRuntime}; + +#[derive(Clone)] +pub struct DonePolicy; + +impl Policy for DonePolicy { + fn clone_box(&self) -> Box<dyn Policy> { + Box::new(self.clone()) + } + + fn decide<'a>( + &'a self, + _view: PolicyView<'a>, + ) -> Pin<Box<dyn Future<Output = Action> + Send + 'a>> { + Box::pin(async { Action::Done }) + } +} + +pub fn primitive(purpose: &str) -> Accelerator { + Accelerator::primitive( + RunState { + purpose: Purpose::new(purpose), + ..RunState::default() + }, + Box::new(DonePolicy), + ToolRuntime::new(), + purpose, + ) +} diff --git a/crates/accelerator/tests/condition.rs b/crates/accelerator/tests/condition.rs index 98f999ca..89802497 100644 --- a/crates/accelerator/tests/condition.rs +++ b/crates/accelerator/tests/condition.rs @@ -1,44 +1,52 @@ -use std::future::Future; -use std::pin::Pin; +mod common; use accelerator::{ - ContextPredicate, EnvironmentPredicate, Graph, Predicate, PurposePredicate, ResourcesPredicate, - State, + Accelerator, Channel, ConditionBranch, ContextFlux, ContextPredicate, Endpoint, + EnvironmentPredicate, FluxMode, Graph, Predicate, PurposePredicate, ResourcesPredicate, }; -use machine::{Action, Context, Environment, Fragment, Inbox, Policy, Purpose, Resources}; +use machine::{Fragment, Purpose, RunState, ToolRuntime}; -#[derive(Clone)] -struct DonePolicy; - -impl Policy for DonePolicy { - fn clone_box(&self) -> Box<dyn Policy> { - Box::new(self.clone()) +fn state_with_purpose(purpose: &str) -> RunState { + RunState { + purpose: Purpose::new(purpose), + ..RunState::default() } +} - fn decide<'a>( - &'a self, - _purpose: &'a Purpose, - _ctx: &'a Context, - _env: &'a Environment, - _resources: &'a Resources, - _inbox: &'a Inbox, - ) -> Pin<Box<dyn Future<Output = Action> + Send + 'a>> { - Box::pin(async { Action::Done }) - } +fn state_with_context(text: &str) -> RunState { + let mut state = RunState { + purpose: Purpose::new(text), + ..RunState::default() + }; + state.context.append(Fragment::assistant(text)); + state } -fn state_with_purpose(purpose: &str) -> State { - State { - purpose: purpose.to_string(), - policy: Box::new(DonePolicy), - ..State::default() - } +fn primitive_with_context(text: &str) -> Accelerator { + Accelerator::primitive( + state_with_context(text), + Box::new(common::DonePolicy), + ToolRuntime::new(), + text, + ) +} + +fn run(graph: Graph) -> RunState { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + Accelerator::composite(graph) + .run_with(RunState::default()) + .await + }) } #[test] fn predicate_composition_reads_purpose_and_environment() { let mut state = state_with_purpose("done: ready"); - state.env.vars.insert("READY".into(), "yes".into()); + state.environment.vars.insert("READY".into(), "yes".into()); let predicate = Predicate::All(vec![ Predicate::Purpose(PurposePredicate::Contains("done".into())), @@ -55,9 +63,9 @@ fn predicate_composition_reads_purpose_and_environment() { fn context_and_resources_predicates_cover_common_cases() { let mut state = state_with_purpose("irrelevant"); state - .ctx + .context .append(Fragment::assistant("research complete").with_tag("research")); - state.res.prompts.insert("judge".into(), "...".into()); + state.resources.prompts.insert("judge".into(), "...".into()); assert!(Predicate::Context(ContextPredicate::HasTag("research".into())).evaluate(&state)); assert!(Predicate::Context(ContextPredicate::Contains("complete".into())).evaluate(&state)); @@ -67,98 +75,129 @@ fn context_and_resources_predicates_cover_common_cases() { #[test] fn condition_routes_true_branch() { let mut graph = Graph::new(); - let source = graph.spawn_named("source", state_with_purpose("done")); - let true_target = graph.spawn_named("true", state_with_purpose("true-target")); - let false_target = graph.spawn_named("false", state_with_purpose("false-target")); - let condition = graph.condition_named( + let source = graph.add_accelerator("source", common::primitive("done")); + let true_target = graph.add_accelerator("true", common::primitive("true-target")); + let false_target = graph.add_accelerator("false", common::primitive("false-target")); + let condition = graph.add_condition( "route", Predicate::Purpose(PurposePredicate::Contains("done".into())), ); - graph.wire(source.done(), condition.trigger()); - graph.wire(condition.pulse_true(), true_target.trigger()); - graph.wire(condition.pulse_false(), false_target.trigger()); + graph.wire(source.done(), condition.condition_in()); + graph.wire( + condition.condition_out(ConditionBranch::True), + true_target.trigger(), + ); + graph.wire( + condition.condition_out(ConditionBranch::False), + false_target.trigger(), + ); + graph.wire( + true_target.purpose(), + Graph::output(Endpoint::State(Channel::Purpose)), + ); + graph.wire( + false_target.purpose(), + Graph::output(Endpoint::State(Channel::Purpose)), + ); - let outputs = run(graph); - assert_eq!(outputs.len(), 1); - assert_eq!(outputs[0].purpose, "true-target"); + let output = run(graph); + assert_eq!(output.purpose.text, "true-target"); } #[test] fn condition_routes_false_branch() { let mut graph = Graph::new(); - let source = graph.spawn_named("source", state_with_purpose("retry")); - let true_target = graph.spawn_named("true", state_with_purpose("true-target")); - let false_target = graph.spawn_named("false", state_with_purpose("false-target")); - let condition = graph.condition_named( + let source = graph.add_accelerator("source", common::primitive("retry")); + let true_target = graph.add_accelerator("true", common::primitive("true-target")); + let false_target = graph.add_accelerator("false", common::primitive("false-target")); + let condition = graph.add_condition( "route", Predicate::Purpose(PurposePredicate::Contains("done".into())), ); - graph.wire(source.done(), condition.trigger()); - graph.wire(condition.pulse_true(), true_target.trigger()); - graph.wire(condition.pulse_false(), false_target.trigger()); + graph.wire(source.done(), condition.condition_in()); + graph.wire( + condition.condition_out(ConditionBranch::True), + true_target.trigger(), + ); + graph.wire( + condition.condition_out(ConditionBranch::False), + false_target.trigger(), + ); + graph.wire( + true_target.purpose(), + Graph::output(Endpoint::State(Channel::Purpose)), + ); + graph.wire( + false_target.purpose(), + Graph::output(Endpoint::State(Channel::Purpose)), + ); - let outputs = run(graph); - assert_eq!(outputs.len(), 1); - assert_eq!(outputs[0].purpose, "false-target"); + let output = run(graph); + assert_eq!(output.purpose.text, "false-target"); } #[test] fn selected_branch_can_rejoin_after_unselected_branch_is_skipped() { let mut graph = Graph::new(); - let source = graph.spawn_named("source", state_with_purpose("done")); - let true_target = graph.spawn_named("true", state_with_purpose("true-target")); - let false_target = graph.spawn_named("false", state_with_purpose("false-target")); - let join = graph.spawn_named("join", state_with_purpose("joined")); - let condition = graph.condition_named( + let source = graph.add_accelerator("source", common::primitive("done")); + let true_target = graph.add_accelerator("true", common::primitive("true-target")); + let false_target = graph.add_accelerator("false", common::primitive("false-target")); + let join = graph.add_accelerator("join", common::primitive("joined")); + let condition = graph.add_condition( "route", Predicate::Purpose(PurposePredicate::Contains("done".into())), ); - graph.wire(source.done(), condition.trigger()); - graph.wire(condition.pulse_true(), true_target.trigger()); - graph.wire(condition.pulse_false(), false_target.trigger()); + graph.wire(source.done(), condition.condition_in()); + graph.wire( + condition.condition_out(ConditionBranch::True), + true_target.trigger(), + ); + graph.wire( + condition.condition_out(ConditionBranch::False), + false_target.trigger(), + ); graph.wire(true_target.done(), join.trigger()); graph.wire(false_target.done(), join.trigger()); + graph.wire( + join.purpose(), + Graph::output(Endpoint::State(Channel::Purpose)), + ); - let outputs = run(graph); - assert_eq!(outputs.len(), 1); - assert_eq!(outputs[0].purpose, "joined"); + let output = run(graph); + assert_eq!(output.purpose.text, "joined"); } #[test] -fn missing_condition_branch_is_invalid() { +fn skipped_branch_contributes_empty_flux_slot() { let mut graph = Graph::new(); - let source = graph.spawn_named("source", state_with_purpose("done")); - let target = graph.spawn_named("target", state_with_purpose("target")); - let condition = graph.condition(Predicate::Purpose(PurposePredicate::Contains( - "done".into(), - ))); - - graph.wire(source.done(), condition.trigger()); - graph.wire(condition.pulse_true(), target.trigger()); - - assert!(graph.build().is_err()); -} + let source = graph.add_accelerator("source", common::primitive("done")); + let true_target = graph.add_accelerator("true", primitive_with_context("true")); + let false_target = graph.add_accelerator("false", primitive_with_context("false")); + let join = graph.add_flux("join", FluxMode::Context(ContextFlux::Append), 2); + let condition = graph.add_condition( + "route", + Predicate::Purpose(PurposePredicate::Contains("done".into())), + ); -#[test] -#[should_panic(expected = "condition port does not belong to this graph")] -fn stale_condition_port_is_rejected() { - let mut first = Graph::new(); - let mut second = Graph::new(); - let source = second.spawn_named("source", state_with_purpose("done")); - let condition = first.condition(Predicate::Purpose(PurposePredicate::Contains( - "done".into(), - ))); - - second.wire(source.done(), condition.trigger()); -} + graph.wire(source.done(), condition.condition_in()); + graph.wire( + condition.condition_out(ConditionBranch::True), + true_target.trigger(), + ); + graph.wire( + condition.condition_out(ConditionBranch::False), + false_target.trigger(), + ); + graph.wire(true_target.context(), join.slot(0, Channel::Context)); + graph.wire(false_target.context(), join.slot(1, Channel::Context)); + graph.wire( + join.flux_out(Channel::Context), + Graph::output(Endpoint::State(Channel::Context)), + ); -fn run(graph: Graph) -> Vec<State> { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - runtime.block_on(async { graph.build().unwrap().run().await }) + let output = run(graph); + assert_eq!(output.context.fragments().len(), 1); } diff --git a/crates/accelerator/tests/fs_tool.rs b/crates/accelerator/tests/fs_tool.rs new file mode 100644 index 00000000..20c55f48 --- /dev/null +++ b/crates/accelerator/tests/fs_tool.rs @@ -0,0 +1,58 @@ +use std::fs; +use std::time::{SystemTime, UNIX_EPOCH}; + +use accelerator::tools::FsTool; +use machine::{Environment, Tool}; +use serde_json::json; + +fn unique_test_root(label: &str) -> std::path::PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("rcm-{label}-{}-{nonce}", std::process::id())) +} + +async fn assert_short_file_edit_returns_no_match(content: &str, old_string: &str) { + let root = unique_test_root("short-edit"); + fs::create_dir_all(&root).expect("test root must be created"); + let path = root.join("artifact.md"); + fs::write(&path, content).expect("fixture must be written"); + let environment = Environment::empty(&root); + let tool = FsTool; + + tool.execute(json!({"action": "read", "filePath": path}), &environment) + .await + .expect("fixture must be readable"); + let result = tool + .execute( + json!({ + "action": "edit", + "filePath": path, + "oldString": old_string, + "newString": "replacement" + }), + &environment, + ) + .await; + + assert_eq!( + result.expect_err("short files must not match"), + "No match found." + ); + assert_eq!( + fs::read_to_string(&path).expect("fixture must remain readable"), + content + ); + fs::remove_dir_all(root).expect("test root must be removed"); +} + +#[tokio::test] +async fn editing_an_empty_file_does_not_panic() { + assert_short_file_edit_returns_no_match("", "missing").await; +} + +#[tokio::test] +async fn editing_a_file_shorter_than_the_search_block_does_not_panic() { + assert_short_file_edit_returns_no_match("one line\n", "one line\nsecond line").await; +} diff --git a/crates/accelerator/tests/git_tool.rs b/crates/accelerator/tests/git_tool.rs new file mode 100644 index 00000000..155aa39c --- /dev/null +++ b/crates/accelerator/tests/git_tool.rs @@ -0,0 +1,192 @@ +use accelerator::tools::{check_git_safety, tokenize_git}; + +fn tokens(input: &str) -> Vec<String> { + tokenize_git(input).expect("tokenize") +} + +fn assert_denied(command: &str, expected_fragment: &str) { + let result = check_git_safety(&tokens(command)); + let reason = result.expect_err(&format!("expected '{command}' to be denied")); + assert!( + reason.contains(expected_fragment), + "denial reason for '{command}' was '{reason}', expected to contain '{expected_fragment}'" + ); +} + +fn assert_allowed(command: &str) { + let result = check_git_safety(&tokens(command)); + assert!( + result.is_ok(), + "expected '{command}' to be allowed, got denial: {:?}", + result.err() + ); +} + +#[test] +fn tokenize_handles_double_quoted_args() { + let result = tokenize_git("commit -m \"fix: handle empty\"").unwrap(); + assert_eq!(result, vec!["commit", "-m", "fix: handle empty"]); +} + +#[test] +fn tokenize_handles_single_quoted_args() { + let result = tokenize_git("commit -m 'a b c'").unwrap(); + assert_eq!(result, vec!["commit", "-m", "a b c"]); +} + +#[test] +fn tokenize_rejects_unbalanced_quotes() { + assert!(tokenize_git("commit -m \"unclosed").is_err()); + assert!(tokenize_git("commit -m 'unclosed").is_err()); +} + +#[test] +fn push_force_is_denied() { + assert_denied("push --force origin feature/foo", "force push"); + assert_denied("push -f origin feature/foo", "force push"); + assert_denied("push --force-with-lease origin feature/foo", "force push"); +} + +#[test] +fn push_to_main_is_denied() { + assert_denied("push origin main", "protected branch 'main'"); + assert_denied("push origin master", "protected branch 'master'"); + assert_denied("push upstream HEAD:main", "protected branch 'main'"); + assert_denied("push origin +feature/foo:main", "protected branch 'main'"); +} + +#[test] +fn push_delete_main_is_denied() { + assert_denied("push origin :main", "deleting remote branch 'main'"); +} + +#[test] +fn push_to_feature_branch_is_allowed() { + assert_allowed("push origin feature/foo"); + assert_allowed("push -u origin feature/maintainer-pipeline"); + assert_allowed("push origin HEAD:feature/foo"); +} + +#[test] +fn reset_hard_is_denied() { + assert_denied("reset --hard", "reset --hard"); + assert_denied("reset --hard HEAD~1", "reset --hard"); +} + +#[test] +fn reset_soft_and_mixed_are_allowed() { + assert_allowed("reset --soft HEAD~1"); + assert_allowed("reset HEAD~1"); + assert_allowed("reset --mixed HEAD"); +} + +#[test] +fn checkout_or_switch_main_is_denied() { + assert_denied("checkout main", "protected default branch"); + assert_denied("switch main", "protected default branch"); + assert_denied("checkout master", "protected default branch"); + assert_denied("switch master", "protected default branch"); +} + +#[test] +fn checkout_force_is_denied() { + assert_denied("checkout -f feature/foo", "force checkout"); + assert_denied("checkout --force feature/foo", "force checkout"); + assert_denied("switch --discard-changes feature/foo", "force switch"); +} + +#[test] +fn checkout_file_restore_is_denied() { + assert_denied("checkout -- src/foo.rs", "checkout -- <path>"); + assert_denied("checkout .", "checkout . discards"); +} + +#[test] +fn checkout_create_branch_is_allowed() { + assert_allowed("checkout -b feature/foo"); + assert_allowed("switch -c feature/foo"); + assert_allowed("checkout feature/foo"); +} + +#[test] +fn restore_worktree_is_denied() { + assert_denied("restore src/foo.rs", "discards changes"); + assert_denied("restore --worktree src/foo.rs", "discards changes"); + assert_denied("restore --staged --worktree src/foo.rs", "discards changes"); +} + +#[test] +fn restore_staged_only_is_allowed() { + assert_allowed("restore --staged src/foo.rs"); +} + +#[test] +fn branch_deletion_is_denied() { + assert_denied("branch -D feature/foo", "branch deletion"); + assert_denied("branch --delete feature/foo", "branch deletion"); + assert_denied("branch -d feature/foo", "branch deletion"); +} + +#[test] +fn branch_create_and_list_are_allowed() { + assert_allowed("branch feature/foo"); + assert_allowed("branch -a"); + assert_allowed("branch --list"); +} + +#[test] +fn stash_drop_and_clear_are_denied() { + assert_denied("stash drop", "stash drop"); + assert_denied("stash clear", "stash clear"); +} + +#[test] +fn stash_push_pop_apply_are_allowed() { + assert_allowed("stash"); + assert_allowed("stash push -m wip"); + assert_allowed("stash pop"); + assert_allowed("stash apply"); +} + +#[test] +fn clean_force_is_denied() { + assert_denied("clean -f", "deletes untracked"); + assert_denied("clean -fd", "deletes untracked"); + assert_denied("clean -fdx", "deletes untracked"); + assert_denied("clean --force", "deletes untracked"); +} + +#[test] +fn clean_dry_run_is_allowed() { + assert_allowed("clean -n"); + assert_allowed("clean --dry-run"); +} + +#[test] +fn no_verify_is_denied_on_commit_rebase_merge() { + assert_denied("commit --no-verify -m fix", "skips required hooks"); + assert_denied("commit --no-gpg-sign -m fix", "skips required hooks"); + assert_denied("rebase --no-verify main", "skips required hooks"); + assert_denied("merge --no-verify feature/foo", "skips required hooks"); +} + +#[test] +fn config_changes_are_denied() { + assert_denied("config user.email foo@example.com", "git config changes"); + assert_denied("config --global user.name X", "git config changes"); +} + +#[test] +fn common_safe_commands_are_allowed() { + assert_allowed("status"); + assert_allowed("log -n 10"); + assert_allowed("diff"); + assert_allowed("diff --staged"); + assert_allowed("show HEAD"); + assert_allowed("add -A"); + assert_allowed("add src/foo.rs"); + assert_allowed("commit -m \"fix: bug\""); + assert_allowed("fetch origin"); + assert_allowed("pull --rebase"); + assert_allowed("rev-parse HEAD"); +} diff --git a/crates/accelerator/tests/graph_failures.rs b/crates/accelerator/tests/graph_failures.rs new file mode 100644 index 00000000..2fedea76 --- /dev/null +++ b/crates/accelerator/tests/graph_failures.rs @@ -0,0 +1,36 @@ +use std::future::Future; +use std::pin::Pin; + +use accelerator::{Accelerator, Graph}; +use machine::{Action, Policy, PolicyView, RunState, ToolRuntime}; + +#[derive(Clone)] +struct PanicPolicy; + +impl Policy for PanicPolicy { + fn clone_box(&self) -> Box<dyn Policy> { + Box::new(self.clone()) + } + + fn decide<'a>( + &'a self, + _view: PolicyView<'a>, + ) -> Pin<Box<dyn Future<Output = Action> + Send + 'a>> { + Box::pin(async { panic!("component policy panic") }) + } +} + +#[tokio::test] +#[should_panic(expected = "component policy panic")] +async fn graph_propagates_component_panics() { + let accelerator = Accelerator::primitive( + RunState::default(), + Box::new(PanicPolicy), + ToolRuntime::new(), + "panicking_component", + ); + let mut graph = Graph::named("failure_propagation"); + graph.add_accelerator("panicking_component", accelerator); + + graph.run(RunState::default()).await; +} diff --git a/crates/accelerator/tests/graph_names.rs b/crates/accelerator/tests/graph_names.rs index 7e4c66f4..e5f842ed 100644 --- a/crates/accelerator/tests/graph_names.rs +++ b/crates/accelerator/tests/graph_names.rs @@ -1,84 +1,708 @@ -use accelerator::{ContextFlux, FluxMode, Graph, Predicate, PurposePredicate, State}; +mod common; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use accelerator::{ + Accelerator, BridgeKind, Channel, ComponentKind, ContextFlux, Endpoint, FluxMode, Graph, + ResFlux, +}; +use machine::{ + Fragment, Model, Policy, PolicyView, Purpose, Resources, RunState, ToolDefinition, ToolRuntime, +}; + +use std::future::Future; +use std::pin::Pin; + +#[derive(Clone)] +struct BarrierPolicy { + barrier: Arc<tokio::sync::Barrier>, +} + +impl Policy for BarrierPolicy { + fn clone_box(&self) -> Box<dyn Policy> { + Box::new(self.clone()) + } + + fn decide<'a>( + &'a self, + _view: PolicyView<'a>, + ) -> Pin<Box<dyn Future<Output = machine::Action> + Send + 'a>> { + Box::pin(async move { + self.barrier.wait().await; + machine::Action::Done + }) + } +} + +fn primitive_with_policy(purpose: &str, policy: Box<dyn Policy>) -> Accelerator { + Accelerator::primitive( + RunState { + purpose: Purpose::new(purpose), + ..RunState::default() + }, + policy, + ToolRuntime::new(), + purpose, + ) +} + +fn run(accelerator: Accelerator) -> RunState { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { accelerator.run_with(RunState::default()).await }) +} + +fn run_with(accelerator: Accelerator, input: RunState) -> RunState { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { accelerator.run_with(input).await }) +} + +#[test] +fn primitive_accelerator_keeps_its_id_when_cloned() { + let accelerator = common::primitive("review"); + let clone = accelerator.clone(); + + assert_eq!(accelerator.id(), clone.id()); +} #[test] -fn duplicate_accelerator_names_are_allowed() { +fn graph_components_have_stable_component_ids() { let mut graph = Graph::new(); - let first = graph.spawn_named("agent", State::default()); - let second = graph.spawn_named("agent", State::default()); + let first = graph.add_accelerator("first", common::primitive("first")); + let second = graph.add_accelerator("second", common::primitive("second")); assert_ne!(first.id(), second.id()); } #[test] -fn graph_and_nodes_may_share_names() { +fn graph_and_component_names_are_independent() { + let mut graph = Graph::named("pipeline"); + graph.add_accelerator("pipeline", common::primitive("leaf")); + + assert_eq!(graph.name.as_str(), "pipeline"); +} + +#[test] +fn flux_and_accelerator_are_distinct_component_kinds() { + let mut graph = Graph::new(); + graph.add_accelerator("agent", common::primitive("agent")); + graph.add_flux("join", FluxMode::Context(ContextFlux::Append), 1); + + let accelerator = &graph.components()[0]; + let flux = &graph.components()[1]; + + assert!(matches!(accelerator.kind, ComponentKind::Accelerator(_))); + assert!(matches!(flux.kind, ComponentKind::Flux(_))); +} + +#[test] +fn composite_accelerator_routes_context_to_output() { + let mut source_state = RunState { + purpose: Purpose::new("source"), + ..RunState::default() + }; + source_state.context.append(Fragment::assistant("done")); + + let mut graph = Graph::new(); + let source = graph.add_accelerator( + "source", + Accelerator::primitive( + source_state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "source", + ), + ); + graph.wire( + source.context(), + Graph::output(Endpoint::State(Channel::Context)), + ); + graph.wire(source.done(), Graph::output(Endpoint::Done)); + + let output = run(Accelerator::composite_named("pipeline", graph)); + + assert_eq!(output.context.fragments().len(), 1); +} + +#[test] +fn composite_accelerator_propagates_environment_run_dir_to_children() { + let run_dir = PathBuf::from("/tmp/rcm-graph-run-dir-test"); + let mut input = RunState::default(); + input.environment.cwd = PathBuf::from("/tmp/rcm-source"); + input.environment.run_dir = Some(run_dir.clone()); + + let mut graph = Graph::new(); + let child = graph.add_accelerator("child", common::primitive("child")); + graph.wire( + child.environment(), + Graph::output(Endpoint::State(Channel::Environment)), + ); + graph.wire(child.done(), Graph::output(Endpoint::Done)); + + let output = run_with(Accelerator::composite_named("pipeline", graph), input); + + assert_eq!(output.run_dir, Some(run_dir.clone())); + assert_eq!(output.environment.cwd, run_dir); + assert_eq!(output.environment.run_dir, output.run_dir); +} + +#[test] +fn resource_flux_preserves_model_order_and_tool_pool() { + let first_state = RunState { + purpose: Purpose::new("first"), + resources: Resources::named("first") + .with_model(Model { + name: "fast".into(), + ..Default::default() + }) + .with_tool_definition(ToolDefinition::from_tool(&accelerator::tools::FindTool)), + ..RunState::default() + }; + let second_state = RunState { + purpose: Purpose::new("second"), + resources: Resources::named("second") + .with_model(Model { + name: "careful".into(), + ..Default::default() + }) + .with_tool_definition(ToolDefinition::from_tool(&accelerator::tools::ShellTool)), + ..RunState::default() + }; + + let mut graph = Graph::new(); + let first = graph.add_accelerator( + "first", + Accelerator::primitive( + first_state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "first", + ), + ); + let second = graph.add_accelerator( + "second", + Accelerator::primitive( + second_state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "second", + ), + ); + let join = graph.add_flux("join", FluxMode::Resources(ResFlux::Merge), 2); + + graph.wire(first.resources(), join.slot(0, Channel::Resources)); + graph.wire(second.resources(), join.slot(1, Channel::Resources)); + graph.wire( + join.flux_out(Channel::Resources), + Graph::output(Endpoint::State(Channel::Resources)), + ); + + let output = run(Accelerator::composite_named("resources", graph)); + + assert_eq!(output.resources.model_order, vec!["fast", "careful"]); + assert!(output.resources.tool_definitions.contains_key("find")); + assert!(output.resources.tool_definitions.contains_key("shell")); +} + +#[test] +fn independent_accelerators_run_in_parallel() { + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let mut graph = Graph::new(); + graph.add_accelerator( + "first", + primitive_with_policy( + "first", + Box::new(BarrierPolicy { + barrier: barrier.clone(), + }), + ), + ); + graph.add_accelerator( + "second", + primitive_with_policy("second", Box::new(BarrierPolicy { barrier })), + ); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let completed = runtime.block_on(async { + tokio::time::timeout( + Duration::from_millis(200), + Accelerator::composite_named("parallel", graph).run_with(RunState::default()), + ) + .await + .is_ok() + }); + + assert!(completed); +} + +#[test] +fn downstream_waits_for_parallel_sources() { + let mut first_state = RunState { + purpose: Purpose::new("first"), + ..RunState::default() + }; + first_state.context.append(Fragment::assistant("first")); + let mut second_state = RunState { + purpose: Purpose::new("second"), + ..RunState::default() + }; + second_state.context.append(Fragment::assistant("second")); + + let mut graph = Graph::new(); + let first = graph.add_accelerator( + "first", + Accelerator::primitive( + first_state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "first", + ), + ); + let second = graph.add_accelerator( + "second", + Accelerator::primitive( + second_state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "second", + ), + ); + let join = graph.add_flux("join", FluxMode::Context(ContextFlux::Append), 2); + + graph.wire(first.context(), join.slot(0, Channel::Context)); + graph.wire(second.context(), join.slot(1, Channel::Context)); + graph.wire( + join.flux_out(Channel::Context), + Graph::output(Endpoint::State(Channel::Context)), + ); + + let output = run(Accelerator::composite_named("join", graph)); + + assert_eq!(output.context.fragments().len(), 2); +} + +#[test] +fn context_last_keeps_only_last_fragment_per_slot() { + let mut first_state = RunState { + purpose: Purpose::new("first"), + ..RunState::default() + }; + first_state.context.append(Fragment::assistant("first-a")); + first_state.context.append(Fragment::assistant("first-b")); + + let mut second_state = RunState { + purpose: Purpose::new("second"), + ..RunState::default() + }; + second_state.context.append(Fragment::assistant("second-a")); + second_state.context.append(Fragment::assistant("second-b")); + let mut graph = Graph::new(); - let agent = graph.spawn_named("graph", State::default()); + let first = graph.add_accelerator( + "first", + Accelerator::primitive( + first_state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "first", + ), + ); + let second = graph.add_accelerator( + "second", + Accelerator::primitive( + second_state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "second", + ), + ); + let join = graph.add_flux("join", FluxMode::Context(ContextFlux::Last), 2); + + graph.wire(first.context(), join.slot(0, Channel::Context)); + graph.wire(second.context(), join.slot(1, Channel::Context)); + graph.wire( + join.flux_out(Channel::Context), + Graph::output(Endpoint::State(Channel::Context)), + ); - assert_ne!(graph.id().as_str(), agent.id().as_str()); + let output = run(Accelerator::composite_named("last", graph)); + + assert_eq!(output.context.fragments().len(), 2); + assert_eq!(output.context.fragments()[0].as_text(), Some("first-b")); + assert_eq!(output.context.fragments()[1].as_text(), Some("second-b")); } #[test] -fn accelerator_flux_and_condition_may_share_names_but_not_ids() { +fn context_digest_extracts_key_segments() { + let mut state = RunState { + purpose: Purpose::new("search"), + ..RunState::default() + }; + // System prompt (dropped) + state + .context + .append(Fragment::system("You are a search agent")); + // Tool call (dropped) + state.context.append(Fragment::tool_call( + "tc1", + "arxiv_search", + serde_json::json!({"query": "quantum"}), + )); + // Tool result (kept) + state.context.append(Fragment::tool_result( + "tc1", + "Found 3 papers on quantum computing", + None, + )); + // Final answer (kept) + state + .context + .append(Fragment::assistant("Here are the top papers...")); + let mut graph = Graph::new(); - let agent = graph.spawn_named("shared", State::default()); - let flux = graph.weave_named("shared", 1, FluxMode::Context(ContextFlux::Append)); - let condition = graph.condition_named( - "shared", - Predicate::Purpose(PurposePredicate::Contains("done".into())), + let source = graph.add_accelerator( + "source", + Accelerator::primitive( + state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "source", + ), ); + let digest = graph.add_flux("digest", FluxMode::Context(ContextFlux::Digest), 1); - assert_ne!(agent.id().as_str(), flux.id().as_str()); - assert_ne!(agent.id().as_str(), condition.id().as_str()); - assert_ne!(flux.id().as_str(), condition.id().as_str()); + graph.wire(source.context(), digest.slot(0, Channel::Context)); + graph.wire( + digest.flux_out(Channel::Context), + Graph::output(Endpoint::State(Channel::Context)), + ); + + let output = run(Accelerator::composite_named("digest", graph)); + + assert_eq!(output.context.fragments().len(), 1); + let text = output.context.fragments()[0].as_text().unwrap(); + assert!( + text.contains("[Tool result]"), + "digest should include tool result" + ); + assert!( + text.contains("Here are the top papers"), + "digest should include final answer" + ); + assert!( + !text.contains("You are a search agent"), + "digest should drop system prompt" + ); } #[test] -fn rename_does_not_change_id() { +fn context_thread_assembles_qa_pairs() { + let mut first_state = RunState { + purpose: Purpose::new("search papers"), + ..RunState::default() + }; + first_state + .context + .append(Fragment::assistant("Found 3 papers on quantum computing")); + + let mut second_state = RunState { + purpose: Purpose::new("download best"), + ..RunState::default() + }; + second_state + .context + .append(Fragment::assistant("Downloaded arxiv:2401.12345.pdf")); + let mut graph = Graph::new(); - let before = graph.id().clone(); + let first = graph.add_accelerator( + "first", + Accelerator::primitive( + first_state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "first", + ), + ); + let second = graph.add_accelerator( + "second", + Accelerator::primitive( + second_state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "second", + ), + ); + let thread = graph.add_flux("thread", FluxMode::Context(ContextFlux::Thread), 2); + + graph.wire(first.context(), thread.slot(0, Channel::Context)); + graph.wire(second.context(), thread.slot(1, Channel::Context)); + graph.wire( + thread.flux_out(Channel::Context), + Graph::output(Endpoint::State(Channel::Context)), + ); + + let output = run(Accelerator::composite_named("thread", graph)); - graph.rename("New Graph Name"); + // Each slot contributes 2 fragments: user question + assistant answer. + assert_eq!(output.context.fragments().len(), 4); - assert_eq!(graph.id(), &before); - assert_eq!(graph.name.as_str(), "New Graph Name"); + // Slot 0: user question + assert_eq!(output.context.fragments()[0].role, machine::Role::User); + assert!( + output.context.fragments()[0] + .as_text() + .unwrap() + .contains("Task 1") + ); + // Slot 0: answer + assert_eq!(output.context.fragments()[1].role, machine::Role::Assistant); + assert_eq!( + output.context.fragments()[1].as_text(), + Some("Found 3 papers on quantum computing") + ); + + // Slot 1: user question + assert_eq!(output.context.fragments()[2].role, machine::Role::User); + assert!( + output.context.fragments()[2] + .as_text() + .unwrap() + .contains("Task 2") + ); + // Slot 1: answer + assert_eq!(output.context.fragments()[3].role, machine::Role::Assistant); + assert_eq!( + output.context.fragments()[3].as_text(), + Some("Downloaded arxiv:2401.12345.pdf") + ); } +// ── FluxMode::Bridge tests ── + #[test] -fn rename_flux_and_condition_do_not_change_ids() { +fn bridge_flattens_context_after_last_filter() { + // Flux(Last) → Bridge(Context→Purpose): Last extracts the final + // fragment per slot, Bridge flattens it into a purpose string. + let mut state = RunState { + purpose: Purpose::new("search"), + ..RunState::default() + }; + state.context.append(Fragment::assistant("first message")); + state.context.append(Fragment::assistant("second message")); + let mut graph = Graph::new(); - let flux = graph.weave_named("flux", 1, FluxMode::Context(ContextFlux::Append)); - let condition = graph.condition_named( - "condition", - Predicate::Purpose(PurposePredicate::Contains("done".into())), + let source = graph.add_accelerator( + "source", + Accelerator::primitive( + state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "source", + ), + ); + let last = graph.add_flux("last", FluxMode::Context(ContextFlux::Last), 1); + let bridge = graph.add_flux( + "bridge", + FluxMode::Bridge { + from: Channel::Context, + to: Channel::Purpose, + kind: BridgeKind::ContextToPurpose, + }, + 1, ); - let flux_id = flux.id().clone(); - let condition_id = condition.id().clone(); - graph.rename_flux(flux.clone(), "New Flux Name"); - graph.rename_condition(condition.clone(), "New Condition Name"); + graph.wire(source.context(), last.slot(0, Channel::Context)); + graph.wire( + last.flux_out(Channel::Context), + bridge.slot(0, Channel::Context), + ); + graph.wire( + bridge.flux_out(Channel::Purpose), + Graph::output(Endpoint::State(Channel::Purpose)), + ); + + let output = run(Accelerator::composite_named("bridge-last", graph)); - assert_eq!(flux.id(), &flux_id); - assert_eq!(condition.id(), &condition_id); + assert_eq!(output.purpose.text, "second message"); + assert!( + !output.purpose.text.contains("first message"), + "Last should have dropped earlier fragments" + ); } #[test] -#[should_panic(expected = "accelerator reference does not belong to this graph")] -fn stale_accelerator_ref_is_rejected() { - let mut first = Graph::new(); - let mut second = Graph::new(); - let agent = first.spawn_named("agent", State::default()); +fn bridge_flattens_full_context_after_append() { + // Flux(Append) → Bridge(Context→Purpose): all fragments pass through, + // Bridge flattens them into one purpose string. + let mut state = RunState { + purpose: Purpose::new("search"), + ..RunState::default() + }; + state.context.append(Fragment::assistant("alpha")); + state.context.append(Fragment::assistant("beta")); + + let mut graph = Graph::new(); + let source = graph.add_accelerator( + "source", + Accelerator::primitive( + state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "source", + ), + ); + let append = graph.add_flux("append", FluxMode::Context(ContextFlux::Append), 1); + let bridge = graph.add_flux( + "bridge", + FluxMode::Bridge { + from: Channel::Context, + to: Channel::Purpose, + kind: BridgeKind::ContextToPurpose, + }, + 1, + ); + + graph.wire(source.context(), append.slot(0, Channel::Context)); + graph.wire( + append.flux_out(Channel::Context), + bridge.slot(0, Channel::Context), + ); + graph.wire( + bridge.flux_out(Channel::Purpose), + Graph::output(Endpoint::State(Channel::Purpose)), + ); + + let output = run(Accelerator::composite_named("bridge-append", graph)); + assert_eq!(output.purpose.text, "alpha\n\nbeta"); +} + +#[test] +fn bridge_flattens_digested_context_to_purpose() { + // Flux(Digest) → Bridge(Context→Purpose): Digest strips scaffolding, + // Bridge flattens the remaining content. + let mut state = RunState { + purpose: Purpose::new("search"), + ..RunState::default() + }; + state + .context + .append(Fragment::system("You are a helpful assistant")); + state.context.append(Fragment::assistant("final answer")); + + let mut graph = Graph::new(); + let source = graph.add_accelerator( + "source", + Accelerator::primitive( + state, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "source", + ), + ); + let digest = graph.add_flux("digest", FluxMode::Context(ContextFlux::Digest), 1); + let bridge = graph.add_flux( + "bridge", + FluxMode::Bridge { + from: Channel::Context, + to: Channel::Purpose, + kind: BridgeKind::ContextToPurpose, + }, + 1, + ); + + graph.wire(source.context(), digest.slot(0, Channel::Context)); + graph.wire( + digest.flux_out(Channel::Context), + bridge.slot(0, Channel::Context), + ); + graph.wire( + bridge.flux_out(Channel::Purpose), + Graph::output(Endpoint::State(Channel::Purpose)), + ); - second.spawn_named("agent", State::default()); - second.rename_accelerator(agent, "renamed"); + let output = run(Accelerator::composite_named("bridge-digest", graph)); + assert_eq!( + output.purpose.text, "final answer", + "Digest strips system fragments, Bridge flattens what remains" + ); } #[test] -#[should_panic(expected = "flux port does not belong to this graph")] -fn stale_flux_port_is_rejected() { - let mut first = Graph::new(); - let mut second = Graph::new(); - let agent = second.spawn_named("agent", State::default()); - let flux = first.weave_named("shared", 1, FluxMode::Context(ContextFlux::Append)); +fn bridge_flattens_multi_slot_context_after_last() { + // Two sources → Flux(Last) → Bridge(Context→Purpose). + let mut first = RunState { + purpose: Purpose::new("search"), + ..RunState::default() + }; + first.context.append(Fragment::assistant("Found 3 papers")); + + let mut second = RunState { + purpose: Purpose::new("download"), + ..RunState::default() + }; + second + .context + .append(Fragment::assistant("Downloaded arxiv:2401.12345")); - second.wire(agent.ctx_out(), flux.slot(0)); + let mut graph = Graph::new(); + let first_acc = graph.add_accelerator( + "first", + Accelerator::primitive( + first, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "first", + ), + ); + let second_acc = graph.add_accelerator( + "second", + Accelerator::primitive( + second, + Box::new(common::DonePolicy), + ToolRuntime::new(), + "second", + ), + ); + let last = graph.add_flux("last", FluxMode::Context(ContextFlux::Last), 2); + let bridge = graph.add_flux( + "bridge", + FluxMode::Bridge { + from: Channel::Context, + to: Channel::Purpose, + kind: BridgeKind::ContextToPurpose, + }, + 1, + ); + + graph.wire(first_acc.context(), last.slot(0, Channel::Context)); + graph.wire(second_acc.context(), last.slot(1, Channel::Context)); + graph.wire( + last.flux_out(Channel::Context), + bridge.slot(0, Channel::Context), + ); + graph.wire( + bridge.flux_out(Channel::Purpose), + Graph::output(Endpoint::State(Channel::Purpose)), + ); + + let output = run(Accelerator::composite_named("bridge-multi", graph)); + assert!( + output.purpose.text.contains("Found 3 papers"), + "should include first slot" + ); + assert!( + output.purpose.text.contains("Downloaded arxiv:2401.12345"), + "should include second slot" + ); } diff --git a/crates/accelerator/tests/image_gen.rs b/crates/accelerator/tests/image_gen.rs new file mode 100644 index 00000000..cddfccba --- /dev/null +++ b/crates/accelerator/tests/image_gen.rs @@ -0,0 +1,308 @@ +use accelerator::tools::ImageGenTool; +use machine::{Environment, Tool}; +use serde_json::json; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; + +const PNG_BASE64: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +async fn image_gateway(responses: Vec<(u16, &'static str)>) -> (String, JoinHandle<Vec<String>>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test gateway should bind"); + let address = listener + .local_addr() + .expect("test gateway should expose its address"); + let handle = tokio::spawn(async move { + let mut requests = Vec::new(); + for (status, response_body) in responses { + let (mut socket, _) = listener + .accept() + .await + .expect("test gateway should accept a request"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let count = socket + .read(&mut buffer) + .await + .expect("test gateway should read a request"); + assert!(count > 0, "image request ended before its body arrived"); + request.extend_from_slice(&buffer[..count]); + let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n") + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(str::trim) + .and_then(|value| value.parse::<usize>().ok()) + }) + .expect("image request should declare content length"); + break (header_end, content_length); + }; + let expected_length = header_end + 4 + content_length; + while request.len() < expected_length { + let count = socket + .read(&mut buffer) + .await + .expect("test gateway should finish reading the request"); + assert!(count > 0, "image request body ended early"); + request.extend_from_slice(&buffer[..count]); + } + + let reason = if status == 200 { "OK" } else { "Error" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + socket + .write_all(response.as_bytes()) + .await + .expect("test gateway should return a response"); + requests.push(String::from_utf8(request).expect("image request should be UTF-8")); + } + requests + }); + (format!("http://{address}/v1/images/generations"), handle) +} + +#[test] +fn image_generation_allows_thirty_minute_requests() { + assert_eq!(ImageGenTool.timeout().as_secs(), 1_800); +} + +#[tokio::test] +async fn image_generation_requires_dedicated_api_key() { + let tool = ImageGenTool; + let environment = Environment::empty("."); + let result = tool + .execute( + json!({ + "prompt": "A compact research landscape", + "filePath": "landscape.png" + }), + &environment, + ) + .await; + + assert_eq!( + result.unwrap_err(), + "image_gen_error code=image_configuration_missing retryable=false provider_code=api_key" + ); +} + +#[tokio::test] +async fn image_generation_uses_gateway_contract_and_writes_png() { + let success_body = format!(r#"{{"data":[{{"b64_json":"{PNG_BASE64}"}}]}}"#); + let (gateway_url, received_requests) = + image_gateway(vec![(200, Box::leak(success_body.into_boxed_str()))]).await; + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "rcm_image_gateway_{}_{}", + std::process::id(), + unique + )); + tokio::fs::create_dir_all(&root) + .await + .expect("test output directory should be created"); + let mut environment = Environment::empty(&root); + environment + .vars + .insert("IMAGE_GEN_API_KEY".into(), "gateway-secret".into()); + environment + .vars + .insert("IMAGE_GEN_API_URL".into(), gateway_url); + + ImageGenTool + .execute( + json!({ + "prompt": "A red fox in an autumn forest", + "filePath": "generated.png", + "size": "1024x1024" + }), + &environment, + ) + .await + .expect("gateway image generation should succeed"); + + let requests = received_requests + .await + .expect("test gateway should finish cleanly"); + let request = &requests[0]; + let (_, body) = request + .split_once("\r\n\r\n") + .expect("request should contain an HTTP body"); + let payload: serde_json::Value = + serde_json::from_str(body).expect("image request body should be JSON"); + assert!( + request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: bearer gateway-secret")) + ); + assert_eq!( + payload, + json!({ + "model": "gpt-image-2", + "prompt": "A red fox in an autumn forest", + "size": "1024x1024", + "n": 1 + }) + ); + assert_eq!( + tokio::fs::read(root.join("generated.png")) + .await + .expect("generated image should be readable"), + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, PNG_BASE64,) + .expect("fixture should be valid base64") + ); + tokio::fs::remove_dir_all(root) + .await + .expect("test output directory should be removed"); +} + +#[tokio::test] +async fn image_generation_does_not_retry_http_400() { + let (gateway_url, received_requests) = image_gateway(vec![( + 400, + r#"{"error":{"code":"unsupported_parameter","message":"sensitive detail"}}"#, + )]) + .await; + let mut environment = Environment::empty("."); + environment + .vars + .insert("IMAGE_GEN_API_KEY".into(), "gateway-secret".into()); + environment + .vars + .insert("IMAGE_GEN_API_URL".into(), gateway_url); + + let error = ImageGenTool + .execute( + json!({"prompt": "fixed canary", "filePath": "unused.png"}), + &environment, + ) + .await + .expect_err("HTTP 400 should fail"); + + assert!(error.contains("code=image_request_rejected")); + assert!(error.contains("http_status=400")); + assert!(error.contains("retryable=false")); + assert!(error.contains("provider_code=unsupported_parameter")); + assert!(!error.contains("sensitive detail")); + assert_eq!(received_requests.await.unwrap().len(), 1); +} + +#[tokio::test] +async fn image_generation_exposes_sanitized_reason_only_in_diagnostic_mode() { + let response = r#"{"detail":"Invalid JWT: Not enough segments; credential=super-secret"}"#; + let (gateway_url, received_requests) = + image_gateway(vec![(401, response), (401, response)]).await; + let mut environment = Environment::empty("."); + environment + .vars + .insert("IMAGE_GEN_API_KEY".into(), "gateway-secret".into()); + environment + .vars + .insert("IMAGE_GEN_API_URL".into(), gateway_url); + + let ordinary_error = ImageGenTool + .execute( + json!({"prompt": "survey prompt", "filePath": "unused.png"}), + &environment, + ) + .await + .expect_err("HTTP 401 should fail"); + assert!(!ordinary_error.contains("provider_reason=")); + assert!(!ordinary_error.contains("super-secret")); + + environment + .vars + .insert("IMAGE_GEN_DIAGNOSTIC".into(), "true".into()); + let diagnostic_error = ImageGenTool + .execute( + json!({"prompt": "fixed canary", "filePath": "unused.png"}), + &environment, + ) + .await + .expect_err("diagnostic HTTP 401 should fail"); + assert!(diagnostic_error.contains("provider_reason=invalid_jwt")); + assert!(!diagnostic_error.contains("super-secret")); + assert!(!diagnostic_error.contains("Not enough segments")); + assert_eq!(received_requests.await.unwrap().len(), 2); +} + +#[tokio::test] +async fn image_generation_retries_http_429_once() { + let success_body = format!(r#"{{"data":[{{"b64_json":"{PNG_BASE64}"}}]}}"#); + let (gateway_url, received_requests) = image_gateway(vec![ + (429, r#"{"error":{"code":"rate_limit_exceeded"}}"#), + (200, Box::leak(success_body.into_boxed_str())), + ]) + .await; + let root = std::env::temp_dir().join(format!( + "rcm_image_retry_{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + tokio::fs::create_dir_all(&root).await.unwrap(); + let mut environment = Environment::empty(&root); + environment + .vars + .insert("IMAGE_GEN_API_KEY".into(), "gateway-secret".into()); + environment + .vars + .insert("IMAGE_GEN_API_URL".into(), gateway_url); + + ImageGenTool + .execute( + json!({"prompt": "fixed canary", "filePath": "retry.png"}), + &environment, + ) + .await + .expect("the one bounded retry should succeed"); + + assert_eq!(received_requests.await.unwrap().len(), 2); + tokio::fs::remove_dir_all(root).await.unwrap(); +} + +#[tokio::test] +async fn image_generation_retries_http_5xx_only_once() { + let (gateway_url, received_requests) = image_gateway(vec![ + (503, r#"{"error":{"code":"upstream_unavailable"}}"#), + (503, r#"{"error":{"code":"upstream_unavailable"}}"#), + ]) + .await; + let mut environment = Environment::empty("."); + environment + .vars + .insert("IMAGE_GEN_API_KEY".into(), "gateway-secret".into()); + environment + .vars + .insert("IMAGE_GEN_API_URL".into(), gateway_url); + + let error = ImageGenTool + .execute( + json!({"prompt": "fixed canary", "filePath": "unused.png"}), + &environment, + ) + .await + .expect_err("two HTTP 503 responses should fail"); + + assert!(error.contains("code=image_provider_unavailable")); + assert!(error.contains("http_status=503")); + assert!(error.contains("retryable=true")); + assert_eq!(received_requests.await.unwrap().len(), 2); +} diff --git a/crates/accelerator/tests/ledger_tool.rs b/crates/accelerator/tests/ledger_tool.rs new file mode 100644 index 00000000..072da737 --- /dev/null +++ b/crates/accelerator/tests/ledger_tool.rs @@ -0,0 +1,334 @@ +use accelerator::tools::{LedgerTool, ledger_digest_for}; +use machine::{Environment, Tool}; + +fn run_dir_env() -> (tempfile::TempDir, Environment) { + let dir = tempfile::tempdir().unwrap(); + let mut env = Environment::empty(dir.path()); + env.run_dir = Some(dir.path().to_path_buf()); + (dir, env) +} + +async fn call( + tool: &LedgerTool, + args: serde_json::Value, + env: &Environment, +) -> Result<String, String> { + tool.execute(args, env).await.map(|result| result.content) +} + +fn transitions_of(content: &str) -> Vec<(String, String, String)> { + let value: serde_json::Value = serde_json::from_str(content).unwrap(); + value["transitions"] + .as_array() + .unwrap() + .iter() + .map(|entry| { + ( + entry["id"].as_str().unwrap().to_string(), + entry["from"].as_str().unwrap().to_string(), + entry["to"].as_str().unwrap().to_string(), + ) + }) + .collect() +} + +#[tokio::test] +async fn legal_lifecycle_pending_running_completed() { + let (_dir, env) = run_dir_env(); + let tool = LedgerTool; + + call( + &tool, + serde_json::json!({"op": "add", "id": "scan", "title": "scan repo"}), + &env, + ) + .await + .unwrap(); + let started = call( + &tool, + serde_json::json!({"op": "start", "id": "scan"}), + &env, + ) + .await + .unwrap(); + assert_eq!( + transitions_of(&started), + vec![("scan".into(), "pending".into(), "running".into())] + ); + + let done = call( + &tool, + serde_json::json!({"op": "complete", "id": "scan", "result": "3 modules"}), + &env, + ) + .await + .unwrap(); + assert_eq!( + transitions_of(&done), + vec![("scan".into(), "running".into(), "completed".into())] + ); +} + +#[tokio::test] +async fn illegal_transition_completed_to_running_is_rejected() { + let (_dir, env) = run_dir_env(); + let tool = LedgerTool; + + call( + &tool, + serde_json::json!({"op": "add", "id": "one", "title": "work"}), + &env, + ) + .await + .unwrap(); + call(&tool, serde_json::json!({"op": "start", "id": "one"}), &env) + .await + .unwrap(); + call( + &tool, + serde_json::json!({"op": "complete", "id": "one"}), + &env, + ) + .await + .unwrap(); + + let error = call(&tool, serde_json::json!({"op": "start", "id": "one"}), &env) + .await + .unwrap_err(); + assert!(error.contains("illegal ledger transition"), "got: {error}"); +} + +#[tokio::test] +async fn start_with_uncompleted_deps_is_rejected() { + let (_dir, env) = run_dir_env(); + let tool = LedgerTool; + + call( + &tool, + serde_json::json!({"op": "add", "id": "dep", "title": "dependency"}), + &env, + ) + .await + .unwrap(); + call( + &tool, + serde_json::json!({"op": "add", "id": "child", "title": "dependent", "deps": ["dep"]}), + &env, + ) + .await + .unwrap(); + + let error = call( + &tool, + serde_json::json!({"op": "start", "id": "child"}), + &env, + ) + .await + .unwrap_err(); + assert!(error.contains("uncompleted deps"), "got: {error}"); +} + +#[tokio::test] +async fn completing_dependency_promotes_ready_dependent() { + let (_dir, env) = run_dir_env(); + let tool = LedgerTool; + + call( + &tool, + serde_json::json!({"op": "add", "id": "dep", "title": "dependency"}), + &env, + ) + .await + .unwrap(); + call( + &tool, + serde_json::json!({"op": "add", "id": "child", "title": "dependent", "deps": ["dep"]}), + &env, + ) + .await + .unwrap(); + call(&tool, serde_json::json!({"op": "start", "id": "dep"}), &env) + .await + .unwrap(); + + let done = call( + &tool, + serde_json::json!({"op": "complete", "id": "dep"}), + &env, + ) + .await + .unwrap(); + let transitions = transitions_of(&done); + assert_eq!( + transitions, + vec![ + ("dep".into(), "running".into(), "completed".into()), + // promotion fired by code, not by the model + ("child".into(), "pending".into(), "running".into()), + ] + ); +} + +#[tokio::test] +async fn digest_reflects_status_counts_and_current_entry() { + let (dir, env) = run_dir_env(); + let tool = LedgerTool; + + call( + &tool, + serde_json::json!({"op": "add", "id": "a", "title": "first"}), + &env, + ) + .await + .unwrap(); + call( + &tool, + serde_json::json!({"op": "add", "id": "b", "title": "second"}), + &env, + ) + .await + .unwrap(); + call(&tool, serde_json::json!({"op": "start", "id": "b"}), &env) + .await + .unwrap(); + + let digest = ledger_digest_for(dir.path()).expect("digest after writes"); + assert_eq!(digest.total, 2); + assert_eq!(digest.by_status.get("pending"), Some(&1)); + assert_eq!(digest.by_status.get("running"), Some(&1)); + let current = digest.current_entry.expect("current entry"); + assert_eq!(current.id, "b"); + assert_eq!(current.status, "running"); +} + +#[tokio::test] +async fn digest_is_none_for_untouched_run_dir() { + let dir = tempfile::tempdir().unwrap(); + assert!(ledger_digest_for(dir.path()).is_none()); +} + +#[tokio::test] +async fn ledger_persists_to_run_dir_json() { + let (dir, env) = run_dir_env(); + let tool = LedgerTool; + + call( + &tool, + serde_json::json!({"op": "add", "id": "kept", "title": "survives"}), + &env, + ) + .await + .unwrap(); + + let persisted = dir.path().join("ledger.json"); + assert!(persisted.is_file(), "ledger.json must exist under run_dir"); + let raw = std::fs::read_to_string(&persisted).unwrap(); + let book: serde_json::Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(book["kept"]["status"], "pending"); +} + +#[tokio::test] +async fn no_run_dir_leaves_no_file_behind() { + let env = Environment::empty("."); + let tool = LedgerTool; + + call( + &tool, + serde_json::json!({"op": "add", "id": "ephemeral", "title": "no file"}), + &env, + ) + .await + .unwrap(); + + assert!( + !std::path::Path::new("ledger.json").exists(), + "no run_dir means no ledger file in the working directory" + ); +} + +#[tokio::test] +async fn tool_result_transitions_lift_into_machine_parser() { + let (_dir, env) = run_dir_env(); + let tool = LedgerTool; + + call( + &tool, + serde_json::json!({"op": "add", "id": "x", "title": "lift"}), + &env, + ) + .await + .unwrap(); + let started = call(&tool, serde_json::json!({"op": "start", "id": "x"}), &env) + .await + .unwrap(); + + // The tool result is a machine-side parseable tool-result fragment body. + let fragment = machine::Fragment::tool_result("call-1", started, None); + let effect = machine::Effect::InboxPushed { + item: machine::InboxItem::new(fragment, None), + }; + let lifted = machine::ledger_transitions_in(&[effect]); + assert_eq!(lifted.len(), 1); + assert_eq!(lifted[0].entry_id, "x"); + assert_eq!(lifted[0].from_status, "pending"); + assert_eq!(lifted[0].to_status, "running"); +} + +#[tokio::test] +async fn concurrent_transitions_on_shared_run_dir_stay_consistent() { + let (dir, env) = run_dir_env(); + let tool = std::sync::Arc::new(LedgerTool); + + call( + &tool, + serde_json::json!({"op": "add", "id": "shared", "title": "contended entry"}), + &env, + ) + .await + .unwrap(); + call( + &tool, + serde_json::json!({"op": "start", "id": "shared"}), + &env, + ) + .await + .unwrap(); + + // Two racing completions: exactly one may win the running→completed + // migration; the loser must receive an illegal-transition error, and + // the persisted book must stay consistent with the winner. + let env_arc = std::sync::Arc::new(env); + let mut handles: Vec<tokio::task::JoinHandle<Result<String, String>>> = Vec::new(); + for _ in 0..2 { + let tool = tool.clone(); + let env = env_arc.clone(); + handles.push(tokio::spawn(async move { + call( + &tool, + serde_json::json!({"op": "complete", "id": "shared", "result": "done"}), + &env, + ) + .await + })); + } + let joined = futures_util::future::join_all(handles).await; + let outcomes: Vec<Result<String, String>> = joined + .into_iter() + .map(|outcome| outcome.expect("ledger task must not panic")) + .collect(); + let successes = outcomes.iter().filter(|outcome| outcome.is_ok()).count(); + let rejections = outcomes + .iter() + .filter_map(|outcome| outcome.as_ref().err()) + .collect::<Vec<_>>(); + assert_eq!(successes, 1, "exactly one completion may win"); + assert_eq!(rejections.len(), 1); + assert!( + rejections[0].contains("illegal ledger transition"), + "got: {}", + rejections[0] + ); + + let digest = ledger_digest_for(dir.path()).expect("digest after writes"); + assert_eq!(digest.by_status.get("completed"), Some(&1)); +} diff --git a/crates/accelerator/tests/lsp_client.rs b/crates/accelerator/tests/lsp_client.rs new file mode 100644 index 00000000..e3ccfcfc --- /dev/null +++ b/crates/accelerator/tests/lsp_client.rs @@ -0,0 +1,78 @@ +use std::fs; + +use accelerator::lsp::LspClient; +use accelerator::lsp::ServerSpec; + +const FAKE_SERVER: &str = r#" +import json, sys + +def read_msg(): + length = None + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + if line in (b'\r\n', b'\n'): + break + name, value = line.decode().split(':', 1) + if name.lower() == 'content-length': + length = int(value.strip()) + return json.loads(sys.stdin.buffer.read(length).decode()) + +def write_msg(msg): + body = json.dumps(msg, separators=(',', ':')).encode() + sys.stdout.buffer.write(b'Content-Length: ' + str(len(body)).encode() + b'\r\n\r\n' + body) + sys.stdout.buffer.flush() + +while True: + msg = read_msg() + if msg is None: + break + if msg.get('method') == 'initialize': + write_msg({'jsonrpc':'2.0','id':msg['id'],'result':{'capabilities':{'textDocumentSync':1}}}) + elif msg.get('method') == 'textDocument/didOpen': + doc = msg['params']['textDocument'] + write_msg({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':doc['uri'],'version':doc['version'],'diagnostics':[{'range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}},'severity':1,'message':'fake error'}]}}) + elif msg.get('method') == 'textDocument/didChange': + doc = msg['params']['textDocument'] + write_msg({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':doc['uri'],'version':doc['version'],'diagnostics':[{'range':{'start':{'line':1,'character':0},'end':{'line':1,'character':1}},'severity':1,'message':'changed error'}]}}) +"#; + +#[tokio::test] +async fn fake_lsp_server_reports_versioned_diagnostics() { + if std::process::Command::new("python3") + .arg("--version") + .output() + .is_err() + { + eprintln!("skipping fake LSP test: python3 not found"); + return; + } + let root = std::env::temp_dir().join(format!("rcm_fake_lsp_{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + let path = root.join("lib.rs"); + fs::write(&path, "fn main() {}\n").unwrap(); + let server = ServerSpec { + id: "fake-lsp", + language_id: "rust", + extensions: &["rs"], + root_markers: &["Cargo.toml"], + command: "python3", + args: &[], + }; + let client = + LspClient::start_with_command(server, root.clone(), "python3", &["-u", "-c", FAKE_SERVER]) + .await + .unwrap(); + let first = client + .touch_file_with_text(&path, "fn main() {}\n", true) + .await + .unwrap(); + assert_eq!(first[0].message, "fake error"); + let second = client + .touch_file_with_text(&path, "fn main() {}\nlet x = 1;\n", true) + .await + .unwrap(); + assert_eq!(second[0].message, "changed error"); + fs::remove_dir_all(root).ok(); +} diff --git a/crates/accelerator/tests/mcp.rs b/crates/accelerator/tests/mcp.rs index b729ef6d..0ec2146b 100644 --- a/crates/accelerator/tests/mcp.rs +++ b/crates/accelerator/tests/mcp.rs @@ -1,73 +1,75 @@ -use accelerator::mcp::McpServerConfig; +use std::collections::HashMap; +use std::path::PathBuf; -#[test] -fn parse_stdio_simple_command() { - let cfg = McpServerConfig::parse("gh=npx -y @modelcontextprotocol/server-github").unwrap(); - assert_eq!(cfg.label, "gh"); - assert_eq!(cfg.command.as_deref(), Some("npx")); - assert_eq!(cfg.args, vec!["-y", "@modelcontextprotocol/server-github"]); - assert!(cfg.url.is_none()); - assert!(cfg.headers.is_empty()); -} +use accelerator::mcp::{McpServerConfig, McpTransportConfig}; #[test] -fn parse_stdio_no_args() { - let cfg = McpServerConfig::parse("myapp=./my-server").unwrap(); - assert_eq!(cfg.label, "myapp"); - assert_eq!(cfg.command.as_deref(), Some("./my-server")); - assert!(cfg.args.is_empty()); - assert!(cfg.url.is_none()); -} +fn stdio_config_keeps_process_parameters() { + let mut env = HashMap::new(); + env.insert("API_KEY".to_string(), "secret".to_string()); + let config = McpServerConfig { + label: "docs".into(), + transport: McpTransportConfig::Stdio { + command: "npx".into(), + args: vec!["-y".into(), "@example/server".into()], + env, + cwd: Some(PathBuf::from(".")), + }, + }; -#[test] -fn parse_http_bare_url() { - let cfg = McpServerConfig::parse("search=https://api.anysearch.com/mcp").unwrap(); - assert_eq!(cfg.label, "search"); - assert!(cfg.command.is_none()); - assert_eq!(cfg.url.as_deref(), Some("https://api.anysearch.com/mcp")); - assert!(cfg.headers.is_empty()); + match config.transport { + McpTransportConfig::Stdio { + command, + args, + env, + cwd, + } => { + assert_eq!(command, "npx"); + assert_eq!(args, vec!["-y", "@example/server"]); + assert_eq!(env.get("API_KEY").map(String::as_str), Some("secret")); + assert_eq!(cwd, Some(PathBuf::from("."))); + } + _ => panic!("expected stdio config"), + } } #[test] -fn parse_http_with_headers() { - let cfg = McpServerConfig::parse( - "search=https://api.anysearch.com/mcp|Authorization:Bearer tok123|X-Custom:val", - ) - .unwrap(); - assert_eq!(cfg.label, "search"); - assert_eq!(cfg.url.as_deref(), Some("https://api.anysearch.com/mcp")); - assert_eq!( - cfg.headers, - vec![ - ("Authorization".into(), "Bearer tok123".into()), - ("X-Custom".into(), "val".into()), - ] - ); -} +fn http_config_keeps_endpoint_and_headers() { + let config = McpServerConfig { + label: "remote".into(), + transport: McpTransportConfig::Http { + url: "https://example.com/mcp".into(), + headers: vec![ + ("Authorization".into(), "Bearer token".into()), + ("X-Project".into(), "RCM".into()), + ], + }, + }; -#[test] -fn parse_http_with_colon_in_value() { - let cfg = McpServerConfig::parse( - "search=https://api.anysearch.com/mcp|Authorization:Bearer tok:123:456", - ) - .unwrap(); - assert_eq!( - cfg.headers[0], - ("Authorization".into(), "Bearer tok:123:456".into()) - ); + match config.transport { + McpTransportConfig::Http { url, headers } => { + assert_eq!(url, "https://example.com/mcp"); + assert_eq!(headers.len(), 2); + } + _ => panic!("expected http config"), + } } #[test] -fn parse_missing_label() { - assert!(McpServerConfig::parse("=npx foo").is_err()); -} +fn sse_config_marks_legacy_transport_explicitly() { + let config = McpServerConfig { + label: "legacy".into(), + transport: McpTransportConfig::Sse { + url: "https://example.com/sse".into(), + headers: Vec::new(), + }, + }; -#[test] -fn parse_missing_value() { - assert!(McpServerConfig::parse("myapp=").is_err()); -} - -#[test] -fn parse_no_equals() { - assert!(McpServerConfig::parse("justatext").is_err()); + match config.transport { + McpTransportConfig::Sse { url, headers } => { + assert_eq!(url, "https://example.com/sse"); + assert!(headers.is_empty()); + } + _ => panic!("expected sse config"), + } } diff --git a/crates/accelerator/tests/provider.rs b/crates/accelerator/tests/provider.rs index 655a5212..c36e9903 100644 --- a/crates/accelerator/tests/provider.rs +++ b/crates/accelerator/tests/provider.rs @@ -18,7 +18,9 @@ struct EnvGuard { impl EnvGuard { fn new(keys: &[&'static str]) -> Self { - let lock = ENV_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let saved = keys .iter() .map(|key| (key.to_string(), std::env::var(key).ok())) @@ -66,7 +68,10 @@ fn resolves_explicit_provider_slash_model() { let model = provider::resolve_model(Some("sii/gpt-4.1")).expect("resolve"); assert_eq!(model.name, "sii/gpt-4.1"); - assert_eq!(model.endpoint.as_deref(), Some("https://apicz.boyuerichdata.com/v1")); + assert_eq!( + model.endpoint.as_deref(), + Some("https://apicz.boyuerichdata.com/v1") + ); assert_eq!(model.credentials.as_deref(), Some("sk-test")); // Cost / Limit / Modalities preserved from MODEL_PRESETS. assert!(model.cost.is_some()); diff --git a/crates/accelerator/tests/shell_tool.rs b/crates/accelerator/tests/shell_tool.rs new file mode 100644 index 00000000..3b0ef05d --- /dev/null +++ b/crates/accelerator/tests/shell_tool.rs @@ -0,0 +1,81 @@ +use accelerator::tools::shell::{OUTPUT_CAP_BYTES, build_result, collect_output}; +use std::process::Stdio; +use tokio::process::Command; + +#[test] +fn build_result_truncates_oversized_output() { + let big = "x".repeat(OUTPUT_CAP_BYTES + 1024); + let result = build_result("echo".into(), big.into_bytes(), Vec::new(), Some(0)); + let output = result.unwrap(); + assert!( + output.content.len() <= OUTPUT_CAP_BYTES + 64, + "output should be capped to OUTPUT_CAP_BYTES + suffix" + ); + assert!( + output.content.contains("truncated"), + "truncation marker should be appended" + ); +} + +#[test] +fn build_result_passes_small_output() { + let result = build_result("echo".into(), b"hello".to_vec(), Vec::new(), Some(0)); + let output = result.unwrap(); + assert_eq!(output.content, "hello"); + assert!(!output.content.contains("truncated")); +} + +#[test] +fn build_result_includes_stderr() { + let result = build_result( + "sh".into(), + Vec::new(), + b"warning: something".to_vec(), + Some(1), + ); + let output = result.unwrap(); + assert!(output.content.contains("warning")); +} + +#[tokio::test] +async fn collect_output_caps_at_output_cap_bytes() { + // Use head -c to generate output larger than OUTPUT_CAP_BYTES. + // This avoids passing a huge argument on the command line (ARG_MAX). + let generate = format!( + "head -c {} < /dev/zero | tr '\\0' x", + OUTPUT_CAP_BYTES + OUTPUT_CAP_BYTES / 2 + ); + let mut child = Command::new("sh") + .arg("-c") + .arg(&generate) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + let (stdout, stderr, _exit_code) = collect_output(&mut child).await; + + assert!( + stdout.len() <= OUTPUT_CAP_BYTES, + "collect_output should cap stdout at OUTPUT_CAP_BYTES (got {})", + stdout.len() + ); + let _ = stderr; +} + +#[tokio::test] +async fn collect_output_passes_small_through() { + let mut child = Command::new("sh") + .arg("-c") + .arg("echo hello world") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + let (stdout, stderr, _exit_code) = collect_output(&mut child).await; + + let text = String::from_utf8_lossy(&stdout); + assert!(text.contains("hello world")); + let _ = stderr; +} diff --git a/crates/accelerator/tests/spawn.rs b/crates/accelerator/tests/spawn.rs new file mode 100644 index 00000000..e473093a --- /dev/null +++ b/crates/accelerator/tests/spawn.rs @@ -0,0 +1,343 @@ +use accelerator::tools::SpawnTool; +use machine::Fragment; +use machine::Tool; + +/// Builtin fragments that act as actual scaffolding so the reorder logic in +/// `fire()` finds an `env` tag and stops looping. +fn scaffolding_env() -> Fragment { + Fragment::system("cwd: .\nplatform: test").with_tag("env") +} + +/// An inner accelerator that halts immediately and leaves a well-known `status:` +/// line in the output context. The status text is set as the base purpose +/// (which `merge_input` always preserves), and the output context gets it via +/// the reorder step copying the purpose tag. +fn worker_with_status(name: &str, status: &str) -> accelerator::Accelerator { + worker_with_handoff(name, &format!("status: {status}")) +} + +fn worker_with_handoff(name: &str, handoff: &str) -> accelerator::Accelerator { + let mut ctx = machine::Context::default(); + ctx.append(scaffolding_env()); + let state = machine::RunState { + purpose: machine::Purpose::new(handoff), + environment: machine::Environment::empty("."), + context: ctx, + ..machine::RunState::default() + }; + accelerator::Accelerator::primitive(state, done_policy(), machine::ToolRuntime::new(), name) +} + +fn done_policy() -> Box<dyn machine::Policy> { + use machine::{Action, Policy, PolicyView}; + use std::future::Future; + use std::pin::Pin; + struct Done; + impl Policy for Done { + fn clone_box(&self) -> Box<dyn Policy> { + Box::new(Done) + } + fn name(&self) -> &str { + "done" + } + fn decide<'a>( + &'a self, + _view: PolicyView<'a>, + ) -> Pin<Box<dyn Future<Output = Action> + Send + 'a>> { + Box::pin(async { Action::Done }) + } + } + Box::new(Done) +} + +#[tokio::test] +async fn spawn_single_item_reports_ok() { + let tool = SpawnTool::new("spawn_test", worker_with_status("inner", "ok")); + let result = tool + .execute( + serde_json::json!({"items": [{"id": "2401.001", "title": "Test Paper"}]}), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should succeed"); + assert!( + result.content.contains("1 items"), + "report: {}", + result.content + ); + assert!( + result.content.contains("ok=1"), + "report: {}", + result.content + ); +} + +#[tokio::test] +async fn spawn_multiple_items_aggregates_results() { + let tool = SpawnTool::new("spawn_test", worker_with_status("inner", "ok")); + let items: Vec<serde_json::Value> = (1..=5) + .map(|i| serde_json::json!({"id": format!("item_{i}")})) + .collect(); + let result = tool + .execute( + serde_json::json!({"items": items}), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should succeed"); + assert!( + result.content.contains("5 items"), + "report: {}", + result.content + ); + assert!( + result.content.contains("ok=5"), + "report: {}", + result.content + ); +} + +#[tokio::test] +async fn spawn_mixed_status_groups_success_and_failure() { + let tool = SpawnTool::new("spawn_test", worker_with_status("inner", "ok")); + let items = vec![ + serde_json::json!({"id": "A"}), + serde_json::json!({"id": "B"}), + serde_json::json!({"id": "C"}), + ]; + let result = tool + .execute( + serde_json::json!({"items": items, "max_parallel": 2}), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should succeed"); + assert!( + result.content.contains("ok=3"), + "report: {}", + result.content + ); +} + +#[tokio::test] +async fn spawn_empty_items_returns_empty_report() { + let tool = SpawnTool::new("spawn_test", worker_with_status("inner", "ok")); + let result = tool + .execute( + serde_json::json!({"items": []}), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should succeed"); + assert!( + result.content.contains("empty"), + "report: {}", + result.content + ); +} + +#[tokio::test] +async fn spawn_respects_max_parallel_concurrency() { + let tool = SpawnTool::new("spawn_test", worker_with_status("inner", "ok")); + let items: Vec<serde_json::Value> = (1..=10) + .map(|i| serde_json::json!({"id": format!("item_{i}")})) + .collect(); + let start = std::time::Instant::now(); + let result = tool + .execute( + serde_json::json!({"items": items, "max_parallel": 3}), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should succeed"); + // Even with fast Done workers, bounded concurrency should not increase + // the total count. + assert!( + result.content.contains("10 items"), + "report: {}", + result.content + ); + assert!( + result.content.contains("ok=10"), + "report: {}", + result.content + ); + let _elapsed = start.elapsed(); +} + +#[tokio::test] +async fn spawn_partial_status_counts_as_completed_not_failed() { + // `partial` is a success per schema/handoff.md (the worker produced its + // artifact). It must not be surfaced as a failure for retry. + let tool = SpawnTool::new("spawn_test", worker_with_status("inner", "partial")); + let items = vec![ + serde_json::json!({"id": "P1"}), + serde_json::json!({"id": "P2"}), + ]; + let result = tool + .execute( + serde_json::json!({"items": items}), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should succeed"); + assert!( + result.content.contains("partial=2"), + "report: {}", + result.content + ); + assert!( + result.content.contains("All items completed successfully."), + "report: {}", + result.content + ); + assert!( + !result.content.contains("failed="), + "report: {}", + result.content + ); +} + +#[tokio::test] +async fn spawn_lenient_status_parsing_tolerates_trailing_note() { + // A worker that annotates its status (`status: ok (image-only PDF)`) is + // still classified as ok, not an unknown failure. + let tool = SpawnTool::new( + "spawn_test", + worker_with_status("inner", "ok (image-only PDF)"), + ); + let result = tool + .execute( + serde_json::json!({"items": [{"id": "L1"}]}), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should succeed"); + assert!( + result.content.contains("ok=1"), + "report: {}", + result.content + ); + assert!( + !result.content.contains("failed="), + "report: {}", + result.content + ); +} + +#[tokio::test] +async fn spawn_accepts_markdown_emphasis_around_status_key() { + let tool = SpawnTool::new( + "spawn_test", + worker_with_handoff("inner", "- **status**: **ok** (full text)"), + ); + let result = tool + .execute( + serde_json::json!({"items": [{"id": "M1"}]}), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should succeed"); + + assert!( + result.content.contains("ok=1"), + "report: {}", + result.content + ); + assert!( + !result.content.contains("failed="), + "report: {}", + result.content + ); +} + +#[tokio::test] +async fn spawn_failure_report_identifies_section_items_without_id() { + let tool = SpawnTool::new("spawn_section", worker_with_status("inner", "blocked")); + let result = tool + .execute( + serde_json::json!({ + "items": [{"n": "07", "slug": "open-problems"}] + }), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should return a failure report"); + + assert!( + result.content.contains("failed=1"), + "report: {}", + result.content + ); + assert!( + result.content.contains("✗ 07"), + "report: {}", + result.content + ); + assert!( + !result.content.contains("✗ ?"), + "report: {}", + result.content + ); +} + +#[tokio::test] +async fn spawn_caps_model_requested_parallelism() { + let tool = SpawnTool::new("spawn_test", worker_with_status("inner", "ok")); + let result = tool + .execute( + serde_json::json!({ + "items": [{"id": "A"}, {"id": "B"}], + "max_parallel": 100_000 + }), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should cap concurrency rather than reject the batch"); + + assert!( + result.content.contains("max_parallel=32"), + "report: {}", + result.content + ); +} + +#[tokio::test] +async fn spawn_rejects_unbounded_item_batches() { + let tool = SpawnTool::new("spawn_test", worker_with_status("inner", "ok")); + let items: Vec<serde_json::Value> = (0..1_025) + .map(|index| serde_json::json!({"id": format!("item_{index}")})) + .collect(); + + let error = tool + .execute( + serde_json::json!({"items": items}), + &machine::Environment::empty("."), + ) + .await + .expect_err("oversized spawn batches must be rejected before execution"); + + assert!(error.contains("1024"), "error: {error}"); +} + +#[tokio::test] +async fn spawn_uses_the_last_handoff_status() { + let tool = SpawnTool::new( + "spawn_test", + worker_with_handoff("inner", "status: blocked\nstatus: ok"), + ); + + let result = tool + .execute( + serde_json::json!({"items": [{"id": "recovered"}]}), + &machine::Environment::empty("."), + ) + .await + .expect("spawn should use the final worker status"); + + assert!( + result.content.contains("ok=1"), + "report: {}", + result.content + ); +} diff --git a/crates/accelerator/tests/trajectory.rs b/crates/accelerator/tests/trajectory.rs new file mode 100644 index 00000000..77b7e751 --- /dev/null +++ b/crates/accelerator/tests/trajectory.rs @@ -0,0 +1,137 @@ +use std::future::Future; +use std::pin::Pin; + +use accelerator::Accelerator; +use machine::{Action, Fragment, Policy, PolicyView, Purpose, RunState, ToolRuntime}; +use storage::Store; + +/// Scripted policy: append one fragment, then finish. Exercises the +/// trajectory recorder through the real accelerator run loop without any +/// LLM dependency. +#[derive(Clone)] +struct AppendOncePolicy { + fragment_text: String, +} + +impl Policy for AppendOncePolicy { + fn clone_box(&self) -> Box<dyn Policy> { + Box::new(self.clone()) + } + + fn decide<'a>( + &'a self, + view: PolicyView<'a>, + ) -> Pin<Box<dyn Future<Output = Action> + Send + 'a>> { + let already_appended = view + .run + .context + .fragments() + .iter() + .any(|fragment| fragment.as_text() == Some(self.fragment_text.as_str())); + let fragment_text = self.fragment_text.clone(); + Box::pin(async move { + if already_appended { + Action::Done + } else { + Action::Append(Fragment::user(fragment_text)) + } + }) + } +} + +#[tokio::test] +async fn run_with_run_dir_records_restorable_trajectory() { + let run_dir = tempfile::tempdir().unwrap(); + let accelerator = Accelerator::primitive( + RunState::default(), + Box::new(AppendOncePolicy { + fragment_text: "recorded step".into(), + }), + ToolRuntime::new(), + "trajectory-test", + ); + + let mut input = RunState { + purpose: Purpose::new("verify trajectory recording"), + ..RunState::default() + }; + input.run_dir = Some(run_dir.path().to_path_buf()); + let output = accelerator.run_with(input).await; + + assert_eq!( + output.context.fragments().last().and_then(|f| f.as_text()), + Some("recorded step") + ); + + // The recorder created exactly one per-machine WAL under trajectory/. + let trajectory_root = run_dir.path().join("trajectory"); + let machine_dirs: Vec<_> = std::fs::read_dir(&trajectory_root) + .expect("trajectory directory must exist when run_dir is set") + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!( + machine_dirs.len(), + 1, + "one machine means exactly one trajectory WAL" + ); + assert!( + machine_dirs[0] + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("trajectory-test-"), + "WAL directory is labeled with accelerator name and id, got {:?}", + machine_dirs[0] + ); + + // The recorded WAL restores to the same final state as the live run. + let store = Store::open(&machine_dirs[0]).unwrap(); + let restored = store.restore().await.unwrap().expect("recorded state"); + assert_eq!( + restored.run.context.fragments(), + output.context.fragments(), + "restored context must equal live run output" + ); + assert_eq!(restored.frame.step, 2, "append + done = two steps"); + + let trajectories = store.trajectories().await.unwrap(); + assert_eq!(trajectories.len(), 2); + assert_eq!( + trajectories[0].event.action, + Action::Append(Fragment::user("recorded step")) + ); + assert_eq!(trajectories[1].event.action, Action::Done); + // Decision-time observation snapshots ride along with every event. + assert_eq!(trajectories[0].step, 1); + assert_eq!(trajectories[0].obs.budget.estimated_input, 0); +} + +#[tokio::test] +async fn run_without_run_dir_writes_nothing() { + let accelerator = Accelerator::primitive( + RunState::default(), + Box::new(AppendOncePolicy { + fragment_text: "no recording".into(), + }), + ToolRuntime::new(), + "no-run-dir", + ); + let output = accelerator + .run_with(RunState { + purpose: Purpose::new("no run dir"), + ..RunState::default() + }) + .await; + + assert_eq!( + output.context.fragments().last().and_then(|f| f.as_text()), + Some("no recording") + ); + // Recording is opt-in: without run_dir nothing is written anywhere, + // including the process working directory (no chdir tricks needed — + // the recorder is simply None). + assert!( + !std::path::Path::new("trajectory").exists(), + "no run_dir means no trajectory directory in the working directory" + ); +} diff --git a/crates/accelerator/tests/webfetch.rs b/crates/accelerator/tests/webfetch.rs new file mode 100644 index 00000000..0172593e --- /dev/null +++ b/crates/accelerator/tests/webfetch.rs @@ -0,0 +1,11 @@ +use accelerator::tools::WebFetchTool; +use machine::{Environment, Tool}; +use serde_json::json; + +#[tokio::test] +async fn fetch_bad_url_returns_error() { + let tool = WebFetchTool; + let args = json!({"url": "https://nonexistent-domain-12345.example"}); + let result = tool.execute(args, &Environment::new(".")).await; + assert!(result.is_err()); +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 7da9d926..fb5043fc 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,19 +1,27 @@ [package] name = "cli" -version = "0.1.0" +version = "0.2.20" edition = "2024" +publish = false [[bin]] name = "accelerate" path = "src/main.rs" +[lib] +name = "cli" +path = "src/lib.rs" + [dependencies] -machine = { path = "../machine" } -accelerator = { path = "../accelerator" } +machine = { version = "0.1.0", path = "../machine" } +accelerator = { version = "0.1.0", path = "../accelerator" } tokio = { workspace = true, features = ["rt", "macros"] } anyhow = { workspace = true } clap = { version = "4", features = ["derive"] } -tracing = "0.1" +tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } +serde = { version = "1", features = ["derive"] } serde_json = { workspace = true } crossterm = "0.28" +toml = "0.8" +chrono = { workspace = true } diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index 4072eb6b..7057841a 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -1,4 +1,5 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; #[derive(Parser)] #[command(name = "accelerate", version, about = "RCM accelerator CLI")] @@ -9,21 +10,40 @@ pub struct Cli { #[derive(Subcommand)] pub enum Command { - /// Run the accelerator on a prompt. + /// Compile and run a .rcm file. Run(RunArgs), + /// Parse a .rcm file and output its AST as JSON. + Parse(ParseArgs), + /// Output available policies, tools, prompts, models, and MCP servers as JSON. + Inventory(InventoryArgs), + /// Render a template for a GitHub event into a runnable .rcm file. + Dispatch(DispatchArgs), + /// Generate one fixed, non-sensitive image for provider diagnostics. + ImageCanary(ImageCanaryArgs), } +/// --- Image canary -------------------------------------------------------- + +#[derive(Args)] +pub struct ImageCanaryArgs { + /// Output path for the verified canary image. + #[arg(long, required = true)] + pub output: PathBuf, +} + +/// --- Run ---------------------------------------------------------------- + #[derive(Args)] pub struct RunArgs { - /// Prompt passed to the accelerator. - #[arg(required = true, num_args = 1..)] - pub prompt: Vec<String>, + /// Path to a .rcm file. + #[arg(required = true)] + pub file: PathBuf, /// Delay between tape-machine animation steps, in milliseconds. #[arg(long, default_value_t = 50)] pub speed: u64, - /// Output format after execution. + /// Output format. #[arg(long, value_enum, default_value_t = Format::Text)] pub format: Format, @@ -31,31 +51,117 @@ pub struct RunArgs { #[arg(long)] pub context: bool, - /// MCP server to launch. - /// - /// Stdio: `label=command arg1 arg2` - /// HTTP: `label=https://url|HeaderName:Value|HeaderName:Value` - /// - /// Repeatable — each `--mcp-server` adds one server. - #[arg(long = "mcp-server")] - pub mcp_servers: Vec<String>, + /// Stream hook events as JSON lines to stdout instead of the tape animation. + #[arg(long)] + pub stream: bool, - /// Model selector in `<provider>/<model>` form (e.g. `sii/gpt-4.1`). + /// Override the purpose declared in the .rcm file. + #[arg(long)] + pub purpose: Option<String>, + + /// Read the purpose override from standard input. /// - /// Provider-only (e.g. `--model sii`) uses the provider's default model. - /// Omit to auto-detect: the first provider whose env var is set wins. - #[arg(long, value_name = "PROVIDER/MODEL")] - pub model: Option<String>, + /// This keeps sensitive or large purpose text out of the process argument + /// list. It cannot be combined with `--purpose`. + #[arg(long, conflicts_with = "purpose")] + pub purpose_stdin: bool, + + /// Use a specific run directory instead of auto-generating a timestamped one. + /// Exported as RCM_RUN_DIR for subprocess/shell tools. + #[arg(long)] + pub run_dir: Option<PathBuf>, } -impl RunArgs { - pub fn prompt_text(&self) -> String { - self.prompt.join(" ") - } +/// --- Parse --------------------------------------------------------------- + +#[derive(Args)] +pub struct ParseArgs { + /// Path to a .rcm file. + #[arg(required = true)] + pub file: PathBuf, +} + +/// --- Inventory ----------------------------------------------------------- + +#[derive(Args)] +pub struct InventoryArgs { + /// Project directory to scan for prompts/ and .rcm files. + #[arg(default_value = ".")] + pub project: PathBuf, +} + +/// --- Dispatch ------------------------------------------------------------ + +#[derive(Args)] +pub struct DispatchArgs { + /// Path to dispatch.toml describing routes from (event, action) to templates. + #[arg(long)] + pub config: PathBuf, + + /// GitHub event name (e.g., "issues", "pull_request"). Typically $GITHUB_EVENT_NAME. + #[arg(long)] + pub event_name: String, + + /// Event action (e.g., "opened"). Typically extracted from the event payload's "action" field. + #[arg(long, default_value = "")] + pub action: String, + + /// Path to the GitHub event JSON payload. Typically $GITHUB_EVENT_PATH. + #[arg(long)] + pub event_path: PathBuf, + + /// Print human-readable progress to stderr. + #[arg(long)] + pub verbose: bool, } +/// --- Shared -------------------------------------------------------------- + #[derive(Clone, Copy, ValueEnum)] pub enum Format { Text, Json, } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Cli, Command}; + + #[test] + fn purpose_stdin_is_accepted_for_run() { + let cli = Cli::try_parse_from(["accelerate", "run", "workflow.rcm", "--purpose-stdin"]) + .expect("purpose stdin should parse"); + + let Command::Run(args) = cli.command else { + panic!("expected run command"); + }; + assert!(args.purpose_stdin); + } + + #[test] + fn purpose_stdin_conflicts_with_inline_purpose() { + let result = Cli::try_parse_from([ + "accelerate", + "run", + "workflow.rcm", + "--purpose", + "secret", + "--purpose-stdin", + ]); + + assert!(result.is_err()); + } + + #[test] + fn image_canary_requires_an_output_path() { + let cli = Cli::try_parse_from(["accelerate", "image-canary", "--output", "canary.png"]) + .expect("image canary should parse"); + + let Command::ImageCanary(args) = cli.command else { + panic!("expected image canary command"); + }; + assert_eq!(args.output, std::path::Path::new("canary.png")); + } +} diff --git a/crates/cli/src/cmd/dispatch.rs b/crates/cli/src/cmd/dispatch.rs new file mode 100644 index 00000000..51f05d5a --- /dev/null +++ b/crates/cli/src/cmd/dispatch.rs @@ -0,0 +1,211 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; +use serde::Deserialize; +use serde_json::Value; + +use crate::args::DispatchArgs; + +#[derive(Debug, Deserialize)] +pub struct DispatchConfig { + pub templates_dir: PathBuf, + pub cache_dir: PathBuf, + #[serde(rename = "route")] + pub routes: Vec<Route>, +} + +#[derive(Debug, Deserialize)] +pub struct Route { + pub event: String, + pub action: Option<String>, + pub template: String, + pub filter: Option<Filter>, + #[serde(default)] + pub fields: HashMap<String, FieldSource>, +} + +#[derive(Debug, Deserialize)] +pub struct Filter { + pub field: String, + pub contains: String, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum FieldSource { + Path(String), + Literal { + #[serde(rename = "literal")] + value: String, + }, +} + +pub fn run(args: DispatchArgs) -> Result<()> { + let config_path = args.config.clone(); + let config_text = fs::read_to_string(&config_path) + .with_context(|| format!("reading dispatch config from {}", config_path.display()))?; + let config: DispatchConfig = toml::from_str(&config_text).context("parsing dispatch config")?; + + let event_text = fs::read_to_string(&args.event_path) + .with_context(|| format!("reading event payload from {}", args.event_path.display()))?; + let event_json: Value = serde_json::from_str(&event_text).context("parsing event JSON")?; + + let action_filter = if args.action.is_empty() { + None + } else { + Some(args.action.as_str()) + }; + + let Some(route) = select_route(&config.routes, &args.event_name, action_filter) else { + if args.verbose { + eprintln!( + "no route matches event '{}' action '{}'; skipping", + args.event_name, args.action + ); + } + return Ok(()); + }; + + if !passes_filter(route, &event_json) { + if args.verbose { + eprintln!("filter excludes event; skipping"); + } + return Ok(()); + } + + let substitutions = collect_substitutions(route, &event_json)?; + + let config_dir = config_path.parent().unwrap_or(Path::new(".")); + let template_path = config_dir.join(&config.templates_dir).join(&route.template); + let template_text = fs::read_to_string(&template_path) + .with_context(|| format!("reading template from {}", template_path.display()))?; + + let rendered = render_template(&template_text, &substitutions)?; + + let cache_dir = config_dir.join(&config.cache_dir); + fs::create_dir_all(&cache_dir) + .with_context(|| format!("creating cache dir {}", cache_dir.display()))?; + + let stem = route.template.trim_end_matches(".tpl"); + let stem = Path::new(stem) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("rendered"); + let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S%6f"); + let output_name = format!("{timestamp}-{stem}.rcm"); + let output_path = cache_dir.join(output_name); + fs::write(&output_path, &rendered) + .with_context(|| format!("writing rendered .rcm to {}", output_path.display()))?; + + println!("{}", output_path.display()); + Ok(()) +} + +pub fn select_route<'a>( + routes: &'a [Route], + event: &str, + action: Option<&str>, +) -> Option<&'a Route> { + let exact = routes + .iter() + .find(|route| route.event == event && route.action.as_deref() == action); + if exact.is_some() { + return exact; + } + routes + .iter() + .find(|route| route.event == event && route.action.is_none()) +} + +pub fn passes_filter(route: &Route, event: &Value) -> bool { + let Some(filter) = &route.filter else { + return true; + }; + let Some(value) = resolve_json_path(event, &filter.field) else { + return false; + }; + value.contains(&filter.contains) +} + +pub fn collect_substitutions(route: &Route, event: &Value) -> Result<HashMap<String, String>> { + let mut substitutions = HashMap::with_capacity(route.fields.len()); + for (placeholder, source) in &route.fields { + let value = match source { + FieldSource::Path(path) => resolve_json_path(event, path).ok_or_else(|| { + anyhow!("event payload missing field '{path}' (required by '{placeholder}')") + })?, + FieldSource::Literal { value } => value.clone(), + }; + substitutions.insert(placeholder.clone(), value); + } + Ok(substitutions) +} + +pub fn resolve_json_path(event: &Value, path: &str) -> Option<String> { + let mut current = event; + for segment in path.split('.') { + current = current.get(segment)?; + } + json_value_to_string(current) +} + +fn json_value_to_string(value: &Value) -> Option<String> { + match value { + Value::Null => None, + Value::String(text) => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + Value::Bool(flag) => Some(flag.to_string()), + _ => None, + } +} + +pub fn render_template(template: &str, substitutions: &HashMap<String, String>) -> Result<String> { + let mut output = String::with_capacity(template.len()); + let mut rest = template; + let mut missing = Vec::new(); + + while let Some(start) = rest.find("{{") { + output.push_str(&rest[..start]); + let after_open = &rest[start + 2..]; + let Some(close_rel) = after_open.find("}}") else { + return Err(anyhow!("unbalanced '{{{{' in template")); + }; + let raw_name = after_open[..close_rel].trim(); + if raw_name.is_empty() || !is_valid_placeholder(raw_name) { + return Err(anyhow!( + "invalid placeholder syntax: '{{{{ {raw_name} }}}}'" + )); + } + match substitutions.get(raw_name) { + Some(value) => output.push_str(value), + None => missing.push(raw_name.to_string()), + } + rest = &after_open[close_rel + 2..]; + } + output.push_str(rest); + + if !missing.is_empty() { + missing.sort(); + missing.dedup(); + return Err(anyhow!( + "template references unresolved placeholders: {missing:?}" + )); + } + Ok(output) +} + +fn is_valid_placeholder(name: &str) -> bool { + let mut chars = name.chars(); + let first = match chars.next() { + Some(character) => character, + None => return false, + }; + if !(first.is_ascii_uppercase() || first == '_') { + return false; + } + chars.all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' + }) +} diff --git a/crates/cli/src/cmd/image_canary.rs b/crates/cli/src/cmd/image_canary.rs new file mode 100644 index 00000000..9e220de1 --- /dev/null +++ b/crates/cli/src/cmd/image_canary.rs @@ -0,0 +1,65 @@ +use accelerator::tools::{IMAGE_GEN_DIAGNOSTIC_ENV, ImageGenTool}; +use anyhow::{Context, Result, anyhow}; +use machine::{Environment, Tool}; + +use crate::args::ImageCanaryArgs; + +const CANARY_PROMPT: &str = "A simple blue circle centered on a plain white background. No text, logos, people, brands, or identifying information."; +const IMAGE_ENVIRONMENT_KEYS: [&str; 3] = [ + "IMAGE_GEN_API_KEY", + "IMAGE_GEN_API_URL", + "IMAGE_GEN_TRUSTED_HOSTS", +]; + +pub async fn run(args: ImageCanaryArgs) -> Result<()> { + let parent = args + .output + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")) + .canonicalize() + .with_context(|| format!("resolving canary output parent {}", args.output.display()))?; + let file_name = args + .output + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("canary output must have a UTF-8 file name"))?; + let mut environment = Environment::empty(&parent); + environment.root = Some(parent.clone()); + for key in IMAGE_ENVIRONMENT_KEYS { + if let Ok(value) = std::env::var(key) { + environment.vars.insert(key.to_owned(), value); + } + } + environment + .vars + .insert(IMAGE_GEN_DIAGNOSTIC_ENV.to_owned(), "true".to_owned()); + + let result = ImageGenTool + .execute( + serde_json::json!({ + "prompt": CANARY_PROMPT, + "filePath": file_name, + "size": "1024x1024", + }), + &environment, + ) + .await + .map_err(|error| anyhow!(error))?; + let output_path = parent.join(file_name); + let size = tokio::fs::metadata(&output_path) + .await + .context("reading verified canary output")? + .len(); + println!( + "{}", + serde_json::json!({ + "schema_version": 1, + "status": "ok", + "output_path": output_path, + "size": size, + "result": result.content, + }) + ); + Ok(()) +} diff --git a/crates/cli/src/cmd/inventory.rs b/crates/cli/src/cmd/inventory.rs new file mode 100644 index 00000000..cd0dcaf0 --- /dev/null +++ b/crates/cli/src/cmd/inventory.rs @@ -0,0 +1,154 @@ +use std::path::Path; + +use accelerator::Catalog; + +use crate::args::InventoryArgs; + +pub fn run(args: InventoryArgs) -> anyhow::Result<()> { + let catalog = Catalog::new(); + let project = args.project; + + let policies = catalog.policy_names(); + let builtin_tools = catalog.tool_names(); + let builtin_prompts = catalog.prompt_names(); + + let mcp_servers = discover_mcp_servers(&project); + let external_models = discover_models(&project); + let external_prompts = discover_prompts(&project); + + let inventory = serde_json::json!({ + "policies": policies, + "tools": { + "builtin": builtin_tools, + "mcp_servers": mcp_servers + }, + "prompts": { + "builtin": builtin_prompts, + "external": external_prompts + }, + "models": { + "external": external_models + } + }); + + println!( + "{}", + serde_json::to_string_pretty(&inventory).map_err(anyhow::Error::msg)? + ); + Ok(()) +} + +fn discover_mcp_servers(project: &Path) -> Vec<serde_json::Value> { + let mut servers = Vec::new(); + let rcm_dir = project.join("rcm"); + if !rcm_dir.is_dir() { + return servers; + } + let Ok(entries) = std::fs::read_dir(&rcm_dir) else { + return servers; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("rcm") { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(file) = crate::rcm::parse(&source) else { + continue; + }; + for mcp in file.mcps { + servers.push(serde_json::json!({ + "label": mcp.label, + "transport": mcp_transport_json(&mcp.transport) + })); + } + } + servers +} + +fn mcp_transport_json(transport: &crate::rcm::McpTransportDef) -> serde_json::Value { + match transport { + crate::rcm::McpTransportDef::Stdio { command, args, .. } => { + serde_json::json!({ + "type": "stdio", + "command": command, + "args": args, + }) + } + crate::rcm::McpTransportDef::Http { url, .. } => { + serde_json::json!({ + "type": "http", + "url": url, + }) + } + crate::rcm::McpTransportDef::Sse { url, .. } => { + serde_json::json!({ + "type": "sse", + "url": url, + }) + } + } +} + +fn discover_models(project: &Path) -> Vec<serde_json::Value> { + let mut models = Vec::new(); + let rcm_dir = project.join("rcm"); + if !rcm_dir.is_dir() { + return models; + } + let Ok(entries) = std::fs::read_dir(&rcm_dir) else { + return models; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("rcm") { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(file) = crate::rcm::parse(&source) else { + continue; + }; + for model in file.models { + models.push(serde_json::json!({ + "name": model.id, + "protocol": model.protocol, + "endpoint": model.endpoint, + })); + } + } + models +} + +fn discover_prompts(project: &Path) -> Vec<serde_json::Value> { + let mut prompts = Vec::new(); + let prompts_dir = project.join("prompts"); + if !prompts_dir.is_dir() { + return prompts; + } + let Ok(entries) = std::fs::read_dir(&prompts_dir) else { + return prompts; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_stem().and_then(|n| n.to_str()) else { + continue; + }; + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let preview: String = content.chars().take(80).collect(); + prompts.push(serde_json::json!({ + "name": name, + "path": path.to_string_lossy(), + "preview": preview, + })); + } + prompts +} diff --git a/crates/cli/src/cmd/mod.rs b/crates/cli/src/cmd/mod.rs index 673989aa..2f8a1015 100644 --- a/crates/cli/src/cmd/mod.rs +++ b/crates/cli/src/cmd/mod.rs @@ -1,14 +1,20 @@ -mod run; +pub mod dispatch; +mod image_canary; +mod inventory; +mod parse; +pub mod run; use crate::args::{Cli, Command}; -pub fn run(cli: Cli) -> anyhow::Result<()> { +pub fn dispatch(cli: Cli) -> anyhow::Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; match cli.command { - Command::Run(args) => { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - runtime.block_on(run::run(args)) - } + Command::Run(args) => runtime.block_on(run::run(args)), + Command::Parse(args) => runtime.block_on(parse::run(args)), + Command::Inventory(args) => inventory::run(args), + Command::Dispatch(args) => dispatch::run(args), + Command::ImageCanary(args) => runtime.block_on(image_canary::run(args)), } } diff --git a/crates/cli/src/cmd/parse.rs b/crates/cli/src/cmd/parse.rs new file mode 100644 index 00000000..0471afab --- /dev/null +++ b/crates/cli/src/cmd/parse.rs @@ -0,0 +1,11 @@ +use crate::args::ParseArgs; +use crate::rcm; + +pub async fn run(args: ParseArgs) -> anyhow::Result<()> { + let source = std::fs::read_to_string(&args.file) + .map_err(|error| anyhow::anyhow!("failed to read {}: {error}", args.file.display()))?; + let file = rcm::parse(&source).map_err(anyhow::Error::msg)?; + let json = serde_json::to_string_pretty(&file).map_err(anyhow::Error::msg)?; + println!("{json}"); + Ok(()) +} diff --git a/crates/cli/src/cmd/run.rs b/crates/cli/src/cmd/run.rs index db800480..13d1fe1b 100644 --- a/crates/cli/src/cmd/run.rs +++ b/crates/cli/src/cmd/run.rs @@ -1,81 +1,348 @@ -use std::sync::Arc; use std::sync::mpsc; use std::thread; -use std::time::Instant; +use std::time::{Duration, Instant}; -use accelerator::mcp::{McpRegistry, McpServerConfig}; -use accelerator::{Graph, State, resolve_model}; +use tokio::io::AsyncReadExt; use tracing_subscriber::prelude::*; use crate::args::{Format, RunArgs}; -use crate::hook; +use crate::hook::{self, HookKind}; use crate::output; +const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(50); + pub async fn run(args: RunArgs) -> anyhow::Result<()> { let (hook_tx, hook_rx) = mpsc::channel(); init_tracing(hook_tx); - // ── Model resolution (fail fast with a clear, actionable error) ── - let model = resolve_model(args.model.as_deref()) - .map_err(|error| anyhow::anyhow!("{}", error))?; - - // ── MCP setup ── - let configs: Vec<McpServerConfig> = args - .mcp_servers - .iter() - .map(|s| McpServerConfig::parse(s)) - .collect::<Result<Vec<_>, _>>() + let accelerator = crate::rcm::compile::compile_file(&args.file) + .await .map_err(anyhow::Error::msg)?; - let mcp_tools: Vec<Arc<dyn machine::Tool>> = if configs.is_empty() { - Vec::new() + let purpose = if args.purpose_stdin { + let mut purpose = String::new(); + tokio::io::stdin().read_to_string(&mut purpose).await?; + purpose } else { - let registry: McpRegistry = McpRegistry::start(&configs) - .await - .map_err(anyhow::Error::msg)?; - registry.tools() - }; - - // ── State ── - let mut state = State { - purpose: args.prompt_text(), - ..State::default() + args.purpose.unwrap_or_default() }; - state.res = state.res.with_model(model); + let run_dir = prepare_run_dir(args.run_dir)?; - for tool in &mcp_tools { - let name = tool.name().to_string(); - state.res = state.res.with_tool(tool.clone()); - state.res.enable(name); + if args.stream { + return stream_run(accelerator, hook_rx, purpose, run_dir).await; } - // ── Accelerator ── let start = Instant::now(); - let (ctx_tx, ctx_rx) = mpsc::channel(); + let (runtime_thread, ctx_rx) = spawn_runtime(accelerator, purpose, run_dir); + + let summary = output::tape::run_animation(hook_rx, args.speed, start, &runtime_thread); + let ctx = finish_runtime(runtime_thread, ctx_rx)?; - thread::spawn(move || { + match args.format { + Format::Text => output::text::print(&ctx, &summary, args.context), + Format::Json => output::json::print(&ctx, &summary, args.context)?, + } + + Ok(()) +} + +// =========================================================================== +// Stream JSON Line Format — EXTERNAL FROZEN CONTRACT +// +// This JSON output format is consumed by portal-gateway's parse-rcm.ts and +// must NOT be changed without synchronizing with the gateway codebase. +// +// Format: {"type":"<event_type>","field1":"value1",...} +// +// Event types: +// graph_start, graph_done, frontier_start, frontier_done +// component_start, component_done, component_skipped +// machine_start, machine_done, halt, completion_start, completion_end +// tool_call, tool_result, tool_error +// appended, taken, inserted, replaced, removed +// model, activate, deactivate, resource +// +// All field names use snake_case. +// Consumer: portal-gateway/src/hub/parse-rcm.ts +// =========================================================================== +async fn stream_run( + accelerator: accelerator::Accelerator, + hook_rx: mpsc::Receiver<hook::HookEvent>, + purpose: String, + run_dir: Option<std::path::PathBuf>, +) -> anyhow::Result<()> { + let (runtime_thread, ctx_rx) = spawn_runtime(accelerator, purpose, run_dir); + + let mut graph_seen = false; + loop { + let event = match hook_rx.recv_timeout(RUNTIME_POLL_INTERVAL) { + Ok(event) => event, + Err(mpsc::RecvTimeoutError::Timeout) if runtime_thread.is_finished() => break, + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }; + if event.source.is_none() + && matches!(event.kind, HookKind::Graph(hook::GraphEvent::Start { .. })) + { + graph_seen = true; + } + let line = match &event.kind { + HookKind::Graph(hook::GraphEvent::Start { graph }) => { + json_line("graph_start", serde_json::json!({ "graph": graph })) + } + HookKind::Graph(hook::GraphEvent::Done { graph }) => { + json_line("graph_done", serde_json::json!({ "graph": graph })) + } + HookKind::Graph(hook::GraphEvent::FrontierStart { + graph, + frontier, + count, + }) => json_line( + "frontier_start", + serde_json::json!({ "graph": graph, "frontier": frontier, "count": count }), + ), + HookKind::Graph(hook::GraphEvent::FrontierDone { + graph, + frontier, + count, + }) => json_line( + "frontier_done", + serde_json::json!({ "graph": graph, "frontier": frontier, "count": count }), + ), + HookKind::Component(hook::ComponentEvent::Start(meta)) => { + json_line("component_start", component_meta_json(meta)) + } + HookKind::Component(hook::ComponentEvent::Done(meta)) => { + json_line("component_done", component_meta_json(meta)) + } + HookKind::Component(hook::ComponentEvent::Skipped(meta)) => { + json_line("component_skipped", component_meta_json(meta)) + } + HookKind::Machine(hook::MachineEvent::Start) => { + json_line("machine_start", serde_json::json!({})) + } + HookKind::Machine(hook::MachineEvent::Halt { step }) => { + json_line("halt", serde_json::json!({ "step": step })) + } + HookKind::Machine(hook::MachineEvent::Done) => { + json_line("machine_done", serde_json::json!({})) + } + HookKind::Completion(hook::CompletionEvent::Start) => { + json_line("completion_start", serde_json::json!({})) + } + HookKind::Completion(completion @ hook::CompletionEvent::End { .. }) => { + json_line("completion_end", completion_event_json(completion)) + } + HookKind::Tool(hook::ToolEvent::Call { + call_id, + tool, + arguments, + }) => json_line( + "tool_call", + serde_json::json!({ "call_id": call_id, "tool": tool, "arguments": arguments }), + ), + HookKind::Tool(hook::ToolEvent::Result { + call_id, + tool, + result_len, + duration, + }) => json_line( + "tool_result", + serde_json::json!({ "call_id": call_id, "tool": tool, "result_len": result_len, "duration": duration }), + ), + HookKind::Tool(hook::ToolEvent::Error { + call_id, + tool, + error, + retryable, + }) => json_line( + "tool_error", + serde_json::json!({ "call_id": call_id, "tool": tool, "error": error, "retryable": retryable }), + ), + HookKind::Fragment(hook::FragmentEvent::Appended(meta)) => { + json_line("appended", fragment_json(meta)) + } + HookKind::Fragment(hook::FragmentEvent::Taken(meta)) => { + json_line("taken", fragment_json(meta)) + } + HookKind::Fragment(hook::FragmentEvent::Inserted { meta, after }) => { + let mut payload = fragment_json(meta); + payload["after"] = serde_json::json!(after); + json_line("inserted", payload) + } + HookKind::Fragment(hook::FragmentEvent::Replaced(meta)) => { + json_line("replaced", fragment_json(meta)) + } + HookKind::Fragment(hook::FragmentEvent::Removed { id }) => { + json_line("removed", serde_json::json!({ "id": id })) + } + HookKind::Fragment(hook::FragmentEvent::Swapped { first, second }) => json_line( + "swapped", + serde_json::json!({ "first": first, "second": second }), + ), + HookKind::Resource(hook::ResourceEvent::Model { name }) => { + json_line("model", serde_json::json!({ "name": name })) + } + HookKind::Resource(hook::ResourceEvent::Activate { name }) => { + json_line("activate", serde_json::json!({ "name": name })) + } + HookKind::Resource(hook::ResourceEvent::Deactivate { name }) => { + json_line("deactivate", serde_json::json!({ "name": name })) + } + }; + println!("{line}"); + if is_terminal_event(&event, graph_seen) { + break; + } + } + + finish_runtime(runtime_thread, ctx_rx)?; + Ok(()) +} + +fn spawn_runtime( + accelerator: accelerator::Accelerator, + purpose: String, + run_dir: Option<std::path::PathBuf>, +) -> (thread::JoinHandle<()>, mpsc::Receiver<machine::Context>) { + let (ctx_tx, ctx_rx) = mpsc::channel(); + let runtime_thread = thread::spawn(move || { let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio runtime"); runtime.block_on(async { - let mut graph = Graph::new(); - let _agent = graph.spawn(state); - let outputs = graph.build().expect("assembly").run().await; - let output = outputs.into_iter().next().expect("agent output"); - let _ = ctx_tx.send(output.ctx); + let mut state = machine::RunState { + purpose: machine::Purpose::new(purpose), + run_dir: run_dir.clone(), + ..machine::RunState::default() + }; + if let Some(ref dir) = run_dir { + state.environment.cwd = dir.clone(); + state.environment.root = Some(dir.clone()); + state.environment.run_dir = Some(dir.clone()); + state + .environment + .vars + .insert("RCM_RUN_DIR".to_string(), dir.display().to_string()); + } + let output = accelerator.run_with(state).await; + let _ = ctx_tx.send(output.context); }); }); + (runtime_thread, ctx_rx) +} + +fn finish_runtime( + runtime_thread: thread::JoinHandle<()>, + ctx_rx: mpsc::Receiver<machine::Context>, +) -> anyhow::Result<machine::Context> { + if let Err(payload) = runtime_thread.join() { + let message = payload + .downcast_ref::<String>() + .map(String::as_str) + .or_else(|| payload.downcast_ref::<&str>().copied()) + .unwrap_or("unknown panic"); + return Err(anyhow::anyhow!("RCM runtime panicked: {message}")); + } + ctx_rx + .recv() + .map_err(|error| anyhow::anyhow!("RCM runtime finished without context: {error}")) +} - let summary = output::tape::run_animation(hook_rx, args.speed, start); - let ctx = ctx_rx.recv()?; +fn prepare_run_dir( + run_dir: Option<std::path::PathBuf>, +) -> anyhow::Result<Option<std::path::PathBuf>> { + let Some(run_dir) = run_dir else { + return Ok(None); + }; + std::fs::create_dir_all(&run_dir)?; + Ok(Some(run_dir.canonicalize()?)) +} - match args.format { - Format::Text => output::text::print(&ctx, &summary, args.context), - Format::Json => output::json::print(&ctx, &summary, args.context)?, +pub fn is_terminal_event(event: &hook::HookEvent, graph_seen: bool) -> bool { + match &event.kind { + HookKind::Graph(hook::GraphEvent::Done { .. }) => event.source.is_none(), + HookKind::Machine(hook::MachineEvent::Done) => !graph_seen && event.source.is_none(), + _ => false, } +} - Ok(()) +fn json_line(event_type: &str, payload: serde_json::Value) -> String { + let mut obj = payload.as_object().cloned().unwrap_or_default(); + obj.insert( + "type".to_string(), + serde_json::Value::String(event_type.to_string()), + ); + serde_json::to_string(&obj).unwrap_or_default() +} + +/// Serialize a completion event using only the additive, sanitized stream fields. +pub fn completion_event_json(event: &hook::CompletionEvent) -> serde_json::Value { + let hook::CompletionEvent::End { + fragments, + input_tokens, + output_tokens, + total_tokens, + cached_input_tokens, + cache_creation_input_tokens, + outcome, + http_status, + failure_kind, + retryable, + duration_ms, + } = event + else { + return serde_json::json!({}); + }; + + let mut payload = serde_json::json!({ + "fragments": fragments, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + "cached_input_tokens": cached_input_tokens, + "cache_creation_input_tokens": cache_creation_input_tokens, + }); + let object = payload + .as_object_mut() + .expect("completion payload is an object"); + if let Some(value) = outcome { + object.insert("outcome".into(), serde_json::json!(value)); + } + if let Some(value) = http_status { + object.insert("http_status".into(), serde_json::json!(value)); + } + if let Some(value) = failure_kind { + object.insert("failure_kind".into(), serde_json::json!(value)); + } + if let Some(value) = retryable { + object.insert("retryable".into(), serde_json::json!(value)); + } + if let Some(value) = duration_ms { + object.insert("duration_ms".into(), serde_json::json!(value)); + } + payload +} + +fn component_meta_json(meta: &hook::ComponentMeta) -> serde_json::Value { + serde_json::json!({ + "name": meta.name, + "kind": meta.kind, + "index": meta.index, + "graph": meta.graph, + "frontier": meta.frontier, + }) +} + +fn fragment_json(meta: &hook::FragmentMeta) -> serde_json::Value { + serde_json::json!({ + "id": meta.id, + "role": meta.role, + "kind": meta.kind, + "tag": meta.tag, + "preview": meta.preview, + }) } fn init_tracing(tx: mpsc::Sender<hook::HookEvent>) { diff --git a/crates/cli/src/hook/mod.rs b/crates/cli/src/hook/mod.rs index fbfdb17f..76106e19 100644 --- a/crates/cli/src/hook/mod.rs +++ b/crates/cli/src/hook/mod.rs @@ -6,7 +6,15 @@ use tracing_subscriber::layer::Context; use tracing_subscriber::registry::LookupSpan; #[derive(Debug, Clone)] -pub(crate) enum HookEvent { +pub struct HookEvent { + pub source: Option<ComponentMeta>, + pub kind: HookKind, +} + +#[derive(Debug, Clone)] +pub enum HookKind { + Graph(GraphEvent), + Component(ComponentEvent), Machine(MachineEvent), Completion(CompletionEvent), Tool(ToolEvent), @@ -15,30 +23,81 @@ pub(crate) enum HookEvent { } #[derive(Debug, Clone)] -pub(crate) enum MachineEvent { +pub enum GraphEvent { + Start { + graph: String, + }, + Done { + graph: String, + }, + FrontierStart { + graph: String, + frontier: u64, + count: usize, + }, + FrontierDone { + graph: String, + frontier: u64, + count: usize, + }, +} + +#[derive(Debug, Clone)] +pub enum ComponentEvent { + Start(ComponentMeta), + Done(ComponentMeta), + Skipped(ComponentMeta), +} + +#[derive(Debug, Clone, Default)] +pub struct ComponentMeta { + pub graph: String, + pub name: String, + pub index: usize, + pub kind: String, + pub frontier: Option<u64>, +} + +#[derive(Debug, Clone)] +pub enum MachineEvent { Start, - Halt { round: u32 }, + Halt { step: u64 }, Done, } #[derive(Debug, Clone)] -pub(crate) enum CompletionEvent { +pub enum CompletionEvent { Start, - End { fragments: usize }, + End { + fragments: usize, + input_tokens: u64, + output_tokens: u64, + total_tokens: u64, + cached_input_tokens: u64, + cache_creation_input_tokens: u64, + outcome: Option<String>, + http_status: Option<u16>, + failure_kind: Option<String>, + retryable: Option<bool>, + duration_ms: Option<u64>, + }, } #[derive(Debug, Clone)] -pub(crate) enum ToolEvent { +pub enum ToolEvent { Call { + call_id: String, tool: String, arguments: String, }, Result { + call_id: String, tool: String, result_len: usize, duration: String, }, Error { + call_id: String, tool: String, error: String, retryable: bool, @@ -46,25 +105,27 @@ pub(crate) enum ToolEvent { } #[derive(Debug, Clone)] -pub(crate) enum FragmentEvent { +pub enum FragmentEvent { Appended(FragmentMeta), Taken(FragmentMeta), - Inserted(FragmentMeta), + Inserted { meta: FragmentMeta, after: u64 }, Replaced(FragmentMeta), Removed { id: u64 }, Swapped { first: u64, second: u64 }, } #[derive(Debug, Clone)] -pub(crate) struct FragmentMeta { - pub(crate) id: u64, - pub(crate) role: String, - pub(crate) kind: String, - pub(crate) preview: String, +pub struct FragmentMeta { + pub id: u64, + pub step: u64, + pub role: String, + pub kind: String, + pub tag: String, + pub preview: String, } #[derive(Debug, Clone)] -pub(crate) enum ResourceEvent { +pub enum ResourceEvent { Model { name: String }, Activate { name: String }, Deactivate { name: String }, @@ -81,12 +142,53 @@ struct HookLayer { } impl<S: Subscriber + for<'a> LookupSpan<'a>> tracing_subscriber::layer::Layer<S> for HookLayer { - fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + fn on_new_span( + &self, + attrs: &tracing::span::Attributes<'_>, + id: &tracing::Id, + ctx: Context<'_, S>, + ) { + let Some(span) = ctx.span(id) else { + return; + }; + let mut fields = HookFields::default(); + attrs.record(&mut fields); + span.extensions_mut().insert(fields); + } + + fn on_record(&self, id: &tracing::Id, values: &tracing::span::Record<'_>, ctx: Context<'_, S>) { + let Some(span) = ctx.span(id) else { + return; + }; + let mut fields = span + .extensions_mut() + .remove::<HookFields>() + .unwrap_or_default(); + values.record(&mut fields); + span.extensions_mut().insert(fields); + } + + fn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) { if event.metadata().target() != "hook" { return; } let mut fields = HookFields::default(); + if let Some(scope) = ctx.event_scope(event) { + // Walk innermost span -> root so the *nearest* component identity + // wins. With `merge_missing` (keep-first) a nested component — e.g. + // a scout inside the `discovery` composite, or any sub-graph node — + // then attributes its completion/tool/fragment events to itself, + // not to the enclosing composite. Walking `from_root()` instead lets + // the outer composite's fields win and lumps every child's work onto + // the parent tape, so children render with 0 cells and parallel + // sub-nodes look like one sequential node. + for span in scope { + if let Some(span_fields) = span.extensions().get::<HookFields>() { + fields.merge_missing(span_fields); + } + } + } event.record(&mut fields); if let Some(event) = HookEvent::from_fields(&fields) { let _ = self.tx.send(event); @@ -108,6 +210,14 @@ enum FieldValue { } impl HookFields { + fn merge_missing(&mut self, other: &HookFields) { + for (name, value) in &other.values { + self.values + .entry(name.clone()) + .or_insert_with(|| value.clone()); + } + } + fn string(&self, name: &str) -> Option<String> { match self.values.get(name) { Some(FieldValue::String(value)) | Some(FieldValue::Debug(value)) => Some(value.clone()), @@ -125,10 +235,6 @@ impl HookFields { } } - fn u32(&self, name: &str) -> Option<u32> { - self.u64(name).map(|value| value as u32) - } - fn usize(&self, name: &str) -> Option<usize> { self.u64(name).map(|value| value as usize) } @@ -141,11 +247,34 @@ impl HookFields { } } + fn source_meta(&self) -> Option<ComponentMeta> { + let name = self.string("component")?; + Some(ComponentMeta { + graph: self.string("graph").unwrap_or_default(), + name, + index: self.usize("component_index").unwrap_or(0), + kind: self.string("component_kind").unwrap_or_default(), + frontier: self.u64("frontier"), + }) + } + + fn component_meta(&self) -> ComponentMeta { + ComponentMeta { + graph: self.string("graph").unwrap_or_default(), + name: self.string("component").unwrap_or_default(), + index: self.usize("component_index").unwrap_or(0), + kind: self.string("component_kind").unwrap_or_default(), + frontier: self.u64("frontier"), + } + } + fn fragment_meta(&self) -> FragmentMeta { FragmentMeta { id: self.u64("id").unwrap_or(0), + step: self.u64("step").unwrap_or(0), role: self.string("role").unwrap_or_default(), kind: self.string("kind").unwrap_or_default(), + tag: self.string("tag").unwrap_or_default(), preview: self.string("preview").unwrap_or_default(), } } @@ -185,60 +314,104 @@ impl tracing::field::Visit for HookFields { impl HookEvent { fn from_fields(fields: &HookFields) -> Option<Self> { - match fields.string("event")?.as_str() { - "machine_start" => Some(Self::Machine(MachineEvent::Start)), - "halt" => Some(Self::Machine(MachineEvent::Halt { - round: fields.u32("round").unwrap_or(0), - })), - "done" => Some(Self::Machine(MachineEvent::Done)), - "completion_start" => Some(Self::Completion(CompletionEvent::Start)), - "completion_end" => Some(Self::Completion(CompletionEvent::End { + let kind = match fields.string("event")?.as_str() { + "graph_start" => HookKind::Graph(GraphEvent::Start { + graph: fields.string("graph").unwrap_or_default(), + }), + "graph_done" => HookKind::Graph(GraphEvent::Done { + graph: fields.string("graph").unwrap_or_default(), + }), + "frontier_start" => HookKind::Graph(GraphEvent::FrontierStart { + graph: fields.string("graph").unwrap_or_default(), + frontier: fields.u64("frontier").unwrap_or(0), + count: fields.usize("count").unwrap_or(0), + }), + "frontier_done" => HookKind::Graph(GraphEvent::FrontierDone { + graph: fields.string("graph").unwrap_or_default(), + frontier: fields.u64("frontier").unwrap_or(0), + count: fields.usize("count").unwrap_or(0), + }), + "component_start" => { + HookKind::Component(ComponentEvent::Start(fields.component_meta())) + } + "component_done" => HookKind::Component(ComponentEvent::Done(fields.component_meta())), + "component_skipped" => { + HookKind::Component(ComponentEvent::Skipped(fields.component_meta())) + } + "machine_start" => HookKind::Machine(MachineEvent::Start), + "halt" => HookKind::Machine(MachineEvent::Halt { + step: fields.u64("step").unwrap_or(0), + }), + "done" => HookKind::Machine(MachineEvent::Done), + "completion_start" => HookKind::Completion(CompletionEvent::Start), + "completion_end" => HookKind::Completion(CompletionEvent::End { fragments: fields.usize("fragments").unwrap_or(0), - })), - "tool_call" => Some(Self::Tool(ToolEvent::Call { + input_tokens: fields.u64("input_tokens").unwrap_or(0), + output_tokens: fields.u64("output_tokens").unwrap_or(0), + total_tokens: fields.u64("total_tokens").unwrap_or(0), + cached_input_tokens: fields.u64("cached_input_tokens").unwrap_or(0), + cache_creation_input_tokens: fields.u64("cache_creation_input_tokens").unwrap_or(0), + outcome: fields.string("outcome").filter(|value| !value.is_empty()), + http_status: fields + .u64("http_status") + .filter(|value| *value > 0) + .and_then(|value| u16::try_from(value).ok()), + failure_kind: fields + .string("failure_kind") + .filter(|value| !value.is_empty()), + retryable: (fields.string("outcome").as_deref() == Some("failure")) + .then(|| fields.bool("retryable")) + .flatten(), + duration_ms: fields.u64("duration_ms"), + }), + "tool_call" => HookKind::Tool(ToolEvent::Call { + call_id: fields.string("call_id").unwrap_or_default(), tool: fields.string("tool").unwrap_or_default(), arguments: fields.string("arguments").unwrap_or_default(), - })), - "tool_result" => Some(Self::Tool(ToolEvent::Result { + }), + "tool_result" => HookKind::Tool(ToolEvent::Result { + call_id: fields.string("call_id").unwrap_or_default(), tool: fields.string("tool").unwrap_or_default(), result_len: fields .string("result") .map(|result| result.len()) .unwrap_or(0), duration: fields.string("duration").unwrap_or_default(), - })), - "tool_error" => Some(Self::Tool(ToolEvent::Error { + }), + "tool_error" => HookKind::Tool(ToolEvent::Error { + call_id: fields.string("call_id").unwrap_or_default(), tool: fields.string("tool").unwrap_or_default(), error: fields.string("error").unwrap_or_default(), retryable: fields.bool("retryable").unwrap_or(true), - })), - "appended" => Some(Self::Fragment(FragmentEvent::Appended( - fields.fragment_meta(), - ))), - "taken" => Some(Self::Fragment(FragmentEvent::Taken(fields.fragment_meta()))), - "inserted" => Some(Self::Fragment(FragmentEvent::Inserted( - fields.fragment_meta(), - ))), - "replaced" => Some(Self::Fragment(FragmentEvent::Replaced( - fields.fragment_meta(), - ))), - "removed" => Some(Self::Fragment(FragmentEvent::Removed { + }), + "appended" => HookKind::Fragment(FragmentEvent::Appended(fields.fragment_meta())), + "taken" => HookKind::Fragment(FragmentEvent::Taken(fields.fragment_meta())), + "inserted" => HookKind::Fragment(FragmentEvent::Inserted { + meta: fields.fragment_meta(), + after: fields.u64("after").unwrap_or(0), + }), + "replaced" => HookKind::Fragment(FragmentEvent::Replaced(fields.fragment_meta())), + "removed" => HookKind::Fragment(FragmentEvent::Removed { id: fields.u64("id").unwrap_or(0), - })), - "swapped" => Some(Self::Fragment(FragmentEvent::Swapped { + }), + "swapped" => HookKind::Fragment(FragmentEvent::Swapped { first: fields.u64("id1").unwrap_or(0), second: fields.u64("id2").unwrap_or(0), - })), - "model" => Some(Self::Resource(ResourceEvent::Model { + }), + "model" => HookKind::Resource(ResourceEvent::Model { name: fields.string("name").unwrap_or_default(), - })), - "activate" => Some(Self::Resource(ResourceEvent::Activate { + }), + "activate" => HookKind::Resource(ResourceEvent::Activate { name: fields.string("name").unwrap_or_default(), - })), - "deactivate" => Some(Self::Resource(ResourceEvent::Deactivate { + }), + "deactivate" => HookKind::Resource(ResourceEvent::Deactivate { name: fields.string("name").unwrap_or_default(), - })), - _ => None, - } + }), + _ => return None, + }; + Some(Self { + source: fields.source_meta(), + kind, + }) } } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs new file mode 100644 index 00000000..511d06d1 --- /dev/null +++ b/crates/cli/src/lib.rs @@ -0,0 +1,13 @@ +mod args; +pub mod cmd; +pub mod hook; +mod output; +pub mod rcm; + +pub mod tape_animation { + pub use crate::output::tape::{ + TapeSnapshot, TapeSnapshotCell, TapeSnapshotTape, snapshot_events, + }; +} + +pub use args::{Cli, Command, Format, ImageCanaryArgs, RunArgs}; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 37fbc8ba..807188a9 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,10 +1,5 @@ -mod args; -mod cmd; -mod hook; -mod output; - use clap::Parser; fn main() -> anyhow::Result<()> { - cmd::run(args::Cli::parse()) + cli::cmd::dispatch(cli::Cli::parse()) } diff --git a/crates/cli/src/output/json.rs b/crates/cli/src/output/json.rs index cc061bee..f769eb42 100644 --- a/crates/cli/src/output/json.rs +++ b/crates/cli/src/output/json.rs @@ -86,14 +86,9 @@ fn content_json(content: &Content) -> serde_json::Value { Content::Document(document) => { json!({ "type": "document", "media_type": format!("{:?}", document.media_type) }) } - Content::Hitch { - message, - retryable, - code, - } => json!({ + Content::Hitch { message, code, .. } => json!({ "type": "hitch", "message": message, - "retryable": retryable, "code": code, }), } diff --git a/crates/cli/src/output/tape.rs b/crates/cli/src/output/tape.rs index 1b0758d2..43a58bbd 100644 --- a/crates/cli/src/output/tape.rs +++ b/crates/cli/src/output/tape.rs @@ -1,5 +1,5 @@ -use std::collections::VecDeque; -use std::io::Write; +use std::collections::{BTreeMap, VecDeque}; +use std::io::{IsTerminal, Write}; use std::sync::mpsc; use std::thread; use std::time::Duration; @@ -8,19 +8,25 @@ use crossterm::{ cursor::{Hide, MoveTo, Show, position}, execute, style::{Color, Stylize}, - terminal::{Clear, ClearType}, + terminal::{Clear, ClearType, size}, }; use crate::hook::{ - CompletionEvent, FragmentEvent, FragmentMeta, HookEvent, MachineEvent, ResourceEvent, ToolEvent, + CompletionEvent, ComponentEvent, ComponentMeta, FragmentEvent, FragmentMeta, GraphEvent, + HookEvent, HookKind, MachineEvent, ResourceEvent, ToolEvent, }; const IDLE_TICK: Duration = Duration::from_millis(50); const CELL_WIDTH: usize = 2; +const MIN_VIEW_ROWS: u16 = 6; +const MAX_VIEW_ROWS: u16 = 24; +const LONG_JUMP_THRESHOLD: usize = 12; +const MAX_TRAVEL_TICKS: usize = 6; #[derive(Clone)] struct TapeCell { id: u64, + tag: String, kind: CellKind, state: CellState, } @@ -30,14 +36,21 @@ enum CellState { Pending, Written, Replacing, + Flashing, + Taking, Removing, + Swapping, } -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, PartialEq)] enum CellKind { SystemText, UserText, AssistantText, + Agent, + Instruction, + Environment, + Purpose, ToolCall, ToolResult, Image, @@ -45,11 +58,25 @@ enum CellKind { Video, Document, Hitch, + Tagged(String), Unknown, } impl CellKind { fn from_meta(meta: &FragmentMeta) -> Self { + match meta.tag.as_str() { + "agent" => Self::Agent, + "instruction" => Self::Instruction, + "env" => Self::Environment, + "purpose" | "purpose_initial" | "purpose_b" => Self::Purpose, + "" => Self::from_role_and_kind(meta), + tag if tag == meta.kind || tag == meta.role => Self::from_role_and_kind(meta), + tag if meta.kind == "text" => Self::Tagged(tag.into()), + _ => Self::from_role_and_kind(meta), + } + } + + fn from_role_and_kind(meta: &FragmentMeta) -> Self { match meta.kind.as_str() { "tool_call" => Self::ToolCall, "tool_result" => Self::ToolResult, @@ -68,11 +95,43 @@ impl CellKind { } } - fn color(self) -> Color { + fn color(&self) -> Color { match self { - Self::SystemText => Color::DarkBlue, - Self::UserText => Color::Green, - Self::AssistantText => Color::White, + Self::SystemText => Color::Rgb { + r: 119, + g: 125, + b: 141, + }, + Self::UserText => Color::Rgb { + r: 111, + g: 176, + b: 131, + }, + Self::AssistantText => Color::Rgb { + r: 219, + g: 213, + b: 190, + }, + Self::Agent => Color::Rgb { + r: 157, + g: 132, + b: 205, + }, + Self::Instruction => Color::Rgb { + r: 205, + g: 160, + b: 106, + }, + Self::Environment => Color::Rgb { + r: 104, + g: 166, + b: 157, + }, + Self::Purpose => Color::Rgb { + r: 230, + g: 173, + b: 91, + }, Self::ToolCall => Color::Yellow, Self::ToolResult => Color::Cyan, Self::Image => Color::Magenta, @@ -80,9 +139,132 @@ impl CellKind { Self::Video => Color::DarkYellow, Self::Document => Color::DarkGreen, Self::Hitch => Color::Red, + Self::Tagged(tag) => tag_color(tag), Self::Unknown => Color::DarkGrey, } } + + fn name(&self) -> String { + match self { + Self::SystemText => "system".into(), + Self::UserText => "user".into(), + Self::AssistantText => "assistant".into(), + Self::Agent => "agent".into(), + Self::Instruction => "instruction".into(), + Self::Environment => "env".into(), + Self::Purpose => "purpose".into(), + Self::ToolCall => "tool_call".into(), + Self::ToolResult => "tool_result".into(), + Self::Image => "image".into(), + Self::Audio => "audio".into(), + Self::Video => "video".into(), + Self::Document => "document".into(), + Self::Hitch => "hitch".into(), + Self::Tagged(tag) => format!("tag:{tag}"), + Self::Unknown => "unknown".into(), + } + } + + /// Scaffolding fragments (agent/instruction/env/purpose) are re-emitted every + /// round — most visibly the `env` timestamp tick. Their updates should flash + /// in place rather than drag the main pointer back across the whole tape. + fn is_scaffolding(&self) -> bool { + matches!( + self, + Self::Agent | Self::Instruction | Self::Environment | Self::Purpose + ) + } +} + +fn tag_color(tag: &str) -> Color { + // 16 evenly-spread hues so distinct tags read as distinct colors. The hash is + // stable, so a given tag always gets the same tone across runs. + const PALETTE: [Color; 16] = [ + Color::Rgb { + r: 196, + g: 132, + b: 93, + }, + Color::Rgb { + r: 207, + g: 156, + b: 78, + }, + Color::Rgb { + r: 177, + g: 151, + b: 83, + }, + Color::Rgb { + r: 170, + g: 173, + b: 88, + }, + Color::Rgb { + r: 143, + g: 166, + b: 105, + }, + Color::Rgb { + r: 112, + g: 170, + b: 110, + }, + Color::Rgb { + r: 101, + g: 166, + b: 146, + }, + Color::Rgb { + r: 96, + g: 168, + b: 173, + }, + Color::Rgb { + r: 102, + g: 157, + b: 191, + }, + Color::Rgb { + r: 121, + g: 151, + b: 199, + }, + Color::Rgb { + r: 140, + g: 140, + b: 205, + }, + Color::Rgb { + r: 164, + g: 133, + b: 198, + }, + Color::Rgb { + r: 188, + g: 128, + b: 184, + }, + Color::Rgb { + r: 202, + g: 126, + b: 154, + }, + Color::Rgb { + r: 204, + g: 124, + b: 124, + }, + Color::Rgb { + r: 189, + g: 143, + b: 116, + }, + ]; + let hash = tag.bytes().fold(0usize, |accumulator, byte| { + accumulator.wrapping_mul(33) ^ byte as usize + }); + PALETTE[hash % PALETTE.len()] } #[derive(Clone)] @@ -91,10 +273,23 @@ enum TapeOp { index: usize, id: u64, kind: CellKind, + intake: bool, label: String, }, Replace { index: usize, + id: u64, + tag: String, + kind: CellKind, + label: String, + }, + /// In-place update of a scaffolding cell (env/agent/instruction/purpose). + /// Unlike `Replace`, it never moves the main pointer — it just blinks the + /// target cell where it sits, so the per-round env tick stops dragging the + /// pointer across long tapes. + Flash { + id: u64, + tag: String, kind: CellKind, label: String, }, @@ -104,19 +299,34 @@ enum TapeOp { label: String, }, Swap { - first: usize, - second: usize, + first: u64, + second: u64, label: String, }, } impl TapeOp { - fn target(&self) -> usize { + fn target(&self, tape: &TapeState) -> usize { match self { - Self::Write { index, .. } - | Self::Replace { index, .. } - | Self::Remove { index, .. } => *index, - Self::Swap { first, .. } => *first, + Self::Write { index, id, .. } + | Self::Replace { index, id, .. } + | Self::Remove { index, id, .. } => tape + .cells + .iter() + .position(|cell| cell.id == *id) + .unwrap_or(*index), + Self::Swap { first, .. } => tape + .cells + .iter() + .position(|cell| cell.id == *first) + .unwrap_or(0), + // Flash never travels; report its current cell so callers that + // inspect target stay consistent, but advance_tape handles it early. + Self::Flash { id, .. } => tape + .cells + .iter() + .position(|cell| cell.id == *id) + .unwrap_or(0), } } @@ -124,20 +334,103 @@ impl TapeOp { match self { Self::Write { label, .. } | Self::Replace { label, .. } + | Self::Flash { label, .. } | Self::Remove { label, .. } | Self::Swap { label, .. } => label, } } } -pub(crate) struct State { +#[derive(Clone, Copy, PartialEq, Eq)] +enum TapeStatus { + Waiting, + Running, + Done, + Skipped, +} + +impl TapeStatus { + fn glyph(self) -> &'static str { + match self { + Self::Waiting => "·", + Self::Running => "▷", + Self::Done => "✓", + Self::Skipped => "⊘", + } + } + + fn color(self) -> Color { + match self { + Self::Waiting => Color::DarkGrey, + Self::Running => Color::Cyan, + Self::Done => Color::Green, + Self::Skipped => Color::DarkYellow, + } + } +} + +struct TapeState { + name: String, + kind: String, cells: Vec<TapeCell>, pointer: usize, queue: VecDeque<TapeOp>, active: Option<TapeOp>, status: String, + lifecycle: TapeStatus, + frontier: Option<u64>, + tool_calls: usize, + /// Transient marker for cell-less actions (model switch, tool activate / + /// deactivate): `(text, remaining_ticks)`. Drawn on the status row and + /// counted down each animation tick. + badge: Option<(String, u8)>, + /// Sticky record of the last action this tape performed, for observability / + /// snapshot tests. Unlike `status` (which the next op's label overwrites) and + /// `badge` (which counts down to None), this persists. + last_action: String, + /// Ticks left to hold the in-place scaffolding flash, so it reads as a gentle + /// breathe rather than a one-frame blink. + flash_remaining: u8, +} + +/// How long a resource-action badge stays on screen, in animation ticks. +const BADGE_TICKS: u8 = 4; +/// How long a scaffolding cell stays lit during an in-place flash. +const FLASH_TICKS: u8 = 4; + +impl TapeState { + fn new(name: impl Into<String>, kind: impl Into<String>) -> Self { + Self { + name: name.into(), + kind: kind.into(), + cells: Vec::new(), + pointer: 0, + queue: VecDeque::new(), + active: None, + status: "waiting".into(), + lifecycle: TapeStatus::Waiting, + frontier: None, + tool_calls: 0, + badge: None, + last_action: String::new(), + flash_remaining: 0, + } + } + + fn set_badge(&mut self, text: impl Into<String>) { + self.badge = Some((text.into(), BADGE_TICKS)); + } +} + +struct ViewState { + graph: Option<String>, + frontier: Option<u64>, + frontier_count: usize, + tapes: BTreeMap<String, TapeState>, + tape_order: Vec<String>, summary: Summary, origin_y: u16, + reserved_rows: u16, } pub(crate) struct Summary { @@ -146,111 +439,385 @@ pub(crate) struct Summary { pub(crate) duration_s: f64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TapeSnapshot { + pub tapes: Vec<TapeSnapshotTape>, + pub hidden_tapes: usize, + pub fragments: usize, + pub tool_calls: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TapeSnapshotTape { + pub name: String, + pub pointer: usize, + pub status: String, + /// Sticky record of the last action verb (intake / refresh / overwriting / + /// swapping / activate … ). Survives later ops, unlike `status`. + pub last_action: String, + pub cells: Vec<TapeSnapshotCell>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TapeSnapshotCell { + pub id: u64, + pub tag: String, + pub tone: String, + pub glyph: String, +} + +pub fn snapshot_events<I>(events: I, reserved_rows: u16) -> TapeSnapshot +where + I: IntoIterator<Item = HookEvent>, +{ + let mut view = ViewState { + graph: None, + frontier: None, + frontier_count: 0, + tapes: BTreeMap::new(), + tape_order: Vec::new(), + summary: Summary { + fragments: 0, + tool_calls: 0, + duration_s: 0.0, + }, + origin_y: 0, + reserved_rows: reserved_rows.max(MIN_VIEW_ROWS), + }; + + for event in events { + apply_event(&mut view, event); + } + drain_animation_queues(&mut view); + view.summary.fragments = view.tapes.values().map(|tape| tape.cells.len()).sum(); + + let visible_tapes = ordered_visible_tapes(&view); + let tapes = visible_tapes + .iter() + .map(|tape| TapeSnapshotTape { + name: tape.name.clone(), + pointer: tape.pointer, + status: tape.status.clone(), + last_action: tape.last_action.clone(), + cells: tape + .cells + .iter() + .map(|cell| TapeSnapshotCell { + id: cell.id, + tag: cell.tag.clone(), + tone: cell.kind.name(), + glyph: cell_glyph(cell).into(), + }) + .collect(), + }) + .collect::<Vec<_>>(); + + TapeSnapshot { + hidden_tapes: ordered_tapes(&view).len().saturating_sub(tapes.len()), + fragments: view.summary.fragments, + tool_calls: view.summary.tool_calls, + tapes, + } +} + pub(crate) fn run_animation( rx: mpsc::Receiver<HookEvent>, delay_ms: u64, start: std::time::Instant, + runtime_thread: &thread::JoinHandle<()>, ) -> Summary { + if !std::io::stdout().is_terminal() { + return run_silent(rx, start, runtime_thread); + } + let step = Duration::from_millis(delay_ms); - let origin_y = reserve_animation_rows(); - let mut state = State { - cells: Vec::new(), - pointer: 0, - queue: VecDeque::new(), - active: None, - status: "booting".into(), + let reserved_rows = initial_view_rows(); + let origin_y = reserve_animation_rows(reserved_rows); + let mut view = ViewState { + graph: None, + frontier: None, + frontier_count: 0, + tapes: BTreeMap::new(), + tape_order: Vec::new(), summary: Summary { fragments: 0, tool_calls: 0, duration_s: 0.0, }, origin_y, + reserved_rows, }; execute!(std::io::stdout(), Hide).ok(); loop { for event in rx.try_iter() { - if matches!(event, HookEvent::Machine(MachineEvent::Done)) { - state.summary.duration_s = start.elapsed().as_secs_f64(); - finish_animation(&mut state, step); - return state.summary; + let finished = is_finish_event(&view, &event); + apply_event(&mut view, event); + if finished { + return finish_animation(&mut view, step, start); } - apply_event(&mut state, event); } - tick(&mut state, step); + tick(&mut view, step); match rx.recv_timeout(IDLE_TICK) { - Ok(HookEvent::Machine(MachineEvent::Done)) => { - state.summary.duration_s = start.elapsed().as_secs_f64(); - finish_animation(&mut state, step); - return state.summary; + Ok(event) => { + let finished = is_finish_event(&view, &event); + apply_event(&mut view, event); + if finished { + return finish_animation(&mut view, step, start); + } + } + Err(mpsc::RecvTimeoutError::Timeout) if runtime_thread.is_finished() => { + return finish_animation(&mut view, step, start); } - Ok(event) => apply_event(&mut state, event), Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => { - state.summary.duration_s = start.elapsed().as_secs_f64(); - finish_animation(&mut state, step); - return state.summary; + return finish_animation(&mut view, step, start); } } } } -fn reserve_animation_rows() -> u16 { +fn run_silent( + rx: mpsc::Receiver<HookEvent>, + start: std::time::Instant, + runtime_thread: &thread::JoinHandle<()>, +) -> Summary { + let mut graph_seen = false; + let mut summary = Summary { + fragments: 0, + tool_calls: 0, + duration_s: 0.0, + }; + + loop { + let event = match rx.recv_timeout(IDLE_TICK) { + Ok(event) => event, + Err(mpsc::RecvTimeoutError::Timeout) if runtime_thread.is_finished() => break, + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + }; + let root_event = event.source.is_none(); + match event.kind { + HookKind::Graph(GraphEvent::Start { .. }) if root_event => graph_seen = true, + HookKind::Graph(GraphEvent::Done { .. }) if root_event => break, + HookKind::Machine(MachineEvent::Done) if !graph_seen && root_event => break, + HookKind::Fragment(FragmentEvent::Appended(_)) + | HookKind::Fragment(FragmentEvent::Taken(_)) + | HookKind::Fragment(FragmentEvent::Inserted { .. }) => summary.fragments += 1, + HookKind::Fragment(FragmentEvent::Removed { .. }) => { + summary.fragments = summary.fragments.saturating_sub(1); + } + HookKind::Tool(ToolEvent::Result { .. }) => summary.tool_calls += 1, + _ => {} + } + } + + summary.duration_s = start.elapsed().as_secs_f64(); + summary +} + +fn initial_view_rows() -> u16 { + let (_, cursor_y) = position().unwrap_or((0, 0)); + let (_, height) = size().unwrap_or((80, MAX_VIEW_ROWS)); + let remaining = height.saturating_sub(cursor_y.saturating_add(1)); + remaining.clamp(MIN_VIEW_ROWS, MAX_VIEW_ROWS) +} + +fn reserve_animation_rows(rows: u16) -> u16 { let (_, before_y) = position().unwrap_or((0, 0)); - for _ in 0..4 { + for _ in 0..rows { println!(); } - let (_, after_y) = position().unwrap_or((0, before_y.saturating_add(4))); - after_y.saturating_sub(4) + let (_, after_y) = position().unwrap_or((0, before_y.saturating_add(rows))); + after_y.saturating_sub(rows) +} + +fn is_finish_event(view: &ViewState, event: &HookEvent) -> bool { + match &event.kind { + HookKind::Graph(GraphEvent::Done { .. }) => event.source.is_none(), + HookKind::Machine(MachineEvent::Done) => event.source.is_none() && view.graph.is_none(), + _ => false, + } } -fn finish_animation(state: &mut State, step: Duration) { - while state.active.is_some() || !state.queue.is_empty() { - tick(state, step); +fn finish_animation(view: &mut ViewState, step: Duration, start: std::time::Instant) -> Summary { + while has_pending_animation(view) { + tick(view, step); } - state.status = "done".into(); - render_frame(state); + view.summary.duration_s = start.elapsed().as_secs_f64(); + render_frame(view); thread::sleep(Duration::from_millis(250)); - execute!(std::io::stdout(), Show, MoveTo(0, state.origin_y + 4)).ok(); + execute!( + std::io::stdout(), + Show, + MoveTo(0, view.origin_y + view.reserved_rows) + ) + .ok(); + Summary { + fragments: view.summary.fragments, + tool_calls: view.summary.tool_calls, + duration_s: view.summary.duration_s, + } +} + +fn tick(view: &mut ViewState, step: Duration) { + advance_all_tapes(view); + render_frame(view); + thread::sleep(step.max(IDLE_TICK)); +} + +fn drain_animation_queues(view: &mut ViewState) { + while has_pending_animation(view) { + advance_all_tapes(view); + } +} + +fn has_pending_animation(view: &ViewState) -> bool { + view.tapes + .values() + .any(|tape| tape.active.is_some() || !tape.queue.is_empty()) +} + +fn advance_all_tapes(view: &mut ViewState) { + for tape in view.tapes.values_mut() { + advance_tape(tape); + } + view.summary.fragments = view.tapes.values().map(|tape| tape.cells.len()).sum(); } -fn tick(state: &mut State, step: Duration) { - if state.active.is_none() { - state.active = state.queue.pop_front(); +fn advance_tape(tape: &mut TapeState) { + tick_badge(tape); + + if tape.active.is_none() { + tape.active = tape.queue.pop_front(); + } + + let Some(op) = tape.active.as_ref() else { + return; + }; + + // Flash (scaffolding re-emit, e.g. the per-round env tick) never moves the + // main pointer — it blinks the target cell in place wherever it sits. + if matches!(op, TapeOp::Flash { .. }) { + tape.status = op.label().to_string(); + advance_active_op(tape); + return; + } + + let target = op.target(tape).min(tape.cells.len().saturating_sub(1)); + tape.status = op.label().to_string(); + match tape.pointer.cmp(&target) { + std::cmp::Ordering::Less => { + tape.pointer = target.min(tape.pointer + travel_stride(tape.pointer, target)); + fill_trail(tape); + } + std::cmp::Ordering::Greater => { + tape.pointer = target.max(tape.pointer - travel_stride(tape.pointer, target)); + } + std::cmp::Ordering::Equal => advance_active_op(tape), } +} - if let Some(op) = state.active.as_ref() { - let target = op.target().min(state.cells.len().saturating_sub(1)); - state.status = op.label().to_string(); - render_frame(state); - thread::sleep(step); +/// The head writes everything it passes: any still-pending cell behind the +/// current pointer is committed. Without this, a long jump (stride > 1) leaves a +/// trail of hollow □ cells the head skipped over — the "filled/hollow/filled" +/// patchwork. Only `Pending` is promoted; transient states (removing, swapping, +/// …) are left for their own op to resolve. +fn fill_trail(tape: &mut TapeState) { + let pointer = tape.pointer; + for cell in tape.cells.iter_mut().take(pointer) { + if cell.state == CellState::Pending { + cell.state = CellState::Written; + } + } +} - match state.pointer.cmp(&target) { - std::cmp::Ordering::Less => state.pointer += 1, - std::cmp::Ordering::Greater => state.pointer -= 1, - std::cmp::Ordering::Equal => advance_active_op(state), +fn tick_badge(tape: &mut TapeState) { + if let Some((_, remaining)) = tape.badge.as_mut() { + *remaining = remaining.saturating_sub(1); + if *remaining == 0 { + tape.badge = None; } + } +} + +fn travel_stride(pointer: usize, target: usize) -> usize { + let distance = pointer.abs_diff(target); + if distance <= LONG_JUMP_THRESHOLD { + 1 } else { - render_frame(state); - thread::sleep(IDLE_TICK); + distance.div_ceil(MAX_TRAVEL_TICKS).max(1) } } -fn advance_active_op(state: &mut State) { - if let Some(TapeOp::Replace { index, .. }) = state.active.as_ref() { - if let Some(cell) = state.cells.get_mut(*index) { - if cell.state != CellState::Replacing { - cell.state = CellState::Replacing; - state.status = "overwriting".into(); +fn advance_active_op(tape: &mut TapeState) { + // Intermediate-state passes: each returns after marking a transient state so + // the action reads as a deliberate motion rather than an instant settle. + if let Some(TapeOp::Replace { index, id, .. }) = tape.active.as_ref() { + let current_index = cell_index(tape, *id).unwrap_or(*index); + if let Some(cell) = tape.cells.get_mut(current_index) + && cell.state != CellState::Replacing + { + cell.state = CellState::Replacing; + tape.status = "overwriting".into(); + tape.last_action = "overwriting".into(); + return; + } + } + + if let Some(TapeOp::Flash { id, .. }) = tape.active.as_ref() { + let id = *id; + if let Some(index) = cell_index(tape, id) { + // Light it, hold it lit for a few ticks (a gentle breathe rather than + // a one-frame blink), then fall through to settle. + if tape.cells[index].state != CellState::Flashing { + tape.cells[index].state = CellState::Flashing; + tape.flash_remaining = FLASH_TICKS; + tape.status = "refresh".into(); + tape.last_action = "refresh".into(); return; } + if tape.flash_remaining > 0 { + tape.flash_remaining -= 1; + return; + } + } + } + + if let Some(TapeOp::Write { + index, + id, + intake: true, + .. + }) = tape.active.as_ref() + { + let current_index = cell_index(tape, *id).unwrap_or(*index); + if let Some(cell) = tape.cells.get_mut(current_index) + && cell.state != CellState::Taking + { + cell.state = CellState::Taking; + tape.status = "intake".into(); + tape.last_action = "intake".into(); + return; } } - let Some(op) = state.active.take() else { + let swap_ids = match tape.active.as_ref() { + Some(TapeOp::Swap { first, second, .. }) => Some((*first, *second)), + _ => None, + }; + if let Some((first, second)) = swap_ids + && mark_swap_cells(tape, first, second) + { + tape.status = "swapping".into(); + tape.last_action = "swapping".into(); + return; + } + + let Some(op) = tape.active.take() else { return; }; @@ -258,131 +825,377 @@ fn advance_active_op(state: &mut State) { TapeOp::Write { index, id, kind, .. } => { - if let Some(cell) = state.cells.get_mut(index) { + let current_index = cell_index(tape, id).unwrap_or(index); + if let Some(cell) = tape.cells.get_mut(current_index) { cell.id = id; cell.kind = kind; cell.state = CellState::Written; } } - TapeOp::Replace { index, kind, .. } => { - if let Some(cell) = state.cells.get_mut(index) { + TapeOp::Replace { + index, + id, + tag, + kind, + .. + } => { + let current_index = cell_index(tape, id).unwrap_or(index); + if let Some(cell) = tape.cells.get_mut(current_index) { + cell.tag = tag; + cell.kind = kind; + cell.state = CellState::Written; + } + } + TapeOp::Flash { id, tag, kind, .. } => { + if let Some(index) = cell_index(tape, id) { + let cell = &mut tape.cells[index]; + cell.tag = tag; cell.kind = kind; cell.state = CellState::Written; } } TapeOp::Remove { index, id, .. } => { - if state.cells.get(index).is_some_and(|cell| cell.id == id) { - state.cells.remove(index); - if !state.cells.is_empty() { - state.pointer = state.pointer.min(state.cells.len() - 1); + let current_index = cell_index(tape, id).unwrap_or(index); + if tape + .cells + .get(current_index) + .is_some_and(|cell| cell.id == id) + { + tape.cells.remove(current_index); + if !tape.cells.is_empty() { + tape.pointer = tape.pointer.min(tape.cells.len() - 1); } else { - state.pointer = 0; + tape.pointer = 0; } } } TapeOp::Swap { first, second, .. } => { - if first < state.cells.len() && second < state.cells.len() { - state.cells.swap(first, second); + if let (Some(first_index), Some(second_index)) = + (cell_index(tape, first), cell_index(tape, second)) + { + tape.cells.swap(first_index, second_index); + if let Some(cell) = tape.cells.get_mut(first_index) { + cell.state = CellState::Written; + } + if let Some(cell) = tape.cells.get_mut(second_index) { + cell.state = CellState::Written; + } + } + } + } +} + +fn mark_swap_cells(tape: &mut TapeState, first: u64, second: u64) -> bool { + let (Some(first_index), Some(second_index)) = + (cell_index(tape, first), cell_index(tape, second)) + else { + return false; + }; + if tape.cells[first_index].state == CellState::Swapping + && tape.cells[second_index].state == CellState::Swapping + { + return false; + } + tape.cells[first_index].state = CellState::Swapping; + tape.cells[second_index].state = CellState::Swapping; + true +} + +fn cell_index(tape: &TapeState, id: u64) -> Option<usize> { + tape.cells.iter().position(|cell| cell.id == id) +} + +fn apply_event(view: &mut ViewState, event: HookEvent) { + let source = event.source; + match event.kind { + HookKind::Graph(event) => apply_graph_event(view, event), + HookKind::Component(event) => apply_component_event(view, event), + HookKind::Fragment(event) => { + apply_fragment_event(tape_for_source(view, source.as_ref()), event) + } + HookKind::Tool(event) => apply_tool_event(view, source.as_ref(), event), + HookKind::Completion(event) => { + apply_completion_event(tape_for_source(view, source.as_ref()), event) + } + HookKind::Machine(event) => { + apply_machine_event(tape_for_source(view, source.as_ref()), event) + } + HookKind::Resource(event) => { + apply_resource_event(tape_for_source(view, source.as_ref()), event) + } + } +} + +fn apply_graph_event(view: &mut ViewState, event: GraphEvent) { + match event { + GraphEvent::Start { graph } => view.graph = Some(graph), + GraphEvent::Done { graph } => { + view.graph = Some(graph); + for tape in view.tapes.values_mut() { + if tape.lifecycle == TapeStatus::Running { + tape.lifecycle = TapeStatus::Done; + } + } + } + GraphEvent::FrontierStart { + graph, + frontier, + count, + } => { + view.graph = Some(graph); + view.frontier = Some(frontier); + view.frontier_count = count; + } + GraphEvent::FrontierDone { + graph, + frontier, + count, + } => { + view.graph = Some(graph); + view.frontier = Some(frontier); + view.frontier_count = view.frontier_count.saturating_sub(count); + } + } +} + +fn apply_component_event(view: &mut ViewState, event: ComponentEvent) { + match event { + ComponentEvent::Start(meta) => { + if meta.kind != "accelerator" { + return; + } + let tape = tape_for_source(view, Some(&meta)); + tape.name = meta.name; + tape.kind = meta.kind; + tape.frontier = meta.frontier; + tape.lifecycle = TapeStatus::Running; + tape.status = "start".into(); + } + ComponentEvent::Done(meta) => { + if meta.kind != "accelerator" { + return; + } + let tape = tape_for_source(view, Some(&meta)); + tape.name = meta.name; + tape.kind = meta.kind; + tape.frontier = meta.frontier; + tape.lifecycle = TapeStatus::Done; + tape.status = "done".into(); + } + ComponentEvent::Skipped(meta) => { + if meta.kind != "accelerator" { + return; } + let tape = tape_for_source(view, Some(&meta)); + tape.name = meta.name; + tape.kind = meta.kind; + tape.frontier = meta.frontier; + tape.lifecycle = TapeStatus::Skipped; + tape.status = "skipped".into(); } } +} - state.summary.fragments = state.cells.len(); +fn apply_fragment_event(tape: &mut TapeState, event: FragmentEvent) { + match event { + FragmentEvent::Appended(meta) => enqueue_write(tape, meta, "write"), + FragmentEvent::Taken(meta) => enqueue_take(tape, meta), + FragmentEvent::Inserted { meta, after } => enqueue_insert(tape, meta, after), + FragmentEvent::Replaced(meta) => enqueue_replace(tape, meta), + FragmentEvent::Removed { id } => enqueue_remove(tape, id), + FragmentEvent::Swapped { first, second } => enqueue_swap(tape, first, second), + } } -fn apply_event(state: &mut State, event: HookEvent) { +fn apply_tool_event(view: &mut ViewState, source: Option<&ComponentMeta>, event: ToolEvent) { match event { - HookEvent::Fragment(FragmentEvent::Appended(meta)) - | HookEvent::Fragment(FragmentEvent::Taken(meta)) => enqueue_write(state, meta, "write"), - HookEvent::Fragment(FragmentEvent::Inserted(meta)) => enqueue_write(state, meta, "insert"), - HookEvent::Fragment(FragmentEvent::Replaced(meta)) => enqueue_replace(state, meta), - HookEvent::Fragment(FragmentEvent::Removed { id }) => enqueue_remove(state, id), - HookEvent::Fragment(FragmentEvent::Swapped { first, second }) => { - enqueue_swap(state, first, second) - } - HookEvent::Tool(ToolEvent::Call { tool, arguments }) => { - state.status = format!("tool {tool} args={}B", arguments.len()); - } - HookEvent::Tool(ToolEvent::Result { + ToolEvent::Call { + call_id, + tool, + arguments, + } => { + tape_for_source(view, source).status = + format!("tool {tool} #{call_id} args={}B", arguments.len()); + } + ToolEvent::Result { + call_id, tool, result_len, duration, - }) => { - state.summary.tool_calls += 1; - state.status = format!("tool {tool} done {result_len}B {duration}"); + } => { + view.summary.tool_calls += 1; + let tape = tape_for_source(view, source); + tape.tool_calls += 1; + tape.status = format!("tool {tool} #{call_id} done {result_len}B {duration}"); } - HookEvent::Tool(ToolEvent::Error { + ToolEvent::Error { + call_id, tool, error, retryable, - }) => { + } => { let retry = if retryable { "retry" } else { "fatal" }; - state.status = format!("tool {tool} error {retry}: {error}"); - } - HookEvent::Completion(CompletionEvent::Start) => { - state.status = "completion".into(); - } - HookEvent::Completion(CompletionEvent::End { fragments }) => { - state.status = format!("drain {fragments} fragments"); + tape_for_source(view, source).status = + format!("tool {tool} #{call_id} error {retry}: {error}"); } - HookEvent::Machine(MachineEvent::Halt { round }) => { - state.status = format!("halt #{round}"); + } +} + +fn apply_completion_event(tape: &mut TapeState, event: CompletionEvent) { + match event { + CompletionEvent::Start => tape.status = "completion".into(), + CompletionEvent::End { fragments, .. } => { + tape.status = format!("drain {fragments} fragments") } - HookEvent::Machine(MachineEvent::Start) => { - state.status = "start".into(); + } +} + +fn apply_machine_event(tape: &mut TapeState, event: MachineEvent) { + match event { + MachineEvent::Start => { + tape.lifecycle = TapeStatus::Running; + tape.status = "start".into(); } - HookEvent::Resource(ResourceEvent::Model { name }) => { - state.status = format!("model {name}"); + MachineEvent::Halt { step } => tape.status = format!("halt #{step}"), + MachineEvent::Done => { + tape.lifecycle = TapeStatus::Done; + tape.status = "done".into(); } - HookEvent::Resource(ResourceEvent::Activate { name }) => { - state.status = format!("activate {name}"); + } +} + +fn apply_resource_event(tape: &mut TapeState, event: ResourceEvent) { + // Resource actions have no cell, so they surface as a transient badge on the + // status row (held for a few ticks) plus the status text. + let (badge, status) = match event { + ResourceEvent::Model { name } => (format!("⟳ model {name}"), format!("model {name}")), + ResourceEvent::Activate { name } => { + (format!("⚡ activate {name}"), format!("activate {name}")) } - HookEvent::Resource(ResourceEvent::Deactivate { name }) => { - state.status = format!("deactivate {name}"); + ResourceEvent::Deactivate { name } => { + (format!("⌁ deactivate {name}"), format!("deactivate {name}")) } - HookEvent::Machine(MachineEvent::Done) => unreachable!(), + }; + tape.set_badge(badge); + tape.status = status.clone(); + tape.last_action = status; +} + +fn tape_for_source<'a>( + view: &'a mut ViewState, + source: Option<&ComponentMeta>, +) -> &'a mut TapeState { + let key = source.map(component_key).unwrap_or_else(|| "main".into()); + if !view.tapes.contains_key(&key) { + view.tape_order.push(key.clone()); + let tape = match source { + Some(meta) => TapeState::new(meta.name.clone(), meta.kind.clone()), + None => TapeState::new("accelerator", "accelerator"), + }; + view.tapes.insert(key.clone(), tape); } + view.tapes.get_mut(&key).expect("tape was just inserted") +} + +fn component_key(meta: &ComponentMeta) -> String { + format!("{}:{}:{}", meta.graph, meta.index, meta.name) +} + +fn enqueue_write(tape: &mut TapeState, meta: FragmentMeta, action: &str) { + let index = tape.cells.len(); + enqueue_cell_write(tape, meta, index, action, false); +} + +fn enqueue_take(tape: &mut TapeState, meta: FragmentMeta) { + let index = tape.cells.len(); + enqueue_cell_write(tape, meta, index, "take", true); } -fn enqueue_write(state: &mut State, meta: FragmentMeta, action: &str) { - let index = state.cells.len(); +fn enqueue_insert(tape: &mut TapeState, meta: FragmentMeta, after: u64) { + let index = tape + .cells + .iter() + .position(|cell| cell.id == after) + .map(|index| index + 1) + .unwrap_or(tape.cells.len()); + enqueue_cell_write(tape, meta, index, "insert", false); +} + +fn enqueue_cell_write( + tape: &mut TapeState, + meta: FragmentMeta, + index: usize, + action: &str, + intake: bool, +) { let kind = CellKind::from_meta(&meta); - state.cells.push(TapeCell { + let cell = TapeCell { id: meta.id, - kind, + tag: meta.tag.clone(), + kind: kind.clone(), state: CellState::Pending, - }); - state.queue.push_back(TapeOp::Write { + }; + if index >= tape.cells.len() { + tape.cells.push(cell); + } else { + tape.cells.insert(index, cell); + } + tape.queue.push_back(TapeOp::Write { index, id: meta.id, kind, - label: format!( - "{action} {}/{} #{} {}", - meta.role, meta.kind, meta.id, meta.preview - ), + intake, + label: fragment_label(action, &meta), }); } -fn enqueue_replace(state: &mut State, meta: FragmentMeta) { +fn enqueue_replace(tape: &mut TapeState, meta: FragmentMeta) { let kind = CellKind::from_meta(&meta); - let Some(index) = state.cells.iter().position(|cell| cell.id == meta.id) else { - enqueue_write(state, meta, "write"); + if tape.cells.iter().all(|cell| cell.id != meta.id) { + enqueue_write(tape, meta, "write"); return; - }; - state.queue.push_back(TapeOp::Replace { + } + // Scaffolding (env/agent/instruction/purpose) is re-emitted every round; + // flash it in place so the pointer doesn't run back across the tape. + if kind.is_scaffolding() { + tape.queue.push_back(TapeOp::Flash { + id: meta.id, + tag: meta.tag.clone(), + kind, + label: fragment_label("refresh", &meta), + }); + return; + } + let index = tape + .cells + .iter() + .position(|cell| cell.id == meta.id) + .expect("id presence checked above"); + tape.queue.push_back(TapeOp::Replace { index, + id: meta.id, + tag: meta.tag.clone(), kind, - label: format!( - "replace {}/{} #{} {}", - meta.role, meta.kind, meta.id, meta.preview - ), + label: fragment_label("replace", &meta), }); } -fn enqueue_remove(state: &mut State, id: u64) { - if let Some(index) = state.cells.iter().position(|cell| cell.id == id) { - state.cells[index].state = CellState::Removing; - state.queue.push_back(TapeOp::Remove { +fn fragment_label(action: &str, meta: &FragmentMeta) -> String { + let tag = if meta.tag.is_empty() { + String::new() + } else { + format!(" [{}]", meta.tag) + }; + format!( + "{action} {}/{}{} #{} {}", + meta.role, meta.kind, tag, meta.id, meta.preview + ) +} + +fn enqueue_remove(tape: &mut TapeState, id: u64) { + if let Some(index) = tape.cells.iter().position(|cell| cell.id == id) { + tape.cells[index].state = CellState::Removing; + tape.queue.push_back(TapeOp::Remove { index, id, label: format!("remove #{id}"), @@ -390,42 +1203,160 @@ fn enqueue_remove(state: &mut State, id: u64) { } } -fn enqueue_swap(state: &mut State, first: u64, second: u64) { - let first_index = state.cells.iter().position(|cell| cell.id == first); - let second_index = state.cells.iter().position(|cell| cell.id == second); - if let (Some(first_index), Some(second_index)) = (first_index, second_index) { - state.queue.push_back(TapeOp::Swap { - first: first_index, - second: second_index, +fn enqueue_swap(tape: &mut TapeState, first: u64, second: u64) { + if cell_index(tape, first).is_some() && cell_index(tape, second).is_some() { + tape.queue.push_back(TapeOp::Swap { + first, + second, label: format!("swap #{first} ↔ #{second}"), }); } } -fn render_frame(state: &State) { +fn render_frame(view: &mut ViewState) { let width = crossterm::terminal::size() .map(|(width, _)| width as usize) .unwrap_or(80); let inner = width.saturating_sub(4).max(20); - let max_cells = (inner / CELL_WIDTH).saturating_sub(1); - let start = state.cells.len().saturating_sub(max_cells); - let visible = &state.cells[start..]; - let pointer_index = state - .pointer - .saturating_sub(start) - .min(visible.len().saturating_sub(1)); - let pointer_col = pointer_index * CELL_WIDTH; - let mut out = std::io::stdout(); - draw_border(&mut out, state.origin_y, width, "Context Tape"); - draw_tape(&mut out, state.origin_y + 1, visible, inner); - draw_machine(&mut out, state.origin_y + 2, pointer_col, state, inner); - draw_footer(&mut out, state.origin_y + 3, width, state); + for row in 0..view.reserved_rows { + execute!( + out, + MoveTo(0, view.origin_y + row), + Clear(ClearType::CurrentLine) + ) + .ok(); + } + + let visible_tapes = ordered_visible_tapes(view); + let hidden_tapes = ordered_tapes(view) + .len() + .saturating_sub(visible_tapes.len()); + + draw_border(&mut out, view.origin_y, width, &view_title(view)); + draw_graph_status(&mut out, view.origin_y + 1, inner, view, hidden_tapes); + let mut row = view.origin_y + 2; + let last_body_row = view.origin_y + view.reserved_rows - 2; + for tape in visible_tapes { + if row + 2 > last_body_row { + break; + } + draw_tape_card(&mut out, row, inner, tape); + row += 3; + } + + draw_footer( + &mut out, + view.origin_y + view.reserved_rows - 1, + width, + view, + ); out.flush().ok(); } +fn view_title(view: &ViewState) -> String { + match &view.graph { + Some(graph) => format!("Graph · {graph}"), + None => "Context Tape".into(), + } +} + +fn draw_graph_status( + out: &mut std::io::Stdout, + row: u16, + inner: usize, + view: &ViewState, + hidden_tapes: usize, +) { + execute!(out, MoveTo(0, row), Clear(ClearType::CurrentLine)).ok(); + let running = view + .tapes + .values() + .filter(|tape| tape.kind == "accelerator" && tape.lifecycle == TapeStatus::Running) + .count(); + let done = view + .tapes + .values() + .filter(|tape| tape.kind == "accelerator" && tape.lifecycle == TapeStatus::Done) + .count(); + let skipped = view + .tapes + .values() + .filter(|tape| tape.kind == "accelerator" && tape.lifecycle == TapeStatus::Skipped) + .count(); + let frontier = view + .frontier + .map(|frontier| format!("frontier #{frontier}")) + .unwrap_or_else(|| "booting".into()); + let hidden = if hidden_tapes == 0 { + String::new() + } else { + format!(" · +{hidden_tapes} hidden") + }; + let line = format!( + "{frontier} · running {running}/{} · done {done} · skipped {skipped}{hidden}", + view.frontier_count + ); + draw_inside(out, row, inner, &line, Color::DarkCyan); +} + +fn draw_tape_card(out: &mut std::io::Stdout, row: u16, inner: usize, tape: &TapeState) { + let frontier = tape + .frontier + .map(|frontier| format!(" · frontier #{frontier}")) + .unwrap_or_default(); + let title = format!("{} {}{}", tape.name, tape.lifecycle.glyph(), frontier); + draw_inside(out, row, inner, &title, tape.lifecycle.color()); + draw_tape(out, row + 1, tape, inner); + draw_tape_status(out, row + 2, inner, tape); +} + +fn ordered_visible_tapes(view: &ViewState) -> Vec<&TapeState> { + let tapes = ordered_tapes(view); + let capacity = visible_tape_capacity(view); + if tapes.len() <= capacity { + return tapes; + } + + let mut visible = Vec::new(); + for tape in tapes + .iter() + .copied() + .filter(|tape| tape.lifecycle == TapeStatus::Running) + { + if visible.len() == capacity { + return visible; + } + visible.push(tape); + } + for tape in tapes { + if visible.len() == capacity { + break; + } + if !visible + .iter() + .any(|visible_tape| std::ptr::eq(*visible_tape, tape)) + { + visible.push(tape); + } + } + visible +} + +fn visible_tape_capacity(view: &ViewState) -> usize { + ((view.reserved_rows.saturating_sub(3)) / 3).max(1) as usize +} + +fn ordered_tapes(view: &ViewState) -> Vec<&TapeState> { + view.tape_order + .iter() + .filter_map(|key| view.tapes.get(key)) + .filter(|tape| tape.kind == "accelerator") + .collect() +} + fn draw_border(out: &mut std::io::Stdout, row: u16, width: usize, title: &str) { execute!(out, MoveTo(0, row), Clear(ClearType::CurrentLine)).ok(); let label = format!(" {title} "); @@ -434,98 +1365,141 @@ fn draw_border(out: &mut std::io::Stdout, row: u16, width: usize, title: &str) { out, "{}{}{}{}", "╭".with(Color::DarkGrey), - "─".repeat(1).with(Color::DarkGrey), + "─".with(Color::DarkGrey), label.with(Color::DarkCyan), format!("{}╮", "─".repeat(right.saturating_sub(1))).with(Color::DarkGrey), ) .ok(); } -fn draw_footer(out: &mut std::io::Stdout, row: u16, width: usize, state: &State) { +fn draw_footer(out: &mut std::io::Stdout, row: u16, width: usize, view: &ViewState) { execute!(out, MoveTo(0, row), Clear(ClearType::CurrentLine)).ok(); let label = format!( " {} cells · {} tools · {:.1}s ", - state.cells.len(), - state.summary.tool_calls, - state.summary.duration_s, + view.summary.fragments, view.summary.tool_calls, view.summary.duration_s, ); let right = width.saturating_sub(label.chars().count() + 2); write!( out, "{}{}{}{}", "╰".with(Color::DarkGrey), - "─".repeat(1).with(Color::DarkGrey), + "─".with(Color::DarkGrey), label.with(Color::DarkCyan), format!("{}╯", "─".repeat(right.saturating_sub(1))).with(Color::DarkGrey), ) .ok(); } -fn draw_tape(out: &mut std::io::Stdout, row: u16, cells: &[TapeCell], inner: usize) { +fn draw_inside(out: &mut std::io::Stdout, row: u16, inner: usize, text: &str, color: Color) { execute!(out, MoveTo(0, row), Clear(ClearType::CurrentLine)).ok(); - write!(out, "{} ", "│".with(Color::DarkGrey)).ok(); - if cells.is_empty() { - write!(out, "{}", "□".with(Color::DarkGrey)).ok(); - } else { - for cell in cells { - write!(out, "{} ", cell_glyph(cell).with(cell_color(cell))).ok(); - } - } - let used = if cells.is_empty() { - 1 - } else { - cells.len() * CELL_WIDTH - }; + let text = trim_to_width(text, inner); + let used = text.chars().count(); write!( out, - "{}{}", + "{} {}{}{}", + "│".with(Color::DarkGrey), + text.with(color), " ".repeat(inner.saturating_sub(used)), - "│".with(Color::DarkGrey) + "│".with(Color::DarkGrey), ) .ok(); } -fn draw_machine( - out: &mut std::io::Stdout, - row: u16, - pointer_col: usize, - state: &State, - inner: usize, -) { +fn draw_tape(out: &mut std::io::Stdout, row: u16, tape: &TapeState, inner: usize) { execute!(out, MoveTo(0, row), Clear(ClearType::CurrentLine)).ok(); write!(out, "{} ", "│".with(Color::DarkGrey)).ok(); - let status = status_line(state, inner); - for _ in 0..pointer_col { - write!(out, " ").ok(); + let (start, end) = visible_window(tape, inner); + if start > 0 { + write!(out, "{} ", "…".with(Color::DarkGrey)).ok(); + } + if tape.cells.is_empty() { + write!(out, "{}", "□".with(Color::DarkGrey)).ok(); + } else { + for index in start..end { + let cell = &tape.cells[index]; + let glyph = if index == tape.pointer { + "◈" + } else { + cell_glyph(cell) + }; + write!(out, "{} ", glyph.with(cell_color(cell))).ok(); + } } - write!(out, "{}", "⚙".with(Color::Cyan).bold()).ok(); - write!(out, " {}", status.as_str().with(Color::DarkCyan)).ok(); - let used = pointer_col + 1 + 1 + status.chars().count(); + if end < tape.cells.len() { + write!(out, "{}", "…".with(Color::DarkGrey)).ok(); + } + write!(out, "{}", "│".with(Color::DarkGrey)).ok(); +} + +fn draw_tape_status(out: &mut std::io::Stdout, row: u16, inner: usize, tape: &TapeState) { + execute!(out, MoveTo(0, row), Clear(ClearType::CurrentLine)).ok(); + let (start, _) = visible_window(tape, inner); + let ellipsis_offset = if start > 0 { 2 } else { 0 }; + let pointer_offset = tape.pointer.saturating_sub(start) * CELL_WIDTH; + let gear_col = (ellipsis_offset + pointer_offset).min(inner.saturating_sub(2)); + let badge = tape + .badge + .as_ref() + .map(|(text, _)| format!("{text} · ")) + .unwrap_or_default(); + let status = format!( + "{}{} · {} · {} cells · {} tools", + badge, + one_line(&tape.status), + position_text(tape), + tape.cells.len(), + tape.tool_calls + ); + let status = trim_to_width(&status, inner.saturating_sub(gear_col + 2)); + let used = gear_col + 2 + status.chars().count(); write!( out, - "{}{}", + "{} {}{} {}{}{}", + "│".with(Color::DarkGrey), + " ".repeat(gear_col), + "⚙".with(Color::Cyan).bold(), + status.with(Color::DarkCyan), " ".repeat(inner.saturating_sub(used)), - "│".with(Color::DarkGrey) + "│".with(Color::DarkGrey), ) .ok(); } -fn status_line(state: &State, width: usize) -> String { - let position = if state.cells.is_empty() { - "0/0".to_string() +fn visible_window(tape: &TapeState, inner: usize) -> (usize, usize) { + if tape.cells.is_empty() { + return (0, 0); + } + let max_cells = (inner / CELL_WIDTH).saturating_sub(3).max(1); + if tape.cells.len() <= max_cells { + return (0, tape.cells.len()); + } + let half = max_cells / 2; + let start = tape + .pointer + .saturating_sub(half) + .min(tape.cells.len().saturating_sub(max_cells)); + (start, start + max_cells) +} + +fn position_text(tape: &TapeState) -> String { + if tape.cells.is_empty() { + "0/0".into() } else { - format!("{}/{}", state.pointer + 1, state.cells.len()) - }; - let mut line = format!("{} {position}", one_line(&state.status)); - let max_len = width.saturating_sub(4).max(20); - if line.chars().count() > max_len { - line = line - .chars() - .take(max_len.saturating_sub(1)) - .collect::<String>(); - line.push('…'); + format!("{}/{}", tape.pointer + 1, tape.cells.len()) + } +} + +fn trim_to_width(text: &str, width: usize) -> String { + let text = one_line(text); + if text.chars().count() <= width { + return text; } - line + let mut trimmed = text + .chars() + .take(width.saturating_sub(1)) + .collect::<String>(); + trimmed.push('…'); + trimmed } fn one_line(text: &str) -> String { @@ -536,14 +1510,22 @@ fn cell_glyph(cell: &TapeCell) -> &'static str { match cell.state { CellState::Pending | CellState::Removing => "□", CellState::Replacing => "◈", + CellState::Flashing => "◇", + CellState::Taking => "▼", + CellState::Swapping => "◆", CellState::Written => "■", } } fn cell_color(cell: &TapeCell) -> Color { match cell.state { - CellState::Removing => Color::Red, + // Red is reserved for errors (hitch). A removal is a normal action, so it + // fades out in dim grey rather than signalling an error. + CellState::Removing => Color::DarkGrey, CellState::Replacing => Color::Yellow, + CellState::Flashing => Color::White, + CellState::Taking => Color::Green, + CellState::Swapping => Color::Magenta, CellState::Pending | CellState::Written => cell.kind.color(), } } diff --git a/crates/cli/src/output/text.rs b/crates/cli/src/output/text.rs index 221db0ff..71b74267 100644 --- a/crates/cli/src/output/text.rs +++ b/crates/cli/src/output/text.rs @@ -53,11 +53,15 @@ fn final_message(ctx: &Context) -> Option<&str> { } fn print_fragment(role: Role, content: &Content) { - let tag = match role { - Role::System => "system", - Role::User => "user", - Role::Assistant => "assistant", - Role::Tool => "tool", + let tag = match content { + Content::ToolCall(_) => "tool_call", + Content::ToolResult(_) => "tool_result", + _ => match role { + Role::System => "system", + Role::User => "user", + Role::Assistant => "assistant", + Role::Tool => "tool", + }, }; match content { @@ -79,14 +83,9 @@ fn print_fragment(role: Role, content: &Content) { Content::Audio(audio) => println!("[{tag}] <audio {:?}>", audio.media_type), Content::Video(video) => println!("[{tag}] <video {:?}>", video.media_type), Content::Document(document) => println!("[{tag}] <document {:?}>", document.media_type), - Content::Hitch { - message, - retryable, - code, - } => { - let retry = if *retryable { " (retryable)" } else { "" }; - let status = code.map(|code| format!(" HTTP {code}")).unwrap_or_default(); - println!("[{tag}] hitch{}{} {message}", status, retry); + Content::Hitch { message, code, .. } => { + let status = code.map(|c| format!(" HTTP {c}")).unwrap_or_default(); + println!("[{tag}] hitch{status} {message}"); } } } diff --git a/crates/cli/src/rcm/ast.rs b/crates/cli/src/rcm/ast.rs new file mode 100644 index 00000000..92ad063c --- /dev/null +++ b/crates/cli/src/rcm/ast.rs @@ -0,0 +1,187 @@ +use serde::Serialize; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize)] +pub struct RcmFile { + pub name: String, + pub uses: Vec<UseDef>, + pub models: Vec<ModelDef>, + pub mcps: Vec<McpDef>, + pub body: AcceleratorBodyDef, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UseDef { + pub path: String, + pub alias: String, +} + +#[derive(Debug, Clone, Serialize)] +pub enum AcceleratorBodyDef { + Primitive(PrimitiveDef), + Graph(GraphDef), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum PromptSourceDef { + Inline(String), + File(String), +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct PrimitiveDef { + pub purpose: Option<String>, + pub models: Vec<String>, + pub prompts: Option<HashMap<String, PromptSourceDef>>, + pub tools: Option<Vec<String>>, + pub mcps: Option<Vec<String>>, + pub policy: Option<String>, + pub environment: Option<String>, + /// Aliases of `use`-imported accelerators to expose as `spawn_<alias>` tools. + /// The planner LLM calls these tools to dispatch parallel workers. + pub spawns: Vec<String>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct GraphDef { + pub accelerators: Vec<GraphAcceleratorDef>, + pub fluxes: Vec<FluxDef>, + pub conditions: Vec<ConditionDef>, + pub wires: Vec<WireDef>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct GraphAcceleratorDef { + pub id: String, + pub source: AcceleratorSourceDef, +} + +#[derive(Debug, Clone, Serialize)] +pub enum AcceleratorSourceDef { + Inline(PrimitiveDef), + Import { + alias: String, + overrides: PrimitiveDef, + }, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ModelDef { + pub id: String, + pub protocol: String, + pub endpoint: Option<String>, + pub credentials_env: Option<String>, + pub credentials_key: Option<String>, + pub limit_context: Option<u64>, + pub limit_input: Option<u64>, + pub limit_output: u64, + pub modalities_input: Vec<String>, + pub modalities_output: Vec<String>, + pub headers: HashMap<String, String>, + pub thinking: bool, + /// Per-request timeout in seconds. `None` uses the engine default. + pub timeout: Option<u64>, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FluxDef { + pub id: String, + pub name: Option<String>, + pub channel: String, + pub mode: String, + pub from: Option<String>, + pub to: Option<String>, + pub arity: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ConditionDef { + pub id: String, + pub name: Option<String>, + pub predicate: Predicate, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WireDef { + pub from: PortDef, + pub to: PortDef, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PortDef { + pub owner: PortOwnerDef, + pub endpoint: EndpointDef, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum PortOwnerDef { + Input, + Output, + Component(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum EndpointDef { + Trigger, + Done, + State(String), + FluxOut, + FluxSlot(usize), + ConditionTrue, + ConditionFalse, +} + +#[derive(Debug, Clone, Serialize)] +pub struct McpDef { + pub label: String, + pub transport: McpTransportDef, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum McpTransportDef { + Stdio { + command: String, + args: Vec<String>, + env: HashMap<String, McpValueDef>, + cwd: Option<String>, + }, + Http { + url: String, + headers: HashMap<String, McpValueDef>, + }, + Sse { + url: String, + headers: HashMap<String, McpValueDef>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum McpValueDef { + Literal(String), + Env(String), +} + +#[derive(Debug, Clone, Serialize)] +pub enum Predicate { + PurposeContains(String), + PurposeEquals(String), + PurposeStartsWith(String), + PurposeEndsWith(String), + PurposeIsEmpty, + ContextHasTag(String), + ContextHasRole(String), + ContextContains(String), + ContextIsEmpty, + EnvVarExists(String), + EnvVarEquals(String, String), + EnvCwdContains(String), + EnvPlatformIs(String), + ResHasModel(String), + ResActiveModelIs(String), + ResHasTool(String), + ResToolEnabled(String), + ResHasPrompt(String), + All(Vec<Predicate>), + Any(Vec<Predicate>), + Not(Box<Predicate>), +} diff --git a/crates/cli/src/rcm/compile.rs b/crates/cli/src/rcm/compile.rs new file mode 100644 index 00000000..07ebdf11 --- /dev/null +++ b/crates/cli/src/rcm/compile.rs @@ -0,0 +1,748 @@ +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; + +use accelerator::mcp::{McpServerConfig, McpTransportConfig}; +use accelerator::tools::SpawnTool; +use accelerator::{ + Accelerator, BridgeKind, Catalog, Channel, ComponentRef, ContextFlux, ContextPredicate, + Endpoint, EnvFlux, EnvironmentPredicate, FluxMode, Graph, Port, Predicate as AccelPredicate, + PurposeFlux, PurposePredicate, ResFlux, ResourceSelection, ResourcesPredicate, +}; +use machine::{Limit, Modalities, Modality, Model, Policy, Protocol, Purpose, RunState}; + +use super::ast::{ + self, AcceleratorBodyDef, AcceleratorSourceDef, EndpointDef, McpTransportDef, McpValueDef, + PortDef, PortOwnerDef, Predicate, PrimitiveDef, PromptSourceDef, RcmFile, +}; + +pub fn compile_file( + path: impl AsRef<Path>, +) -> Pin<Box<dyn Future<Output = Result<Accelerator, String>> + Send>> { + compile_file_with_catalog(path, Catalog::new()) +} + +pub fn compile_file_with_catalog( + path: impl AsRef<Path>, + catalog: Catalog, +) -> Pin<Box<dyn Future<Output = Result<Accelerator, String>> + Send>> { + let path = path.as_ref().to_path_buf(); + Box::pin(async move { + let mut compiler = Compiler::new(PathBuf::from("."), catalog); + compiler.compile_path(&path).await + }) +} + +struct Compiler { + root: PathBuf, + base_catalog: Catalog, + cache: HashMap<PathBuf, Accelerator>, + active: HashSet<PathBuf>, +} + +impl Compiler { + fn new(root: PathBuf, catalog: Catalog) -> Self { + Self { + root, + base_catalog: catalog, + cache: HashMap::new(), + active: HashSet::new(), + } + } + + fn compile_path<'a>( + &'a mut self, + path: &'a Path, + ) -> Pin<Box<dyn Future<Output = Result<Accelerator, String>> + Send + 'a>> { + Box::pin(async move { + let full_path = if path.is_absolute() { + path.to_path_buf() + } else { + self.root.join(path) + }; + let key = full_path + .canonicalize() + .map_err(|error| format!("failed to resolve {}: {error}", full_path.display()))?; + if let Some(accelerator) = self.cache.get(&key) { + return Ok(accelerator.clone()); + } + if !self.active.insert(key.clone()) { + return Err(format!("cyclic rcm import: {}", key.display())); + } + let source = std::fs::read_to_string(&key) + .map_err(|error| format!("failed to read {}: {error}", key.display()))?; + let file = crate::rcm::parse(&source)?; + let previous_root = std::mem::replace( + &mut self.root, + key.parent().unwrap_or_else(|| Path::new(".")).to_path_buf(), + ); + let compiled = self.compile_file_ast(&file).await; + self.root = previous_root; + self.active.remove(&key); + let accelerator = compiled?; + self.cache.insert(key, accelerator.clone()); + Ok(accelerator) + }) + } + + async fn compile_file_ast(&mut self, file: &RcmFile) -> Result<Accelerator, String> { + let mut catalog = self.base_catalog.clone(); + for model in build_models(&file.models)? { + catalog.register_model(model)?; + } + for mcp_def in &file.mcps { + catalog.register_mcp_server(mcp_config_from_def(mcp_def, &self.root)?)?; + } + + let mut imports = HashMap::new(); + for use_def in &file.uses { + let accelerator = self.compile_path(Path::new(&use_def.path)).await?; + if imports.insert(use_def.alias.clone(), accelerator).is_some() { + return Err(format!("duplicate import alias: {}", use_def.alias)); + } + } + + match &file.body { + AcceleratorBodyDef::Primitive(primitive) => { + let (state, policy, tool_runtime) = + build_state(&catalog, primitive, &self.root).await?; + Ok(Accelerator::primitive( + state, + policy, + tool_runtime, + file.name.as_str(), + )) + } + AcceleratorBodyDef::Graph(graph_def) => { + let graph = self + .compile_graph(file.name.as_str(), graph_def, &catalog, &imports) + .await?; + Ok(Accelerator::composite_named(file.name.as_str(), graph)) + } + } + } + + async fn compile_graph( + &mut self, + name: &str, + graph_def: &ast::GraphDef, + catalog: &Catalog, + imports: &HashMap<String, Accelerator>, + ) -> Result<Graph, String> { + let mut graph = Graph::named(name); + let mut symbols = HashMap::new(); + let mut component_kinds = HashMap::new(); + + for accelerator_def in &graph_def.accelerators { + let accelerator = match &accelerator_def.source { + AcceleratorSourceDef::Inline(primitive) => { + let (state, policy, tool_runtime) = + build_state(catalog, primitive, &self.root).await?; + let mut acc = Accelerator::primitive( + state, + policy, + tool_runtime, + accelerator_def.id.as_str(), + ); + inject_spawns(&primitive.spawns, imports, &mut acc).await?; + acc + } + AcceleratorSourceDef::Import { alias, overrides } => { + let mut acc = imports + .get(alias) + .ok_or_else(|| format!("unknown accelerator import: {}", alias))? + .clone(); + inject_spawns(&overrides.spawns, imports, &mut acc).await?; + acc + } + }; + let component = graph.add_accelerator(accelerator_def.id.as_str(), accelerator); + insert_symbol(&mut symbols, accelerator_def.id.as_str(), component)?; + component_kinds.insert(accelerator_def.id.clone(), ComponentTag::Accelerator); + } + + for flux_def in &graph_def.fluxes { + if flux_def.arity == 0 { + return Err(format!( + "flux {} requires arity greater than zero", + flux_def.id + )); + } + let mode = flux_mode_from_def(flux_def)?; + let input_channel = mode.input_channel(); + let output_channel = mode.output_channel(); + let component = graph.add_flux( + flux_def.name.as_deref().unwrap_or(flux_def.id.as_str()), + mode, + flux_def.arity, + ); + insert_symbol(&mut symbols, flux_def.id.as_str(), component)?; + component_kinds.insert( + flux_def.id.clone(), + ComponentTag::Flux { + input_channel, + output_channel, + arity: flux_def.arity, + }, + ); + } + + for condition_def in &graph_def.conditions { + let predicate = convert_predicate(&condition_def.predicate)?; + let component = graph.add_condition( + condition_def + .name + .as_deref() + .unwrap_or(condition_def.id.as_str()), + predicate, + ); + insert_symbol(&mut symbols, condition_def.id.as_str(), component)?; + component_kinds.insert(condition_def.id.clone(), ComponentTag::Condition); + } + + let mut flux_slots = flux_slot_map(graph_def); + for wire in &graph_def.wires { + let from = port_from_def(&wire.from, &symbols, &component_kinds)?; + let to = port_from_def(&wire.to, &symbols, &component_kinds)?; + validate_wire(&from, &to)?; + mark_flux_slot(&wire.to, &mut flux_slots)?; + graph.wire(from, to); + } + validate_flux_slots(&flux_slots)?; + graph.validate()?; + + Ok(graph) + } +} + +#[derive(Clone, Copy)] +enum ComponentTag { + Accelerator, + Flux { + input_channel: Channel, + output_channel: Channel, + arity: usize, + }, + Condition, +} + +fn insert_symbol( + symbols: &mut HashMap<String, ComponentRef>, + name: &str, + component: ComponentRef, +) -> Result<(), String> { + if symbols.insert(name.to_string(), component).is_some() { + return Err(format!("duplicate graph component: {}", name)); + } + Ok(()) +} + +fn mcp_config_from_def(def: &ast::McpDef, root: &Path) -> Result<McpServerConfig, String> { + let transport = match &def.transport { + McpTransportDef::Stdio { + command, + args, + env, + cwd, + } => McpTransportConfig::Stdio { + command: command.clone(), + args: args.clone(), + env: mcp_env_from_def(env)?, + cwd: cwd.as_ref().map(|path| root.join(path)), + }, + McpTransportDef::Http { url, headers } => McpTransportConfig::Http { + url: url.clone(), + headers: mcp_headers_from_def(headers)?, + }, + McpTransportDef::Sse { url, headers } => McpTransportConfig::Sse { + url: url.clone(), + headers: mcp_headers_from_def(headers)?, + }, + }; + Ok(McpServerConfig { + label: def.label.clone(), + transport, + }) +} + +fn mcp_env_from_def( + values: &HashMap<String, McpValueDef>, +) -> Result<HashMap<String, String>, String> { + values + .iter() + .map(|(name, value)| Ok((name.clone(), mcp_value_text(value)?))) + .collect() +} + +fn mcp_headers_from_def( + values: &HashMap<String, McpValueDef>, +) -> Result<Vec<(String, String)>, String> { + values + .iter() + .map(|(name, value)| Ok((name.clone(), mcp_value_text(value)?))) + .collect() +} + +fn mcp_value_text(value: &McpValueDef) -> Result<String, String> { + match value { + McpValueDef::Literal(value) => expand_env_placeholders(value), + McpValueDef::Env(name) => { + std::env::var(name).map_err(|_| format!("environment variable '{}' is not set", name)) + } + } +} + +fn expand_env_placeholders(value: &str) -> Result<String, String> { + let mut result = String::new(); + let mut rest = value; + while let Some(start) = rest.find("${") { + result.push_str(&rest[..start]); + let after_start = &rest[start + 2..]; + let Some(end) = after_start.find('}') else { + return Err(format!("unclosed environment placeholder in '{value}'")); + }; + let name = &after_start[..end]; + let replacement = std::env::var(name) + .map_err(|_| format!("environment variable '{}' is not set", name))?; + result.push_str(&replacement); + rest = &after_start[end + 1..]; + } + result.push_str(rest); + Ok(result) +} + +fn build_models(defs: &[ast::ModelDef]) -> Result<Vec<Model>, String> { + let mut model_names = HashSet::new(); + let mut models = Vec::new(); + for def in defs { + let protocol = parse_protocol(&def.protocol)?; + let modalities = build_modalities(&def.modalities_input, &def.modalities_output)?; + let credentials = match (&def.credentials_env, &def.credentials_key) { + (Some(env_var), None) => std::env::var(env_var).ok(), + (None, Some(key)) => Some(key.clone()), + (None, None) => None, + (Some(_), Some(_)) => { + return Err("credentials cannot have both 'env' and 'key'".to_string()); + } + }; + let (input, context) = match (def.limit_context, def.limit_input) { + (Some(ctx), None) => (ctx, ctx), + (None, Some(inp)) => (inp, inp), + (Some(ctx), Some(inp)) => (inp, ctx), + (None, None) => { + return Err("model limit requires at least one of context or input".to_string()); + } + }; + let headers = if def.headers.is_empty() { + None + } else { + Some(def.headers.clone()) + }; + let mut model = Model { + name: def.id.clone(), + protocol, + endpoint: def.endpoint.clone(), + credentials, + limit: Some(Limit { + context, + input: Some(input), + output: def.limit_output, + }), + cost: None, + modalities: Some(modalities), + headers, + thinking: def.thinking, + ..Default::default() + }; + if let Some(timeout) = def.timeout { + model.timeout = timeout; + } + if !model_names.insert(def.id.clone()) { + return Err(format!("duplicate model: {}", def.id)); + } + models.push(model); + } + Ok(models) +} + +fn parse_protocol(name: &str) -> Result<Protocol, String> { + match name { + "openai" => Ok(Protocol::OpenAI), + "anthropic" => Ok(Protocol::Anthropic), + "gemini" => Ok(Protocol::Gemini), + _ => Err(format!("unknown protocol: {}", name)), + } +} + +fn build_modalities(input: &[String], output: &[String]) -> Result<Modalities, String> { + Ok(Modalities { + input: parse_modalities(input)?, + output: parse_modalities(output)?, + }) +} + +fn parse_modalities(names: &[String]) -> Result<Vec<Modality>, String> { + names + .iter() + .map(|name| match name.as_str() { + "text" => Ok(Modality::Text), + "audio" => Ok(Modality::Audio), + "image" => Ok(Modality::Image), + "video" => Ok(Modality::Video), + "pdf" => Ok(Modality::Pdf), + _ => Err(format!("unknown modality: {}", name)), + }) + .collect() +} + +async fn build_state( + catalog: &Catalog, + def: &PrimitiveDef, + root: &Path, +) -> Result<(RunState, Box<dyn Policy>, machine::ToolRuntime), String> { + if def.models.is_empty() { + return Err("accelerator requires at least one model".to_string()); + } + + let policy_name = def.policy.as_deref().unwrap_or("captain"); + let policy = catalog.policy(policy_name)?; + let runtime_resources = catalog + .build_runtime_resources(ResourceSelection { + models: def.models.clone(), + tools: def.tools.clone().unwrap_or_default(), + mcp_servers: def.mcps.clone().unwrap_or_default(), + prompt_texts: match &def.prompts { + Some(prompt_sources) => prompt_texts_from_sources(prompt_sources, root)?, + None => HashMap::new(), + }, + }) + .await?; + let mut resources = runtime_resources.resources; + resources.deactivate_model(); + resources.deactivate_tools(); + + Ok(( + RunState { + purpose: Purpose::new(def.purpose.clone().unwrap_or_default()), + environment: catalog.environment(def.environment.as_deref().unwrap_or("local"))?, + resources, + ..RunState::default() + }, + policy, + runtime_resources.tool_runtime, + )) +} + +fn prompt_texts_from_sources( + prompt_sources: &HashMap<String, PromptSourceDef>, + root: &Path, +) -> Result<HashMap<String, String>, String> { + let mut prompts = HashMap::new(); + for (name, source) in prompt_sources { + let content = match source { + PromptSourceDef::Inline(content) => content.clone(), + PromptSourceDef::File(path) => { + let prompt_path = root.join(path); + std::fs::read_to_string(&prompt_path).map_err(|error| { + format!("failed to read prompt {}: {error}", prompt_path.display()) + })? + } + }; + prompts.insert(name.clone(), content); + } + Ok(prompts) +} + +/// Inject `spawn_<alias>` tools into a planner accelerator for each name in +/// `spawn_names`. Each tool wraps the imported worker accelerator. +/// Aliases not found in `imports` produce a compile error. +async fn inject_spawns( + spawn_names: &[String], + imports: &HashMap<String, Accelerator>, + planner: &mut Accelerator, +) -> Result<(), String> { + for alias in spawn_names { + let worker = imports + .get(alias) + .ok_or_else(|| format!("spawns references unknown accelerator import: {alias}"))?; + let tool = Arc::new(SpawnTool::new(format!("spawn_{alias}"), worker.clone())); + planner.inject_tool(tool); + } + Ok(()) +} + +fn flux_mode_from_def(def: &ast::FluxDef) -> Result<FluxMode, String> { + match (def.channel.as_str(), def.mode.as_str()) { + ("purpose", "concat") => Ok(FluxMode::Purpose(PurposeFlux::Concat)), + ("context", "append") => Ok(FluxMode::Context(ContextFlux::Append)), + ("context", "last") => Ok(FluxMode::Context(ContextFlux::Last)), + ("context", "digest") => Ok(FluxMode::Context(ContextFlux::Digest)), + ("context", "thread") => Ok(FluxMode::Context(ContextFlux::Thread)), + ("environment", "overlay") => Ok(FluxMode::Environment(EnvFlux::Overlay)), + ("resources", "merge") => Ok(FluxMode::Resources(ResFlux::Merge)), + ("bridge", "flatten") => { + let from_str = def + .from + .as_deref() + .ok_or_else(|| "bridge flux requires 'from'".to_string())?; + let to_str = def + .to + .as_deref() + .ok_or_else(|| "bridge flux requires 'to'".to_string())?; + let from = parse_channel(from_str)?; + let to = parse_channel(to_str)?; + match (from, to) { + (Channel::Context, Channel::Purpose) => Ok(FluxMode::Bridge { + from, + to, + kind: BridgeKind::ContextToPurpose, + }), + _ => Err(format!( + "unsupported bridge direction: {} → {} (only context → purpose is implemented)", + from_str, to_str + )), + } + } + _ => Err(format!("unknown flux mode: {} {}", def.channel, def.mode)), + } +} + +fn port_from_def( + def: &PortDef, + symbols: &HashMap<String, ComponentRef>, + kinds: &HashMap<String, ComponentTag>, +) -> Result<Port, String> { + match &def.owner { + PortOwnerDef::Input => Ok(boundary_port(true, &def.endpoint)?), + PortOwnerDef::Output => Ok(boundary_port(false, &def.endpoint)?), + PortOwnerDef::Component(name) => { + let component = symbols + .get(name) + .ok_or_else(|| format!("unknown graph component: {}", name))?; + let kind = kinds + .get(name) + .ok_or_else(|| format!("unknown graph component: {}", name))?; + component_port(component, *kind, &def.endpoint) + } + } +} + +fn boundary_port(is_input: bool, endpoint: &EndpointDef) -> Result<Port, String> { + let endpoint = match endpoint { + EndpointDef::Trigger => Endpoint::Trigger, + EndpointDef::Done => Endpoint::Done, + EndpointDef::State(channel) => Endpoint::State(parse_channel(channel)?), + _ => { + return Err( + "boundary ports only support trigger, done, and state channels".to_string(), + ); + } + }; + if is_input { + Ok(Port::input(endpoint)) + } else { + Ok(Port::output(endpoint)) + } +} + +fn component_port( + component: &ComponentRef, + kind: ComponentTag, + endpoint: &EndpointDef, +) -> Result<Port, String> { + match (kind, endpoint) { + (ComponentTag::Accelerator, EndpointDef::Trigger) => Ok(component.trigger()), + (ComponentTag::Accelerator, EndpointDef::Done) => Ok(component.done()), + (ComponentTag::Accelerator, EndpointDef::State(channel)) => { + Ok(component.port_state(parse_channel(channel)?)) + } + (ComponentTag::Flux { output_channel, .. }, EndpointDef::FluxOut) => { + Ok(component.flux_out(output_channel)) + } + ( + ComponentTag::Flux { + input_channel, + arity, + .. + }, + EndpointDef::FluxSlot(slot), + ) => { + if *slot >= arity { + return Err(format!("flux slot {} is out of range", slot)); + } + Ok(component.slot(*slot, input_channel)) + } + (ComponentTag::Condition, EndpointDef::Trigger) => Ok(component.condition_in()), + (ComponentTag::Condition, EndpointDef::ConditionTrue) => { + Ok(component.condition_out(accelerator::ConditionBranch::True)) + } + (ComponentTag::Condition, EndpointDef::ConditionFalse) => { + Ok(component.condition_out(accelerator::ConditionBranch::False)) + } + _ => Err("endpoint does not match component type".to_string()), + } +} + +fn validate_wire(from: &Port, to: &Port) -> Result<(), String> { + if !is_output_port(from) { + return Err("wire source is not an output port".to_string()); + } + if !is_input_port(to) { + return Err("wire target is not an input port".to_string()); + } + if from.channel() != to.channel() { + return Err("wire channel mismatch".to_string()); + } + Ok(()) +} + +fn is_output_port(port: &Port) -> bool { + match (&port.owner, port.endpoint) { + (accelerator::PortOwner::BoundaryInput, _) => true, + (accelerator::PortOwner::BoundaryOutput, _) => false, + ( + _, + Endpoint::Done | Endpoint::State(_) | Endpoint::FluxOut(_) | Endpoint::ConditionOut(_), + ) => true, + _ => false, + } +} + +fn is_input_port(port: &Port) -> bool { + match (&port.owner, port.endpoint) { + (accelerator::PortOwner::BoundaryOutput, _) => true, + (accelerator::PortOwner::BoundaryInput, _) => false, + ( + _, + Endpoint::Trigger + | Endpoint::State(_) + | Endpoint::FluxSlot { .. } + | Endpoint::ConditionIn, + ) => true, + _ => false, + } +} + +fn flux_slot_map(graph: &ast::GraphDef) -> HashMap<String, Vec<bool>> { + graph + .fluxes + .iter() + .map(|flux| (flux.id.clone(), vec![false; flux.arity])) + .collect() +} + +fn mark_flux_slot(port: &PortDef, slots: &mut HashMap<String, Vec<bool>>) -> Result<(), String> { + let PortOwnerDef::Component(component) = &port.owner else { + return Ok(()); + }; + let EndpointDef::FluxSlot(slot) = port.endpoint else { + return Ok(()); + }; + let Some(component_slots) = slots.get_mut(component) else { + return Ok(()); + }; + if slot >= component_slots.len() { + return Err(format!("flux slot {} is out of range", slot)); + } + component_slots[slot] = true; + Ok(()) +} + +fn validate_flux_slots(slots: &HashMap<String, Vec<bool>>) -> Result<(), String> { + for (flux, filled) in slots { + if let Some(slot) = filled.iter().position(|has_input| !has_input) { + return Err(format!("flux {} missing input for slot {}", flux, slot)); + } + } + Ok(()) +} + +fn parse_channel(channel: &str) -> Result<Channel, String> { + match channel { + "purpose" => Ok(Channel::Purpose), + "context" => Ok(Channel::Context), + "environment" => Ok(Channel::Environment), + "resources" => Ok(Channel::Resources), + _ => Err(format!("unknown state channel: {}", channel)), + } +} + +fn convert_predicate(predicate: &Predicate) -> Result<AccelPredicate, String> { + Ok(match predicate { + Predicate::PurposeContains(value) => { + AccelPredicate::Purpose(PurposePredicate::Contains(value.clone())) + } + Predicate::PurposeEquals(value) => { + AccelPredicate::Purpose(PurposePredicate::Equals(value.clone())) + } + Predicate::PurposeStartsWith(value) => { + AccelPredicate::Purpose(PurposePredicate::StartsWith(value.clone())) + } + Predicate::PurposeEndsWith(value) => { + AccelPredicate::Purpose(PurposePredicate::EndsWith(value.clone())) + } + Predicate::PurposeIsEmpty => AccelPredicate::Purpose(PurposePredicate::IsEmpty), + Predicate::ContextHasTag(value) => { + AccelPredicate::Context(ContextPredicate::HasTag(value.clone())) + } + Predicate::ContextHasRole(value) => { + AccelPredicate::Context(ContextPredicate::HasRole(parse_role(value)?)) + } + Predicate::ContextContains(value) => { + AccelPredicate::Context(ContextPredicate::Contains(value.clone())) + } + Predicate::ContextIsEmpty => AccelPredicate::Context(ContextPredicate::IsEmpty), + Predicate::EnvVarExists(value) => { + AccelPredicate::Environment(EnvironmentPredicate::VarExists(value.clone())) + } + Predicate::EnvVarEquals(key, value) => { + AccelPredicate::Environment(EnvironmentPredicate::VarEquals(key.clone(), value.clone())) + } + Predicate::EnvCwdContains(value) => { + AccelPredicate::Environment(EnvironmentPredicate::CwdContains(value.clone())) + } + Predicate::EnvPlatformIs(value) => { + AccelPredicate::Environment(EnvironmentPredicate::PlatformIs(value.clone())) + } + Predicate::ResHasModel(value) => { + AccelPredicate::Resources(ResourcesPredicate::HasModel(value.clone())) + } + Predicate::ResActiveModelIs(value) => { + AccelPredicate::Resources(ResourcesPredicate::ActiveModelIs(value.clone())) + } + Predicate::ResHasTool(value) => { + AccelPredicate::Resources(ResourcesPredicate::HasTool(value.clone())) + } + Predicate::ResToolEnabled(value) => { + AccelPredicate::Resources(ResourcesPredicate::ToolEnabled(value.clone())) + } + Predicate::ResHasPrompt(value) => { + AccelPredicate::Resources(ResourcesPredicate::HasPrompt(value.clone())) + } + Predicate::All(predicates) => AccelPredicate::All( + predicates + .iter() + .map(convert_predicate) + .collect::<Result<_, _>>()?, + ), + Predicate::Any(predicates) => AccelPredicate::Any( + predicates + .iter() + .map(convert_predicate) + .collect::<Result<_, _>>()?, + ), + Predicate::Not(predicate) => AccelPredicate::Not(Box::new(convert_predicate(predicate)?)), + }) +} + +fn parse_role(role: &str) -> Result<machine::Role, String> { + match role { + "system" => Ok(machine::Role::System), + "user" => Ok(machine::Role::User), + "assistant" => Ok(machine::Role::Assistant), + "tool" => Ok(machine::Role::Tool), + _ => Err(format!("unknown role: {}", role)), + } +} diff --git a/crates/cli/src/rcm/lexer.rs b/crates/cli/src/rcm/lexer.rs new file mode 100644 index 00000000..a8711787 --- /dev/null +++ b/crates/cli/src/rcm/lexer.rs @@ -0,0 +1,135 @@ +/// A token in the `.rcm` language. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Token { + Ident(String), + StringLit(String), + LexError(String), + LBrace, + RBrace, + Arrow, + Dot, + Semicolon, + Equals, + LParen, + RParen, + Eof, +} + +/// Tokenize a `.rcm` source file into tokens. +pub(crate) fn tokenize(source: &str) -> Vec<Token> { + let mut tokens = Vec::new(); + let chars: Vec<char> = source.chars().collect(); + let mut pos = 0usize; + + while pos < chars.len() { + let ch = chars[pos]; + + if ch.is_whitespace() { + pos += 1; + continue; + } + + // Line comments — skipped entirely. + if ch == '/' && pos + 1 < chars.len() && chars[pos + 1] == '/' { + while pos < chars.len() && chars[pos] != '\n' { + pos += 1; + } + continue; + } + + // String literals. + if ch == '"' { + pos += 1; + let start = pos; + while pos < chars.len() && chars[pos] != '"' { + if chars[pos] == '\\' { + pos += 1; + } + pos += 1; + } + if pos >= chars.len() { + tokens.push(Token::LexError("unclosed string literal".to_string())); + continue; + } + let raw: String = chars[start..pos].iter().collect(); + tokens.push(Token::StringLit(raw)); + pos += 1; + continue; + } + + // Bracket and comma (for arrays). + if ch == '[' || ch == ']' || ch == ',' { + tokens.push(Token::Ident(ch.to_string())); + pos += 1; + continue; + } + + if ch == '{' { + tokens.push(Token::LBrace); + pos += 1; + continue; + } + if ch == '}' { + tokens.push(Token::RBrace); + pos += 1; + continue; + } + if ch == '.' { + tokens.push(Token::Dot); + pos += 1; + continue; + } + if ch == ';' { + tokens.push(Token::Semicolon); + pos += 1; + continue; + } + if ch == '(' { + tokens.push(Token::LParen); + pos += 1; + continue; + } + if ch == ')' { + tokens.push(Token::RParen); + pos += 1; + continue; + } + if ch == '=' { + tokens.push(Token::Equals); + pos += 1; + continue; + } + + if ch == '-' && pos + 1 < chars.len() && chars[pos + 1] == '>' { + tokens.push(Token::Arrow); + pos += 2; + continue; + } + + if ch.is_ascii_alphabetic() || ch == '_' { + let start = pos; + while pos < chars.len() + && (chars[pos].is_ascii_alphanumeric() || chars[pos] == '_' || chars[pos] == '-') + { + pos += 1; + } + let ident: String = chars[start..pos].iter().collect(); + tokens.push(Token::Ident(ident)); + continue; + } + + if ch.is_ascii_digit() { + let start = pos; + while pos < chars.len() && chars[pos].is_ascii_digit() { + pos += 1; + } + tokens.push(Token::Ident(chars[start..pos].iter().collect())); + continue; + } + + pos += 1; + } + + tokens.push(Token::Eof); + tokens +} diff --git a/crates/cli/src/rcm/mod.rs b/crates/cli/src/rcm/mod.rs new file mode 100644 index 00000000..896dae27 --- /dev/null +++ b/crates/cli/src/rcm/mod.rs @@ -0,0 +1,16 @@ +mod ast; +pub mod compile; +mod lexer; +mod parser; + +pub use ast::{ + AcceleratorBodyDef, AcceleratorSourceDef, ConditionDef, EndpointDef, FluxDef, + GraphAcceleratorDef, GraphDef, McpDef, McpTransportDef, McpValueDef, ModelDef, PortDef, + PortOwnerDef, Predicate, PrimitiveDef, PromptSourceDef, RcmFile, UseDef, WireDef, +}; +pub(crate) use parser::Parser; + +pub fn parse(source: &str) -> Result<RcmFile, String> { + let tokens = lexer::tokenize(source); + Parser::new(tokens).parse() +} diff --git a/crates/cli/src/rcm/parser.rs b/crates/cli/src/rcm/parser.rs new file mode 100644 index 00000000..8d42f79d --- /dev/null +++ b/crates/cli/src/rcm/parser.rs @@ -0,0 +1,641 @@ +use super::ast::*; +use super::lexer::Token; + +pub(crate) struct Parser { + tokens: Vec<Token>, + pos: usize, +} + +impl Parser { + pub fn new(tokens: Vec<Token>) -> Self { + Self { tokens, pos: 0 } + } + + pub fn parse(&mut self) -> Result<RcmFile, String> { + let name = self.parse_name()?; + let mut uses = Vec::new(); + let mut models = Vec::new(); + let mut mcps = Vec::new(); + let mut body = None; + + while !self.is_eof() { + match self.peek_ident() { + Some("use") => uses.push(self.use_def()?), + Some("model") => models.push(self.model()?), + Some("mcp") => mcps.push(self.mcp()?), + Some("accelerator") => { + if body.is_some() { + return Err("rcm file can only export one accelerator".to_string()); + } + body = Some(AcceleratorBodyDef::Primitive(self.top_accelerator()?)); + } + Some("graph") => { + if body.is_some() { + return Err("rcm file can only export one accelerator".to_string()); + } + body = Some(AcceleratorBodyDef::Graph(self.graph()?)); + } + _ => return Err(format!("unexpected token: {:?}", self.peek())), + } + } + + Ok(RcmFile { + name, + uses, + models, + mcps, + body: body.ok_or_else(|| "rcm file requires accelerator or graph body".to_string())?, + }) + } + + fn parse_name(&mut self) -> Result<String, String> { + self.expect_ident("name")?; + self.expect(Token::Equals)?; + self.expect_string() + } + + fn use_def(&mut self) -> Result<UseDef, String> { + self.expect_ident("use")?; + let path = self.expect_string()?; + self.expect_ident("as")?; + let alias = self.expect_ident_any()?; + Ok(UseDef { path, alias }) + } + + fn top_accelerator(&mut self) -> Result<PrimitiveDef, String> { + self.expect_ident("accelerator")?; + self.expect(Token::LBrace)?; + self.primitive_fields() + } + + fn graph(&mut self) -> Result<GraphDef, String> { + self.expect_ident("graph")?; + self.expect(Token::LBrace)?; + let mut accelerators = Vec::new(); + let mut fluxes = Vec::new(); + let mut conditions = Vec::new(); + let mut wires = Vec::new(); + + while !self.eat(Token::RBrace) { + match self.peek_ident() { + Some("accelerator") => accelerators.push(self.graph_accelerator()?), + Some("flux") => fluxes.push(self.flux()?), + Some("condition") => conditions.push(self.condition()?), + _ => wires.push(self.wire()?), + } + } + + Ok(GraphDef { + accelerators, + fluxes, + conditions, + wires, + }) + } + + fn graph_accelerator(&mut self) -> Result<GraphAcceleratorDef, String> { + self.expect_ident("accelerator")?; + let id = self.expect_ident_any()?; + let source = if self.eat(Token::Equals) { + let alias = self.expect_ident_any()?; + let overrides = if self.eat(Token::LBrace) { + self.primitive_fields()? + } else { + PrimitiveDef::default() + }; + AcceleratorSourceDef::Import { alias, overrides } + } else { + self.expect(Token::LBrace)?; + AcceleratorSourceDef::Inline(self.primitive_fields()?) + }; + Ok(GraphAcceleratorDef { id, source }) + } + + fn primitive_fields(&mut self) -> Result<PrimitiveDef, String> { + let mut def = PrimitiveDef::default(); + while !self.eat(Token::RBrace) { + let key = self.expect_ident_any()?; + self.expect(Token::Equals)?; + match key.as_str() { + "purpose" => def.purpose = Some(self.expect_string()?), + "models" => def.models = self.expect_string_array()?, + "policy" => def.policy = Some(self.expect_string()?), + "environment" => def.environment = Some(self.expect_string()?), + "prompts" => def.prompts = Some(self.prompt_sources()?), + "tools" => def.tools = Some(self.expect_string_array()?), + "mcps" => def.mcps = Some(self.expect_string_array()?), + "spawns" => def.spawns = self.expect_string_array()?, + _ => return Err(format!("unknown accelerator field: {}", key)), + } + } + Ok(def) + } + + fn prompt_sources( + &mut self, + ) -> Result<std::collections::HashMap<String, PromptSourceDef>, String> { + self.expect(Token::LBrace)?; + let mut prompts = std::collections::HashMap::new(); + while !self.eat(Token::RBrace) { + let name = self.expect_ident_any()?; + self.expect(Token::Equals)?; + let source = if self.eat_ident("file") { + PromptSourceDef::File(self.expect_string()?) + } else { + PromptSourceDef::Inline(self.expect_string()?) + }; + if prompts.insert(name.clone(), source).is_some() { + return Err(format!("duplicate prompt: {}", name)); + } + self.eat(Token::Semicolon); + } + Ok(prompts) + } + + fn flux(&mut self) -> Result<FluxDef, String> { + self.expect_ident("flux")?; + let id = self.expect_ident_any()?; + self.expect(Token::LBrace)?; + let mut name = None; + let mut channel = None; + let mut mode = None; + let mut from = None; + let mut to = None; + let mut arity = None; + while !self.eat(Token::RBrace) { + let key = self.expect_ident_any()?; + self.expect(Token::Equals)?; + match key.as_str() { + "name" => name = Some(self.expect_string()?), + "channel" => channel = Some(self.expect_ident_any()?), + "mode" => mode = Some(self.expect_ident_any()?), + "from" => from = Some(self.expect_ident_any()?), + "to" => to = Some(self.expect_ident_any()?), + "arity" => arity = Some(self.expect_usize()?), + _ => return Err(format!("unknown flux field: {}", key)), + } + } + Ok(FluxDef { + id, + name, + channel: channel.ok_or_else(|| "flux requires channel".to_string())?, + mode: mode.ok_or_else(|| "flux requires mode".to_string())?, + from, + to, + arity: arity.ok_or_else(|| "flux requires arity".to_string())?, + }) + } + + fn condition(&mut self) -> Result<ConditionDef, String> { + self.expect_ident("condition")?; + let id = self.expect_ident_any()?; + self.expect(Token::LBrace)?; + let mut name = None; + while self.peek_ident() == Some("name") { + self.advance(); + self.expect(Token::Equals)?; + name = Some(self.expect_string()?); + self.eat(Token::Semicolon); + } + let predicate = self.predicate_block()?; + self.expect(Token::RBrace)?; + Ok(ConditionDef { + id, + name, + predicate, + }) + } + + fn mcp(&mut self) -> Result<McpDef, String> { + self.expect_ident("mcp")?; + let label = self.expect_ident_any()?; + self.expect(Token::LBrace)?; + + let mut transport = None; + let mut command = None; + let mut args = Vec::new(); + let mut env = std::collections::HashMap::new(); + let mut cwd = None; + let mut url = None; + let mut headers = std::collections::HashMap::new(); + + while !self.eat(Token::RBrace) { + let key = self.expect_ident_any()?; + self.expect(Token::Equals)?; + match key.as_str() { + "transport" => transport = Some(self.expect_ident_any()?), + "command" => command = Some(self.expect_string()?), + "args" => args = self.expect_string_array()?, + "env" => env = self.mcp_values()?, + "cwd" => cwd = Some(self.expect_string()?), + "url" => url = Some(self.expect_string()?), + "headers" => headers = self.mcp_values()?, + other => return Err(format!("unknown mcp field: {}", other)), + } + self.eat(Token::Semicolon); + } + + let transport = match transport.as_deref() { + Some("stdio") => McpTransportDef::Stdio { + command: command.ok_or_else(|| "stdio mcp requires command".to_string())?, + args, + env, + cwd, + }, + Some("http") => McpTransportDef::Http { + url: url.ok_or_else(|| "http mcp requires url".to_string())?, + headers, + }, + Some("sse") => McpTransportDef::Sse { + url: url.ok_or_else(|| "sse mcp requires url".to_string())?, + headers, + }, + Some(other) => return Err(format!("unknown mcp transport: {}", other)), + None => return Err("mcp requires transport".to_string()), + }; + + Ok(McpDef { label, transport }) + } + + fn mcp_values(&mut self) -> Result<std::collections::HashMap<String, McpValueDef>, String> { + self.expect(Token::LBrace)?; + let mut values = std::collections::HashMap::new(); + while !self.eat(Token::RBrace) { + let name = self.expect_ident_any()?; + self.expect(Token::Equals)?; + let value = if self.eat_ident("env") { + McpValueDef::Env(self.expect_string()?) + } else { + McpValueDef::Literal(self.expect_string()?) + }; + if values.insert(name.clone(), value).is_some() { + return Err(format!("duplicate mcp value: {}", name)); + } + self.eat(Token::Semicolon); + } + Ok(values) + } + + fn model(&mut self) -> Result<ModelDef, String> { + self.expect_ident("model")?; + let id = self.expect_ident_any()?; + self.expect(Token::LBrace)?; + + let mut protocol = String::new(); + let mut endpoint = None; + let mut credentials_env = None; + let mut credentials_key = None; + let mut limit_context = None; + let mut limit_input = None; + let mut limit_output = 0; + let mut modalities_input = Vec::new(); + let mut modalities_output = Vec::new(); + let mut headers = std::collections::HashMap::new(); + let mut thinking = false; + let mut timeout = None; + + while !self.eat(Token::RBrace) { + let key = self.expect_ident_any()?; + self.expect(Token::Equals)?; + if self.eat(Token::LBrace) { + match key.as_str() { + "credentials" => { + self.model_credentials(&mut credentials_env, &mut credentials_key)? + } + "limit" => { + self.model_limit(&mut limit_context, &mut limit_input, &mut limit_output)? + } + "modalities" => { + self.model_modalities(&mut modalities_input, &mut modalities_output)? + } + "headers" => headers = self.model_headers()?, + other => return Err(format!("unknown model block: {}", other)), + } + } else { + match key.as_str() { + "protocol" => protocol = self.expect_string()?, + "endpoint" => endpoint = Some(self.expect_string()?), + "thinking" => { + let value = self.expect_string()?; + thinking = value.parse().map_err(|_| { + format!("thinking must be \"true\" or \"false\", got: {}", value) + })?; + } + "timeout" => { + let value = self.expect_string()?; + timeout = Some(value.parse().map_err(|_| { + format!("timeout must be a number of seconds, got: {}", value) + })?); + } + other => return Err(format!("unknown model field: {}", other)), + } + } + } + + if protocol.is_empty() { + return Err("model requires a protocol".to_string()); + } + + Ok(ModelDef { + id, + protocol, + endpoint, + credentials_env, + credentials_key, + limit_context, + limit_input, + limit_output, + modalities_input, + modalities_output, + headers, + thinking, + timeout, + }) + } + + fn model_credentials( + &mut self, + env: &mut Option<String>, + key: &mut Option<String>, + ) -> Result<(), String> { + while !self.eat(Token::RBrace) { + let field = self.expect_ident_any()?; + self.expect(Token::Equals)?; + match field.as_str() { + "env" => *env = Some(self.expect_string()?), + "key" => *key = Some(self.expect_string()?), + other => return Err(format!("unknown credentials field: {}", other)), + } + self.eat_ident(","); + } + Ok(()) + } + + fn model_limit( + &mut self, + context: &mut Option<u64>, + input: &mut Option<u64>, + output: &mut u64, + ) -> Result<(), String> { + while !self.eat(Token::RBrace) { + let field = self.expect_ident_any()?; + self.expect(Token::Equals)?; + let value = self.expect_string()?; + let number = value + .parse() + .map_err(|_| format!("invalid limit value: {}", value))?; + match field.as_str() { + "context" => *context = Some(number), + "input" => *input = Some(number), + "output" => *output = number, + other => return Err(format!("unknown limit field: {}", other)), + } + self.eat_ident(","); + } + Ok(()) + } + + fn model_modalities( + &mut self, + input: &mut Vec<String>, + output: &mut Vec<String>, + ) -> Result<(), String> { + while !self.eat(Token::RBrace) { + let field = self.expect_ident_any()?; + self.expect(Token::Equals)?; + match field.as_str() { + "input" => *input = self.expect_string_array()?, + "output" => *output = self.expect_string_array()?, + other => return Err(format!("unknown modalities field: {}", other)), + } + self.eat_ident(","); + } + Ok(()) + } + + fn model_headers(&mut self) -> Result<std::collections::HashMap<String, String>, String> { + let mut headers = std::collections::HashMap::new(); + while !self.eat(Token::RBrace) { + let name = self.expect_ident_any()?; + self.expect(Token::Equals)?; + let value = self.expect_string()?; + if headers.insert(name.clone(), value).is_some() { + return Err(format!("duplicate header: {}", name)); + } + self.eat_ident(","); + } + Ok(headers) + } + + fn wire(&mut self) -> Result<WireDef, String> { + let from = self.port_ref()?; + self.expect(Token::Arrow)?; + let to = self.port_ref()?; + Ok(WireDef { from, to }) + } + + fn port_ref(&mut self) -> Result<PortDef, String> { + let owner_name = self.expect_ident_any()?; + self.expect(Token::Dot)?; + let endpoint = self.endpoint()?; + let owner = match owner_name.as_str() { + "input" => PortOwnerDef::Input, + "output" => PortOwnerDef::Output, + _ => PortOwnerDef::Component(owner_name), + }; + Ok(PortDef { owner, endpoint }) + } + + fn endpoint(&mut self) -> Result<EndpointDef, String> { + let name = self.expect_ident_any()?; + match name.as_str() { + "trigger" => Ok(EndpointDef::Trigger), + "done" => Ok(EndpointDef::Done), + "purpose" | "context" | "environment" | "policy" | "resources" => { + Ok(EndpointDef::State(name)) + } + "out" => Ok(EndpointDef::FluxOut), + "slot" => { + self.expect(Token::LParen)?; + let slot = self.expect_usize()?; + self.expect(Token::RParen)?; + Ok(EndpointDef::FluxSlot(slot)) + } + "true" => Ok(EndpointDef::ConditionTrue), + "false" => Ok(EndpointDef::ConditionFalse), + _ => Err(format!("unknown endpoint: {}", name)), + } + } + + fn predicate_block(&mut self) -> Result<Predicate, String> { + let key = self.expect_ident_any()?; + match key.as_str() { + "all" => { + self.expect(Token::LBrace)?; + let mut predicates = Vec::new(); + while !self.eat(Token::RBrace) { + predicates.push(self.predicate_block()?); + self.eat(Token::Semicolon); + } + Ok(Predicate::All(predicates)) + } + "any" => { + self.expect(Token::LBrace)?; + let mut predicates = Vec::new(); + while !self.eat(Token::RBrace) { + predicates.push(self.predicate_block()?); + self.eat(Token::Semicolon); + } + Ok(Predicate::Any(predicates)) + } + "not" => { + self.expect(Token::LBrace)?; + let inner = self.predicate_block()?; + self.expect(Token::RBrace)?; + Ok(Predicate::Not(Box::new(inner))) + } + channel => { + let op = self.expect_ident_any()?; + self.channel_predicate(channel, &op) + } + } + } + + fn channel_predicate(&mut self, channel: &str, op: &str) -> Result<Predicate, String> { + match (channel, op) { + ("purpose", "contains") => Ok(Predicate::PurposeContains(self.expect_string()?)), + ("purpose", "equals") => Ok(Predicate::PurposeEquals(self.expect_string()?)), + ("purpose", "starts_with") => Ok(Predicate::PurposeStartsWith(self.expect_string()?)), + ("purpose", "ends_with") => Ok(Predicate::PurposeEndsWith(self.expect_string()?)), + ("purpose", "is_empty") => Ok(Predicate::PurposeIsEmpty), + ("context", "has_tag") => Ok(Predicate::ContextHasTag(self.expect_string()?)), + ("context", "has_role") => Ok(Predicate::ContextHasRole(self.expect_string()?)), + ("context", "contains") => Ok(Predicate::ContextContains(self.expect_string()?)), + ("context", "is_empty") => Ok(Predicate::ContextIsEmpty), + ("env", "var") => { + let var_name = self.expect_string()?; + let action = self.expect_ident_any()?; + match action.as_str() { + "exists" => Ok(Predicate::EnvVarExists(var_name)), + _ => Err(format!("unknown env var predicate action: {}", action)), + } + } + ("env", "var_equals") => { + let var_name = self.expect_string()?; + let value = self.expect_string()?; + Ok(Predicate::EnvVarEquals(var_name, value)) + } + ("env", "cwd_contains") => Ok(Predicate::EnvCwdContains(self.expect_string()?)), + ("env", "platform_is") => Ok(Predicate::EnvPlatformIs(self.expect_string()?)), + ("resources", "has_model") => Ok(Predicate::ResHasModel(self.expect_string()?)), + ("resources", "active_model_is") => { + Ok(Predicate::ResActiveModelIs(self.expect_string()?)) + } + ("resources", "has_tool") => Ok(Predicate::ResHasTool(self.expect_string()?)), + ("resources", "tool_enabled") => Ok(Predicate::ResToolEnabled(self.expect_string()?)), + ("resources", "has_prompt") => Ok(Predicate::ResHasPrompt(self.expect_string()?)), + _ => Err(format!("unknown predicate: {} {}", channel, op)), + } + } + + fn peek(&self) -> &Token { + static EOF: Token = Token::Eof; + self.tokens.get(self.pos).unwrap_or(&EOF) + } + + fn peek_ident(&self) -> Option<&str> { + match self.peek() { + Token::Ident(ident) => Some(ident.as_str()), + _ => None, + } + } + + fn advance(&mut self) { + self.pos += 1; + } + + fn eat(&mut self, expected: Token) -> bool { + if self.peek() == &expected { + self.advance(); + true + } else { + false + } + } + + fn is_eof(&self) -> bool { + matches!(self.peek(), Token::Eof) + } + + fn expect(&mut self, expected: Token) -> Result<(), String> { + if self.eat(expected.clone()) { + Ok(()) + } else { + Err(format!("expected {:?}, found {:?}", expected, self.peek())) + } + } + + fn expect_ident(&mut self, value: &str) -> Result<(), String> { + match self.peek() { + Token::Ident(ident) if ident == value => { + self.advance(); + Ok(()) + } + other => Err(format!("expected '{}', found {:?}", value, other)), + } + } + + fn expect_ident_any(&mut self) -> Result<String, String> { + match self.peek() { + Token::Ident(ident) => { + let value = ident.clone(); + self.advance(); + Ok(value) + } + Token::LexError(error) => Err(error.clone()), + other => Err(format!("expected identifier, found {:?}", other)), + } + } + + fn expect_string(&mut self) -> Result<String, String> { + match self.peek() { + Token::StringLit(value) => { + let value = value.clone(); + self.advance(); + Ok(value) + } + Token::LexError(error) => Err(error.clone()), + other => Err(format!("expected string, found {:?}", other)), + } + } + + fn expect_string_array(&mut self) -> Result<Vec<String>, String> { + self.expect_ident("[")?; + let mut items = Vec::new(); + loop { + if self.eat_ident("]") { + break; + } + items.push(self.expect_string()?); + self.eat_ident(","); + } + Ok(items) + } + + fn expect_usize(&mut self) -> Result<usize, String> { + let value = self.expect_ident_any()?; + value + .parse() + .map_err(|_| format!("expected number, found {}", value)) + } + + fn eat_ident(&mut self, expected: &str) -> bool { + match self.peek() { + Token::Ident(ident) if ident == expected => { + self.advance(); + true + } + _ => false, + } + } +} diff --git a/crates/cli/tests/autoresearch_examples.rs b/crates/cli/tests/autoresearch_examples.rs new file mode 100644 index 00000000..62070943 --- /dev/null +++ b/crates/cli/tests/autoresearch_examples.rs @@ -0,0 +1,42 @@ +use std::path::Path; + +#[test] +fn compile_autoresearch_survey_rcm_files() { + let cli_manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace_root = cli_manifest_dir + .parent() + .and_then(Path::parent) + .expect("cli crate should be inside workspace/crates/cli"); + + let example_files = [ + "examples/autoresearch-survey/rcm/anchor.rcm", + "examples/autoresearch-survey/rcm/query_plan.rcm", + "examples/autoresearch-survey/rcm/discovery.rcm", + "examples/autoresearch-survey/rcm/expansion.rcm", + "examples/autoresearch-survey/rcm/rank_pool.rcm", + "examples/autoresearch-survey/rcm/card_plan.rcm", + "examples/autoresearch-survey/rcm/paper_card.rcm", + "examples/autoresearch-survey/rcm/research_map.rcm", + "examples/autoresearch-survey/rcm/judge_panel.rcm", + "examples/autoresearch-survey/rcm/survey_outline.rcm", + "examples/autoresearch-survey/rcm/section_expander.rcm", + "examples/autoresearch-survey/rcm/survey_assembler.rcm", + "examples/autoresearch-survey/rcm/zh_frame.rcm", + "examples/autoresearch-survey/rcm/section_translator.rcm", + "examples/autoresearch-survey/rcm/zh_assemble.rcm", + "examples/autoresearch-survey/rcm/survey_brief.rcm", + "examples/autoresearch-survey/rcm/autoresearch_survey.rcm", + ]; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + for example_file in example_files { + let path = workspace_root.join(example_file); + runtime + .block_on(cli::rcm::compile::compile_file(&path)) + .unwrap_or_else(|error| panic!("failed to compile {}: {}", path.display(), error)); + } +} diff --git a/crates/cli/tests/compile.rs b/crates/cli/tests/compile.rs new file mode 100644 index 00000000..057024db --- /dev/null +++ b/crates/cli/tests/compile.rs @@ -0,0 +1,374 @@ +use std::fs; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use accelerator::{Accelerator, Catalog}; +use machine::{Environment, RunState, Tool, ToolResult}; +use serde_json::Value; + +static FILE_SEQ: AtomicU64 = AtomicU64::new(0); + +struct ExternalTool; + +impl Tool for ExternalTool { + fn name(&self) -> &str { + "external_tool" + } + + fn description(&self) -> &str { + "External test tool" + } + + fn parameters(&self) -> Value { + serde_json::json!({ "type": "object", "properties": {} }) + } + + fn execute<'a>( + &'a self, + _args: Value, + _env: &'a Environment, + ) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>> { + Box::pin(async { + Ok(ToolResult { + call_id: String::new(), + content: "ok".to_string(), + title: None, + }) + }) + } +} + +fn unique_path(name: &str, extension: &str) -> PathBuf { + let process_id = std::process::id(); + let sequence = FILE_SEQ.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("rcm-{name}-{process_id}-{sequence}.{extension}")) +} + +fn write_rcm(name: &str, source: &str) -> PathBuf { + let path = unique_path(name, "rcm"); + fs::write(&path, source).unwrap(); + path +} + +fn write_rcm_near(name: &str, source: &str, prompt_name: &str, prompt: &str) -> PathBuf { + let dir = unique_path(name, "dir"); + fs::create_dir(&dir).unwrap(); + fs::write(dir.join(prompt_name), prompt).unwrap(); + let path = dir.join("main.rcm"); + fs::write(&path, source).unwrap(); + path +} + +fn remove_compile_path(path: &Path) { + if let Some(parent) = path.parent() + && parent + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("rcm-") && name.ends_with(".dir")) + { + let _ = fs::remove_dir_all(parent); + return; + } + let _ = fs::remove_file(path); +} + +fn compile_path(path: &Path) -> Result<Accelerator, String> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let result = runtime.block_on(cli::rcm::compile::compile_file(path)); + remove_compile_path(path); + result +} + +fn compile_path_with_catalog(path: &Path, catalog: Catalog) -> Result<Accelerator, String> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let result = runtime.block_on(cli::rcm::compile::compile_file_with_catalog(path, catalog)); + remove_compile_path(path); + result +} + +fn compile_result(source: &str) -> Result<Accelerator, String> { + let path = write_rcm("compile", source); + compile_path(&path) +} + +fn primitive_state(accelerator: &Accelerator) -> &RunState { + accelerator + .internal_state() + .expect("expected primitive accelerator") +} + +#[test] +fn compile_selects_resource_pools_without_initial_activation() { + let source = r#" + name = "resources" + model fast { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + model careful { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "2000", output = "200" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + purpose = "inspect" + models = ["fast", "careful"] + prompts = { captain = "Custom captain" } + tools = ["fs", "shell"] + } + "#; + + let accelerator = compile_result(source).unwrap(); + let state = primitive_state(&accelerator); + + assert_eq!(state.resources.models.len(), 2); + assert!(state.resources.models.contains_key("fast")); + assert!(state.resources.models.contains_key("careful")); + assert!(state.resources.active_model.is_empty()); + assert!(state.resources.prompts.contains_key("captain")); + assert_eq!( + state.resources.prompts.get("captain").map(String::as_str), + Some("Custom captain") + ); + assert_eq!(state.resources.tool_definitions.len(), 2); + assert!(state.resources.tool_definitions.contains_key("fs")); + assert!(state.resources.tool_definitions.contains_key("shell")); + assert!(state.resources.active_tools.is_empty()); +} + +#[test] +fn compile_keeps_no_tools_when_only_prompts_are_supplied() { + let source = r#" + name = "prompt only" + model gpt { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + purpose = "inspect" + models = ["gpt"] + prompts = { captain = "Custom captain" } + } + "#; + + let accelerator = compile_result(source).unwrap(); + let state = primitive_state(&accelerator); + + assert!(state.resources.prompts.contains_key("captain")); + assert!(state.resources.tool_definitions.is_empty()); + assert!(state.resources.active_tools.is_empty()); +} + +#[test] +fn compile_reads_prompt_files_relative_to_rcm_file() { + let source = r#" + name = "file prompt" + model gpt { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + purpose = "inspect" + models = ["gpt"] + prompts = { captain = file "captain.txt" } + } + "#; + let path = write_rcm_near("prompt", source, "captain.txt", "File captain"); + + let accelerator = compile_path(&path).unwrap(); + let state = primitive_state(&accelerator); + + assert_eq!( + state.resources.prompts.get("captain").map(String::as_str), + Some("File captain") + ); +} + +#[test] +fn compile_accepts_external_catalog_tools() { + let source = r#" + name = "external tool" + model gpt { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + purpose = "inspect" + models = ["gpt"] + tools = ["external_tool"] + } + "#; + let path = write_rcm("external-tool", source); + let mut catalog = Catalog::new(); + catalog.register_tool(Arc::new(ExternalTool)).unwrap(); + + let accelerator = compile_path_with_catalog(&path, catalog).unwrap(); + let state = primitive_state(&accelerator); + + assert!( + state + .resources + .tool_definitions + .contains_key("external_tool") + ); + assert_eq!(state.resources.tool_definitions.len(), 1); +} + +#[test] +fn compile_requires_at_least_one_accelerator_model() { + let source = r#" + name = "missing models" + model gpt { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + purpose = "inspect" + } + "#; + + let error = match compile_result(source) { + Ok(_) => panic!("expected compile error"), + Err(error) => error, + }; + assert!(error.contains("accelerator requires at least one model")); +} + +#[test] +fn compile_rejects_flux_slot_out_of_range() { + let source = r#" + name = "bad flux" + model gpt { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + graph { + accelerator source { + purpose = "source" + models = ["gpt"] + } + flux joined { + channel = context + mode = append + arity = 1 + } + source.context -> joined.slot(2) + } + "#; + + let error = match compile_result(source) { + Ok(_) => panic!("expected compile error"), + Err(error) => error, + }; + assert!(error.contains("out of range")); +} + +#[test] +fn compile_does_not_start_unselected_mcp_servers() { + let source = r#" + name = "unused mcp" + mcp docs { + transport = stdio + command = "definitely-not-a-real-mcp-command" + } + model gpt { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + purpose = "inspect" + models = ["gpt"] + tools = ["fs"] + } + "#; + + let accelerator = compile_result(source).unwrap(); + let state = primitive_state(&accelerator); + assert!(state.resources.tool_definitions.contains_key("fs")); + assert!( + !state + .resources + .tool_definitions + .keys() + .any(|name| name.starts_with("docs.")) + ); +} + +#[test] +fn compile_rejects_unknown_mcp_selection() { + let source = r#" + name = "unknown mcp" + model gpt { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + purpose = "inspect" + models = ["gpt"] + mcps = ["docs"] + } + "#; + + let error = match compile_result(source) { + Ok(_) => panic!("expected compile error"), + Err(error) => error, + }; + assert!(error.contains("unknown mcp server: docs")); +} + +#[test] +fn compile_rejects_graph_cycles() { + let source = r#" + name = "cycle" + model gpt { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + graph { + accelerator first { + purpose = "first" + models = ["gpt"] + } + accelerator second { + purpose = "second" + models = ["gpt"] + } + first.done -> second.trigger + second.done -> first.trigger + } + "#; + + let error = match compile_result(source) { + Ok(_) => panic!("expected compile error"), + Err(error) => error, + }; + assert!(error.contains("cycle")); +} diff --git a/crates/cli/tests/dispatch.rs b/crates/cli/tests/dispatch.rs new file mode 100644 index 00000000..84b6f719 --- /dev/null +++ b/crates/cli/tests/dispatch.rs @@ -0,0 +1,221 @@ +use std::collections::HashMap; + +use cli::cmd::dispatch::{ + FieldSource, Filter, Route, collect_substitutions, passes_filter, render_template, + resolve_json_path, select_route, +}; +use serde_json::json; + +fn route_simple(event: &str, action: Option<&str>, template: &str) -> Route { + Route { + event: event.to_string(), + action: action.map(str::to_string), + template: template.to_string(), + filter: None, + fields: HashMap::new(), + } +} + +#[test] +fn select_route_prefers_exact_action_match() { + let routes = vec![ + route_simple("issues", None, "fallback.tpl"), + route_simple("issues", Some("opened"), "opened.tpl"), + ]; + let chosen = select_route(&routes, "issues", Some("opened")).unwrap(); + assert_eq!(chosen.template, "opened.tpl"); +} + +#[test] +fn select_route_falls_back_to_action_less_route() { + let routes = vec![route_simple("workflow_dispatch", None, "health.tpl")]; + let chosen = select_route(&routes, "workflow_dispatch", None).unwrap(); + assert_eq!(chosen.template, "health.tpl"); +} + +#[test] +fn select_route_returns_none_when_no_event_matches() { + let routes = vec![route_simple("issues", Some("opened"), "opened.tpl")]; + assert!(select_route(&routes, "pull_request", Some("opened")).is_none()); +} + +#[test] +fn select_route_returns_none_when_action_differs_and_no_fallback() { + let routes = vec![route_simple("issues", Some("opened"), "opened.tpl")]; + assert!(select_route(&routes, "issues", Some("closed")).is_none()); +} + +#[test] +fn passes_filter_when_no_filter_present() { + let route = route_simple("issues", Some("opened"), "any.tpl"); + let payload = json!({}); + assert!(passes_filter(&route, &payload)); +} + +#[test] +fn passes_filter_when_substring_present() { + let mut route = route_simple("issue_comment", Some("created"), "mention.tpl"); + route.filter = Some(Filter { + field: "comment.body".to_string(), + contains: "@maintainer".to_string(), + }); + let payload = json!({"comment": {"body": "hey @maintainer help"}}); + assert!(passes_filter(&route, &payload)); +} + +#[test] +fn passes_filter_rejects_when_substring_absent() { + let mut route = route_simple("issue_comment", Some("created"), "mention.tpl"); + route.filter = Some(Filter { + field: "comment.body".to_string(), + contains: "@maintainer".to_string(), + }); + let payload = json!({"comment": {"body": "thanks for the fix"}}); + assert!(!passes_filter(&route, &payload)); +} + +#[test] +fn passes_filter_rejects_when_field_missing() { + let mut route = route_simple("issue_comment", Some("created"), "mention.tpl"); + route.filter = Some(Filter { + field: "comment.body".to_string(), + contains: "@maintainer".to_string(), + }); + let payload = json!({"comment": {}}); + assert!(!passes_filter(&route, &payload)); +} + +#[test] +fn resolve_json_path_walks_nested_objects() { + let payload = json!({"repository": {"full_name": "owner/repo"}}); + assert_eq!( + resolve_json_path(&payload, "repository.full_name").as_deref(), + Some("owner/repo") + ); +} + +#[test] +fn resolve_json_path_handles_numbers_and_bools() { + let payload = json!({"issue": {"number": 42, "locked": false}}); + assert_eq!( + resolve_json_path(&payload, "issue.number").as_deref(), + Some("42") + ); + assert_eq!( + resolve_json_path(&payload, "issue.locked").as_deref(), + Some("false") + ); +} + +#[test] +fn resolve_json_path_returns_none_for_missing_field() { + let payload = json!({"issue": {}}); + assert!(resolve_json_path(&payload, "issue.number").is_none()); + assert!(resolve_json_path(&payload, "unknown.path").is_none()); +} + +#[test] +fn collect_substitutions_pulls_paths_and_literals() { + let mut route = route_simple("issue_comment", Some("created"), "mention.tpl"); + route.fields.insert( + "REPO".into(), + FieldSource::Path("repository.full_name".into()), + ); + route.fields.insert( + "TRIGGER_NUMBER".into(), + FieldSource::Path("issue.number".into()), + ); + route.fields.insert( + "TRIGGER_KIND".into(), + FieldSource::Literal { + value: "issue".into(), + }, + ); + + let payload = json!({ + "repository": {"full_name": "acme/widget"}, + "issue": {"number": 7} + }); + + let subs = collect_substitutions(&route, &payload).unwrap(); + assert_eq!(subs.get("REPO").map(String::as_str), Some("acme/widget")); + assert_eq!(subs.get("TRIGGER_NUMBER").map(String::as_str), Some("7")); + assert_eq!(subs.get("TRIGGER_KIND").map(String::as_str), Some("issue")); +} + +#[test] +fn collect_substitutions_errors_when_required_field_missing() { + let mut route = route_simple("issues", Some("opened"), "triage.tpl"); + route.fields.insert( + "ISSUE_NUMBER".into(), + FieldSource::Path("issue.number".into()), + ); + + let payload = json!({"issue": {}}); + let result = collect_substitutions(&route, &payload); + let error = result.unwrap_err().to_string(); + assert!( + error.contains("issue.number"), + "expected error to mention the missing path, got: {error}" + ); +} + +#[test] +fn render_template_substitutes_known_placeholders() { + let template = "issue {{ISSUE_NUMBER}} in {{REPO}}"; + let mut subs = HashMap::new(); + subs.insert("ISSUE_NUMBER".into(), "42".into()); + subs.insert("REPO".into(), "acme/widget".into()); + assert_eq!( + render_template(template, &subs).unwrap(), + "issue 42 in acme/widget" + ); +} + +#[test] +fn render_template_handles_repeated_placeholders() { + let template = "{{REPO}} again {{REPO}}"; + let mut subs = HashMap::new(); + subs.insert("REPO".into(), "x/y".into()); + assert_eq!(render_template(template, &subs).unwrap(), "x/y again x/y"); +} + +#[test] +fn render_template_passes_through_text_with_no_placeholders() { + let template = "no placeholders here"; + let subs = HashMap::new(); + assert_eq!(render_template(template, &subs).unwrap(), template); +} + +#[test] +fn render_template_errors_on_unresolved_placeholders() { + let template = "hi {{MISSING}}"; + let subs = HashMap::new(); + let error = render_template(template, &subs).unwrap_err().to_string(); + assert!(error.contains("MISSING"), "got: {error}"); +} + +#[test] +fn render_template_errors_on_unbalanced_braces() { + let template = "broken {{NAME"; + let mut subs = HashMap::new(); + subs.insert("NAME".into(), "x".into()); + let error = render_template(template, &subs).unwrap_err().to_string(); + assert!(error.contains("unbalanced"), "got: {error}"); +} + +#[test] +fn render_template_errors_on_invalid_placeholder_syntax() { + let template = "bad {{lowercase}}"; + let subs = HashMap::new(); + let error = render_template(template, &subs).unwrap_err().to_string(); + assert!(error.contains("invalid placeholder"), "got: {error}"); +} + +#[test] +fn render_template_accepts_whitespace_inside_braces() { + let template = "hello {{ NAME }}"; + let mut subs = HashMap::new(); + subs.insert("NAME".into(), "world".into()); + assert_eq!(render_template(template, &subs).unwrap(), "hello world"); +} diff --git a/crates/cli/tests/parser.rs b/crates/cli/tests/parser.rs new file mode 100644 index 00000000..b785b59c --- /dev/null +++ b/crates/cli/tests/parser.rs @@ -0,0 +1,338 @@ +use cli::rcm; +use cli::rcm::{ + AcceleratorBodyDef, AcceleratorSourceDef, EndpointDef, McpTransportDef, McpValueDef, + PortOwnerDef, PromptSourceDef, +}; + +#[test] +fn parse_primitive_accelerator_file() { + let source = r#" + name = "reviewer" + model gpt { + protocol = "openai" + credentials = { env = "MY_KEY" } + limit = { context = "100000", output = "4096" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + purpose = "review code" + models = ["gpt"] + prompts = { + captain = file "./captain.txt" + review = "Review carefully" + } + tools = ["fs", "shell"] + mcps = ["docs"] + } + "#; + + let file = rcm::parse(source).unwrap(); + + assert_eq!(file.name, "reviewer"); + assert_eq!(file.models.len(), 1); + match file.body { + AcceleratorBodyDef::Primitive(primitive) => { + assert_eq!(primitive.purpose.as_deref(), Some("review code")); + assert_eq!(primitive.models, vec!["gpt"]); + assert_eq!(primitive.tools, Some(vec!["fs".into(), "shell".into()])); + assert_eq!(primitive.mcps, Some(vec!["docs".into()])); + let prompts = primitive.prompts.as_ref().unwrap(); + assert_eq!( + prompts.get("captain"), + Some(&PromptSourceDef::File("./captain.txt".into())) + ); + assert_eq!( + prompts.get("review"), + Some(&PromptSourceDef::Inline("Review carefully".into())) + ); + } + AcceleratorBodyDef::Graph(_) => panic!("expected primitive accelerator"), + } +} + +#[test] +fn parse_mcp_transports() { + let source = r#" + name = "mcp demo" + mcp docs { + transport = stdio + command = "npx" + args = ["-y", "@example/server"] + env = { API_KEY = env "DOCS_API_KEY" } + cwd = "." + } + mcp remote { + transport = http + url = "https://example.com/mcp" + headers = { Authorization = env "DOCS_TOKEN" X_Project = "RCM" } + } + mcp legacy { + transport = sse + url = "https://example.com/sse" + } + accelerator { + models = ["gpt"] + mcps = ["docs", "remote"] + } + "#; + + let file = rcm::parse(source).unwrap(); + + assert_eq!(file.mcps.len(), 3); + match &file.mcps[0].transport { + McpTransportDef::Stdio { + command, + args, + env, + cwd, + } => { + assert_eq!(command, "npx"); + assert_eq!(args, &vec!["-y".to_string(), "@example/server".to_string()]); + assert_eq!( + env.get("API_KEY"), + Some(&McpValueDef::Env("DOCS_API_KEY".into())) + ); + assert_eq!(cwd.as_deref(), Some(".")); + } + _ => panic!("expected stdio transport"), + } + match &file.mcps[1].transport { + McpTransportDef::Http { url, headers } => { + assert_eq!(url, "https://example.com/mcp"); + assert_eq!( + headers.get("Authorization"), + Some(&McpValueDef::Env("DOCS_TOKEN".into())) + ); + assert_eq!( + headers.get("X_Project"), + Some(&McpValueDef::Literal("RCM".into())) + ); + } + _ => panic!("expected http transport"), + } + assert!(matches!( + file.mcps[2].transport, + McpTransportDef::Sse { .. } + )); +} + +#[test] +fn parse_graph_with_imported_accelerator_and_boundary_wires() { + let source = r#" + name = "pipeline" + use "./reviewer.rcm" as Reviewer + graph { + accelerator review = Reviewer + input.purpose -> review.purpose + review.done -> output.done + review.context -> output.context + } + "#; + + let file = rcm::parse(source).unwrap(); + + assert_eq!(file.uses[0].alias, "Reviewer"); + let graph = match file.body { + AcceleratorBodyDef::Graph(graph) => graph, + AcceleratorBodyDef::Primitive(_) => panic!("expected graph accelerator"), + }; + assert_eq!(graph.accelerators.len(), 1); + assert_eq!(graph.wires.len(), 3); + assert_eq!(graph.wires[0].from.owner, PortOwnerDef::Input); + assert_eq!( + graph.wires[0].to.endpoint, + EndpointDef::State("purpose".into()) + ); +} + +#[test] +fn parse_graph_with_flux_and_condition_ports() { + let source = r#" + name = "pipeline" + graph { + accelerator source { + purpose = "source" + models = ["gpt"] + } + flux joined { + channel = context + mode = append + arity = 2 + } + condition approved { + any { + purpose contains "LGTM" + context contains "approved" + } + } + source.context -> joined.slot(0) + joined.out -> source.context + source.done -> approved.trigger + approved.true -> output.done + } + "#; + + let file = rcm::parse(source).unwrap(); + let graph = match file.body { + AcceleratorBodyDef::Graph(graph) => graph, + AcceleratorBodyDef::Primitive(_) => panic!("expected graph accelerator"), + }; + + assert_eq!(graph.fluxes[0].arity, 2); + assert_eq!(graph.conditions.len(), 1); + assert_eq!(graph.wires[0].to.endpoint, EndpointDef::FluxSlot(0)); + assert_eq!(graph.wires[2].to.endpoint, EndpointDef::Trigger); + assert_eq!(graph.wires[3].from.endpoint, EndpointDef::ConditionTrue); +} + +#[test] +fn parse_inline_graph_accelerator() { + let source = r#" + name = "pipeline" + graph { + accelerator fetch { + purpose = "fetch diff" + models = ["gpt"] + tools = ["shell"] + } + } + "#; + + let file = rcm::parse(source).unwrap(); + let graph = match file.body { + AcceleratorBodyDef::Graph(graph) => graph, + AcceleratorBodyDef::Primitive(_) => panic!("expected graph accelerator"), + }; + + match &graph.accelerators[0].source { + AcceleratorSourceDef::Inline(primitive) => { + assert_eq!(primitive.purpose.as_deref(), Some("fetch diff")); + } + AcceleratorSourceDef::Import { .. } => panic!("expected inline accelerator"), + } +} + +#[test] +fn parse_rejects_unclosed_string() { + let result = rcm::parse(r#"name = "test" accelerator { purpose = "unclosed"#); + assert!(result.is_err()); +} + +#[test] +fn parse_model_with_headers() { + let source = r#" + name = "kimi-coder" + model kimi { + protocol = "openai" + endpoint = "https://api.kimi.com/coding" + credentials = { env = "KIMI_KEY" } + headers = { User-Agent = "KimiCLI/1.5" X-Custom = "value" } + limit = { context = "262144", output = "32768" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + models = ["kimi"] + } + "#; + + let file = rcm::parse(source).unwrap(); + assert_eq!(file.models.len(), 1); + let model = &file.models[0]; + assert_eq!(model.id, "kimi"); + assert_eq!(model.protocol, "openai"); + assert_eq!( + model.endpoint.as_deref(), + Some("https://api.kimi.com/coding") + ); + assert_eq!( + model.headers.get("User-Agent"), + Some(&"KimiCLI/1.5".to_string()) + ); + assert_eq!(model.headers.get("X-Custom"), Some(&"value".to_string())); +} + +#[test] +fn parse_model_without_headers() { + let source = r#" + name = "basic" + model gpt { + protocol = "openai" + credentials = { env = "OPENAI_KEY" } + limit = { context = "100000", output = "4096" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + models = ["gpt"] + } + "#; + + let file = rcm::parse(source).unwrap(); + assert!(file.models[0].headers.is_empty()); +} + +#[test] +fn parse_model_with_thinking_true() { + let source = r#" + name = "kimi-thinking" + model kimi { + protocol = "openai" + credentials = { env = "KIMI_KEY" } + limit = { context = "262144", output = "32768" } + modalities = { input = ["text"], output = ["text"] } + thinking = "true" + } + accelerator { + models = ["kimi"] + } + "#; + + let file = rcm::parse(source).unwrap(); + assert!( + file.models[0].thinking, + "thinking = \"true\" must be parsed as bool true" + ); +} + +#[test] +fn parse_model_without_thinking_defaults_to_false() { + let source = r#" + name = "basic" + model gpt { + protocol = "openai" + credentials = { env = "OPENAI_KEY" } + limit = { context = "100000", output = "4096" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + models = ["gpt"] + } + "#; + + let file = rcm::parse(source).unwrap(); + assert!( + !file.models[0].thinking, + "models without `thinking =` default to false (no reasoning_content pollution)" + ); +} + +#[test] +fn parse_model_thinking_invalid_value_errors() { + let source = r#" + name = "bad" + model x { + protocol = "openai" + credentials = { env = "K" } + limit = { context = "1", output = "1" } + modalities = { input = ["text"], output = ["text"] } + thinking = "maybe" + } + accelerator { models = ["x"] } + "#; + + let error = rcm::parse(source).unwrap_err(); + assert!( + error.contains("thinking"), + "error must mention 'thinking', got: {}", + error + ); +} diff --git a/crates/cli/tests/run_dir.rs b/crates/cli/tests/run_dir.rs new file mode 100644 index 00000000..dfc4e77b --- /dev/null +++ b/crates/cli/tests/run_dir.rs @@ -0,0 +1,86 @@ +use std::path::PathBuf; + +/// Test that RunState with run_dir properly sets environment vars. +#[test] +fn run_dir_sets_rcm_run_dir_env_var() { + let run_dir = PathBuf::from("/tmp/my-run"); + + let mut state = machine::RunState { + run_dir: Some(run_dir.clone()), + ..machine::RunState::default() + }; + state.environment.run_dir = Some(PathBuf::from("/tmp/my-run")); + state + .environment + .vars + .insert("RCM_RUN_DIR".to_string(), "/tmp/my-run".to_string()); + + assert_eq!(state.run_dir, Some(PathBuf::from("/tmp/my-run"))); + assert_eq!( + state.environment.run_dir, + Some(PathBuf::from("/tmp/my-run")) + ); + assert_eq!( + state.environment.vars.get("RCM_RUN_DIR"), + Some(&"/tmp/my-run".to_string()) + ); +} + +/// Test that RunState defaults have run_dir as None. +#[test] +fn run_dir_default_state_is_none() { + let state = machine::RunState::default(); + assert!(state.run_dir.is_none(), "default run_dir should be None"); + assert!( + state.environment.run_dir.is_none(), + "default env.run_dir should be None" + ); + assert!( + !state.environment.vars.contains_key("RCM_RUN_DIR"), + "default env should not contain RCM_RUN_DIR" + ); +} + +/// Test that run_dir handles unicode paths. +#[test] +fn run_dir_handles_unicode_path() { + let run_dir = PathBuf::from("/tmp/测试-运行"); + + let mut state = machine::RunState { + run_dir: Some(run_dir), + ..machine::RunState::default() + }; + state.environment.run_dir = Some(PathBuf::from("/tmp/测试-运行")); + state + .environment + .vars + .insert("RCM_RUN_DIR".to_string(), "/tmp/测试-运行".to_string()); + + assert_eq!(state.run_dir, Some(PathBuf::from("/tmp/测试-运行"))); + assert_eq!( + state.environment.vars.get("RCM_RUN_DIR"), + Some(&"/tmp/测试-运行".to_string()) + ); +} + +/// Test that run_dir with relative path works. +#[test] +fn run_dir_accepts_relative_path() { + let run_dir = PathBuf::from("relative/path/run"); + + let mut state = machine::RunState { + run_dir: Some(run_dir), + ..machine::RunState::default() + }; + state.environment.run_dir = Some(PathBuf::from("relative/path/run")); + state + .environment + .vars + .insert("RCM_RUN_DIR".to_string(), "relative/path/run".to_string()); + + assert_eq!(state.run_dir, Some(PathBuf::from("relative/path/run"))); + assert_eq!( + state.environment.vars.get("RCM_RUN_DIR"), + Some(&"relative/path/run".to_string()) + ); +} diff --git a/crates/cli/tests/run_stream.rs b/crates/cli/tests/run_stream.rs new file mode 100644 index 00000000..4a327643 --- /dev/null +++ b/crates/cli/tests/run_stream.rs @@ -0,0 +1,172 @@ +use cli::cmd::run::{completion_event_json, is_terminal_event}; +use cli::hook::{CompletionEvent, ComponentMeta, GraphEvent, HookEvent, HookKind, MachineEvent}; +use serde::Deserialize; + +fn event(kind: HookKind) -> HookEvent { + HookEvent { source: None, kind } +} + +fn nested_event(kind: HookKind) -> HookEvent { + HookEvent { + source: Some(ComponentMeta { + graph: "survey_pipeline".into(), + name: "discovery".into(), + index: 1, + kind: "accelerator".into(), + frontier: Some(2), + }), + kind, + } +} + +#[test] +fn root_graph_done_terminates_after_root_graph_start() { + let done = event(HookKind::Graph(GraphEvent::Done { + graph: "main".into(), + })); + assert!(is_terminal_event(&done, true)); +} + +#[test] +fn nested_graph_done_does_not_terminate_root_stream() { + let done = nested_event(HookKind::Graph(GraphEvent::Done { + graph: "discovery".into(), + })); + + assert!(!is_terminal_event(&done, true)); +} + +#[test] +fn machine_done_terminates_only_when_no_graph_was_seen() { + let done = event(HookKind::Machine(MachineEvent::Done)); + assert!( + is_terminal_event(&done, false), + "primitive accelerator should terminate on Machine::Done" + ); + assert!( + !is_terminal_event(&done, true), + "graph-based run must wait for Graph::Done; Machine::Done fires per-machine" + ); +} + +#[test] +fn nested_machine_done_never_terminates_root_stream() { + let done = nested_event(HookKind::Machine(MachineEvent::Done)); + + assert!(!is_terminal_event(&done, false)); + assert!(!is_terminal_event(&done, true)); +} + +#[test] +fn machine_start_never_terminates() { + let start = event(HookKind::Machine(MachineEvent::Start)); + assert!(!is_terminal_event(&start, false)); + assert!(!is_terminal_event(&start, true)); +} + +#[test] +fn machine_halt_never_terminates() { + let halt = event(HookKind::Machine(MachineEvent::Halt { step: 3 })); + assert!(!is_terminal_event(&halt, false)); + assert!(!is_terminal_event(&halt, true)); +} + +#[test] +fn graph_start_never_terminates() { + let start = event(HookKind::Graph(GraphEvent::Start { + graph: "main".into(), + })); + assert!(!is_terminal_event(&start, false)); + assert!(!is_terminal_event(&start, true)); +} + +#[test] +fn frontier_events_never_terminate() { + let frontier_start = event(HookKind::Graph(GraphEvent::FrontierStart { + graph: "main".into(), + frontier: 1, + count: 2, + })); + let frontier_done = event(HookKind::Graph(GraphEvent::FrontierDone { + graph: "main".into(), + frontier: 1, + count: 2, + })); + assert!(!is_terminal_event(&frontier_start, true)); + assert!(!is_terminal_event(&frontier_done, true)); +} + +#[test] +fn completion_failure_json_adds_sanitized_metadata() { + let payload = completion_event_json(&CompletionEvent::End { + fragments: 1, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + outcome: Some("failure".into()), + http_status: Some(503), + failure_kind: Some("provider_error".into()), + retryable: Some(true), + duration_ms: Some(12_345), + }); + + assert_eq!(payload["outcome"], "failure"); + assert_eq!(payload["http_status"], 503); + assert_eq!(payload["failure_kind"], "provider_error"); + assert_eq!(payload["retryable"], true); + assert_eq!(payload["duration_ms"], 12_345); + assert!(payload.get("error").is_none()); + assert!(payload.get("message").is_none()); +} + +#[test] +fn legacy_completion_event_omits_new_optional_fields() { + let payload = completion_event_json(&CompletionEvent::End { + fragments: 2, + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + outcome: None, + http_status: None, + failure_kind: None, + retryable: None, + duration_ms: None, + }); + + assert!(payload.get("outcome").is_none()); + assert!(payload.get("http_status").is_none()); + assert!(payload.get("failure_kind").is_none()); + assert!(payload.get("retryable").is_none()); + assert!(payload.get("duration_ms").is_none()); +} + +#[derive(Deserialize)] +struct LegacyCompletionConsumer { + fragments: usize, + total_tokens: u64, +} + +#[test] +fn legacy_consumers_can_ignore_additive_completion_fields() { + let payload = completion_event_json(&CompletionEvent::End { + fragments: 1, + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + outcome: Some("failure".into()), + http_status: Some(429), + failure_kind: Some("rate_limited".into()), + retryable: Some(true), + duration_ms: Some(100), + }); + + let legacy: LegacyCompletionConsumer = serde_json::from_value(payload).unwrap(); + assert_eq!(legacy.fragments, 1); + assert_eq!(legacy.total_tokens, 0); +} diff --git a/crates/cli/tests/tape_animation.rs b/crates/cli/tests/tape_animation.rs new file mode 100644 index 00000000..146b7c31 --- /dev/null +++ b/crates/cli/tests/tape_animation.rs @@ -0,0 +1,247 @@ +use cli::hook::{ComponentMeta, FragmentEvent, FragmentMeta, HookEvent, HookKind, ResourceEvent}; +use cli::tape_animation::snapshot_events; + +fn resource_event(source: ComponentMeta, event: ResourceEvent) -> HookEvent { + HookEvent { + source: Some(source), + kind: HookKind::Resource(event), + } +} + +fn source(name: &str, index: usize) -> ComponentMeta { + ComponentMeta { + graph: "test".into(), + name: name.into(), + index, + kind: "accelerator".into(), + frontier: Some(1), + } +} + +fn fragment(id: u64, role: &str, kind: &str, tag: &str) -> FragmentMeta { + FragmentMeta { + id, + step: id, + role: role.into(), + kind: kind.into(), + tag: tag.into(), + preview: format!("fragment {id}"), + } +} + +fn fragment_event(source: ComponentMeta, event: FragmentEvent) -> HookEvent { + HookEvent { + source: Some(source), + kind: HookKind::Fragment(event), + } +} + +#[test] +fn snapshot_uses_fragment_tags_for_cell_tones() { + let source = source("agent", 0); + let snapshot = snapshot_events( + [ + fragment_event( + source.clone(), + FragmentEvent::Appended(fragment(1, "system", "text", "env")), + ), + fragment_event( + source, + FragmentEvent::Appended(fragment(2, "system", "text", "custom_context")), + ), + ], + 9, + ); + + let cells = &snapshot.tapes[0].cells; + assert_eq!(cells[0].tone, "env"); + assert_eq!(cells[1].tone, "tag:custom_context"); +} + +#[test] +fn snapshot_applies_insert_remove_and_swap_by_fragment_id() { + let source = source("worker", 0); + let snapshot = snapshot_events( + [ + fragment_event( + source.clone(), + FragmentEvent::Appended(fragment(1, "user", "text", "user")), + ), + fragment_event( + source.clone(), + FragmentEvent::Appended(fragment(2, "assistant", "text", "assistant")), + ), + fragment_event( + source.clone(), + FragmentEvent::Inserted { + meta: fragment(3, "system", "text", "instruction"), + after: 1, + }, + ), + fragment_event( + source.clone(), + FragmentEvent::Swapped { + first: 1, + second: 2, + }, + ), + fragment_event(source, FragmentEvent::Removed { id: 3 }), + ], + 9, + ); + + let ids = snapshot.tapes[0] + .cells + .iter() + .map(|cell| cell.id) + .collect::<Vec<_>>(); + assert_eq!(ids, vec![2, 1]); +} + +#[test] +fn snapshot_hides_overflow_tapes_with_fixed_view_capacity() { + let events = (0..5).map(|index| { + fragment_event( + source(&format!("worker_{index}"), index), + FragmentEvent::Appended(fragment(index as u64 + 1, "user", "text", "user")), + ) + }); + + let snapshot = snapshot_events(events, 9); + + assert_eq!(snapshot.tapes.len(), 2); + assert_eq!(snapshot.hidden_tapes, 3); +} + +#[test] +fn scaffolding_replace_flashes_without_moving_pointer() { + let source = source("worker", 0); + // Append the env scaffolding first, then a long run of content so the pointer + // sits far from the env cell, then re-emit env (the per-round timestamp tick). + let mut events = vec![fragment_event( + source.clone(), + FragmentEvent::Appended(fragment(1, "system", "text", "env")), + )]; + for id in 2..=12 { + events.push(fragment_event( + source.clone(), + FragmentEvent::Appended(fragment(id, "assistant", "text", "")), + )); + } + let pointer_before = snapshot_events(events.clone(), 9).tapes[0].pointer; + + // env re-emitted many times (the per-round timestamp tick) → repeated Flash. + for _ in 0..5 { + events.push(fragment_event( + source.clone(), + FragmentEvent::Replaced(fragment(1, "system", "text", "env")), + )); + } + let after = snapshot_events(events, 9); + + // The pointer must never run back to the env cell at index 0, no matter how + // many times env is refreshed. + assert_eq!(after.tapes[0].pointer, pointer_before); + assert!(pointer_before > 0, "pointer should be at the content end"); + // The env cell keeps its env tone (flash settles back to Written/env). + assert_eq!(after.tapes[0].cells[0].tone, "env"); +} + +#[test] +fn head_leaves_no_hollow_trail_behind_it() { + let source = source("worker", 0); + // Append a long run so the head ends at the far end; after it has travelled, + // no cell behind the pointer should still be hollow (pending). + let events = (1..=20).map(|id| { + fragment_event( + source.clone(), + FragmentEvent::Appended(fragment(id, "assistant", "text", "")), + ) + }); + let snapshot = snapshot_events(events, 9); + + let tape = &snapshot.tapes[0]; + let hollow_behind = tape + .cells + .iter() + .take(tape.pointer) + .filter(|cell| cell.glyph == "□") + .count(); + assert_eq!( + hollow_behind, 0, + "no hollow cells should remain behind the head" + ); +} + +#[test] +fn removal_is_not_colored_like_an_error() { + // Red is reserved for errors (hitch); a removal must not read as red. After a + // removal settles, the cell is gone, so this guards the glyph/tone vocabulary: + // a hitch fragment is the only thing that should carry the error tone. + let source = source("worker", 0); + let snapshot = snapshot_events( + [fragment_event( + source, + FragmentEvent::Appended(fragment(1, "system", "hitch", "")), + )], + 9, + ); + assert_eq!(snapshot.tapes[0].cells[0].tone, "hitch"); +} + +#[test] +fn distinct_tags_get_distinct_tones() { + let source = source("worker", 0); + let snapshot = snapshot_events( + [ + fragment_event( + source.clone(), + FragmentEvent::Appended(fragment(1, "system", "text", "handoff")), + ), + fragment_event( + source, + FragmentEvent::Appended(fragment(2, "system", "text", "scratch")), + ), + ], + 9, + ); + + let cells = &snapshot.tapes[0].cells; + assert_eq!(cells[0].tone, "tag:handoff"); + assert_eq!(cells[1].tone, "tag:scratch"); +} + +#[test] +fn take_settles_with_intake_status() { + let source = source("worker", 0); + let snapshot = snapshot_events( + [fragment_event( + source, + FragmentEvent::Taken(fragment(1, "user", "text", "user")), + )], + 9, + ); + + // Taking from the inbox runs through the intake animation; the cell still + // lands as a normal written cell, distinct from a plain append by its verb. + assert_eq!(snapshot.tapes[0].last_action, "intake"); + assert_eq!(snapshot.tapes[0].cells.len(), 1); + assert_eq!(snapshot.tapes[0].cells[0].id, 1); +} + +#[test] +fn resource_actions_surface_a_status_verb() { + let source = source("worker", 0); + let snapshot = snapshot_events( + [ + fragment_event( + source.clone(), + FragmentEvent::Appended(fragment(1, "user", "text", "user")), + ), + resource_event(source, ResourceEvent::Activate { name: "fs".into() }), + ], + 9, + ); + + assert_eq!(snapshot.tapes[0].last_action, "activate fs"); +} diff --git a/crates/cli/tests/templates.rs b/crates/cli/tests/templates.rs new file mode 100644 index 00000000..c0ffcb23 --- /dev/null +++ b/crates/cli/tests/templates.rs @@ -0,0 +1,241 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use cli::cmd::dispatch::{DispatchConfig, collect_substitutions, render_template}; + +fn project_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(std::path::Path::parent) + .expect("workspace root") + .to_path_buf() +} + +fn load_dispatch_config() -> DispatchConfig { + let config_path = project_root().join("examples/project-maintainer/dispatch.toml"); + let text = fs::read_to_string(&config_path).expect("read dispatch.toml"); + toml::from_str(&text).expect("parse dispatch.toml") +} + +fn render_route(event_name: &str, action: Option<&str>, payload: serde_json::Value) -> String { + let config = load_dispatch_config(); + let route = config + .routes + .iter() + .find(|route| route.event == event_name && route.action.as_deref() == action) + .or_else(|| { + config + .routes + .iter() + .find(|route| route.event == event_name && route.action.is_none()) + }) + .unwrap_or_else(|| panic!("no route for {event_name}/{action:?}")); + + let substitutions = collect_substitutions(route, &payload).expect("collect substitutions"); + + let template_path = project_root() + .join("examples/project-maintainer") + .join(&config.templates_dir) + .join(&route.template); + let template_text = fs::read_to_string(&template_path).expect("read template"); + render_template(&template_text, &substitutions).expect("render template") +} + +fn assert_rendered_parses(rendered: &str, label: &str) { + cli::rcm::parse(rendered).unwrap_or_else(|err| { + panic!("rendered {label} template failed to parse: {err}\n--- rendered ---\n{rendered}") + }); +} + +fn assert_no_unrendered_placeholders(rendered: &str, label: &str) { + if rendered.contains("{{") || rendered.contains("}}") { + panic!("rendered {label} contains leftover braces:\n{rendered}"); + } +} + +#[test] +fn dispatch_config_parses() { + let config = load_dispatch_config(); + assert!(!config.routes.is_empty(), "dispatch.toml has no routes"); +} + +#[test] +fn issue_triage_template_renders_and_parses() { + let payload = serde_json::json!({ + "action": "opened", + "repository": {"full_name": "acme/widget"}, + "issue": {"number": 42, "title": "Crash on empty input"} + }); + let rendered = render_route("issues", Some("opened"), payload); + assert!(rendered.contains("acme/widget")); + assert!(rendered.contains("#42") || rendered.contains("42")); + assert_no_unrendered_placeholders(&rendered, "issue_triage"); + assert_rendered_parses(&rendered, "issue_triage"); +} + +#[test] +fn pr_review_template_renders_and_parses_on_opened() { + let payload = serde_json::json!({ + "action": "opened", + "repository": {"full_name": "acme/widget"}, + "pull_request": {"number": 7, "title": "Fix off-by-one"} + }); + let rendered = render_route("pull_request", Some("opened"), payload); + assert!(rendered.contains("acme/widget")); + assert!(rendered.contains("7")); + assert_no_unrendered_placeholders(&rendered, "pr_review (opened)"); + assert_rendered_parses(&rendered, "pr_review (opened)"); +} + +#[test] +fn pr_review_template_renders_and_parses_on_synchronize() { + let payload = serde_json::json!({ + "action": "synchronize", + "repository": {"full_name": "acme/widget"}, + "pull_request": {"number": 7} + }); + let rendered = render_route("pull_request", Some("synchronize"), payload); + assert_no_unrendered_placeholders(&rendered, "pr_review (synchronize)"); + assert_rendered_parses(&rendered, "pr_review (synchronize)"); +} + +#[test] +fn mention_handler_template_renders_for_issue_comment() { + let payload = serde_json::json!({ + "action": "created", + "repository": {"full_name": "acme/widget"}, + "issue": {"number": 42}, + "comment": { + "id": 123456, + "body": "@maintainer can you look at this?", + "user": {"login": "alice"} + } + }); + let rendered = render_route("issue_comment", Some("created"), payload); + assert!(rendered.contains("acme/widget")); + assert!(rendered.contains("alice")); + assert!(rendered.contains("can you look at this")); + assert!( + rendered.contains("issue#42") || rendered.contains("issue 42") || rendered.contains("#42") + ); + assert_no_unrendered_placeholders(&rendered, "mention_handler (issue)"); + assert_rendered_parses(&rendered, "mention_handler (issue)"); +} + +#[test] +fn mention_handler_template_renders_for_pr_review_comment() { + let payload = serde_json::json!({ + "action": "created", + "repository": {"full_name": "acme/widget"}, + "pull_request": {"number": 88}, + "comment": { + "id": 999, + "body": "@maintainer please review the auth flow", + "user": {"login": "bob"} + } + }); + let rendered = render_route("pull_request_review_comment", Some("created"), payload); + assert!(rendered.contains("bob")); + assert!(rendered.contains("pr")); + assert!(rendered.contains("88")); + assert_no_unrendered_placeholders(&rendered, "mention_handler (pr)"); + assert_rendered_parses(&rendered, "mention_handler (pr)"); +} + +#[test] +fn health_check_template_renders_and_parses_on_workflow_dispatch() { + let payload = serde_json::json!({ + "repository": {"full_name": "acme/widget"} + }); + let rendered = render_route("workflow_dispatch", None, payload); + assert!(rendered.contains("acme/widget")); + assert_no_unrendered_placeholders(&rendered, "health_check"); + assert_rendered_parses(&rendered, "health_check"); +} + +#[test] +fn health_check_template_renders_and_parses_on_schedule() { + let payload = serde_json::json!({ + "repository": {"full_name": "acme/widget"} + }); + let rendered = render_route("schedule", None, payload); + assert_no_unrendered_placeholders(&rendered, "health_check (schedule)"); + assert_rendered_parses(&rendered, "health_check (schedule)"); +} + +#[test] +fn every_template_substitution_has_a_template_placeholder() { + let config = load_dispatch_config(); + for route in &config.routes { + let template_path = project_root() + .join("examples/project-maintainer") + .join(&config.templates_dir) + .join(&route.template); + let template_text = fs::read_to_string(&template_path) + .unwrap_or_else(|err| panic!("could not read {}: {err}", template_path.display())); + for placeholder in route.fields.keys() { + let token = format!("{{{{{placeholder}}}}}"); + let token_padded = format!("{{{{ {placeholder} }}}}"); + assert!( + template_text.contains(&token) || template_text.contains(&token_padded), + "route '{}' declares field '{}' but template '{}' does not reference it", + route.event, + placeholder, + route.template + ); + } + } +} + +#[test] +fn every_template_placeholder_has_a_route_substitution() { + let config = load_dispatch_config(); + let mut templates_seen: HashMap<String, &str> = HashMap::new(); + for route in &config.routes { + let placeholders: Vec<String> = route.fields.keys().cloned().collect(); + templates_seen + .entry(route.template.clone()) + .or_insert_with(|| route.event.as_str()); + + let template_path = project_root() + .join("examples/project-maintainer") + .join(&config.templates_dir) + .join(&route.template); + let template_text = fs::read_to_string(&template_path).unwrap(); + let referenced = extract_placeholders(&template_text); + for name in &referenced { + assert!( + placeholders.contains(name), + "template '{}' uses {{{{ {} }}}} but route for event '{}' doesn't supply it", + route.template, + name, + route.event + ); + } + } + assert!(!templates_seen.is_empty()); +} + +fn extract_placeholders(template: &str) -> Vec<String> { + let mut names = Vec::new(); + let mut rest = template; + while let Some(start) = rest.find("{{") { + let after = &rest[start + 2..]; + let Some(end) = after.find("}}") else { + break; + }; + let raw = after[..end].trim().to_string(); + if !raw.is_empty() + && raw.chars().all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' + }) + { + names.push(raw); + } + rest = &after[end + 2..]; + } + names.sort(); + names.dedup(); + names +} diff --git a/crates/machine/Cargo.toml b/crates/machine/Cargo.toml index 9e10f104..894c1a09 100644 --- a/crates/machine/Cargo.toml +++ b/crates/machine/Cargo.toml @@ -2,15 +2,24 @@ name = "machine" version = "0.1.0" edition = "2024" -description = "CM — Context Machine core primitives" +publish = false +description = "RCM machine — core context primitives" + +[package.metadata.cargo-machete] +# Imported through its Rust crate name (`rig`), so source scanning cannot map +# the dependency key back to `rig-core`. +ignored = ["rig-core"] [dependencies] -utils = { path = "../utils" } +utils = { version = "0.1.0", path = "../utils" } tokio = { workspace = true } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -rig = { workspace = true } -tracing = "0.1" +serde = { workspace = true } +serde_json = { workspace = true } +rig-core = { version = "0.36", features = ["reqwest", "rustls"] } +http = "1" +tracing = { workspace = true } +base64 = "0.22" +futures-util = "0.3" [dev-dependencies] -tokio = { version = "1", features = ["rt", "macros"] } +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/crates/machine/src/completion.rs b/crates/machine/src/completion.rs index 6b4d8e64..27fb4dab 100644 --- a/crates/machine/src/completion.rs +++ b/crates/machine/src/completion.rs @@ -1,12 +1,51 @@ +//! LLM completion driver. +//! +//! ## Contract +//! +//! [`send`] constructs a [`rig::completion::CompletionRequest`] directly from +//! the encoded fragments. The `chat_history` slot preserves their order with +//! no rotation, placeholder, or fabricated "initial prompt". Adjacent system +//! fragments are joined into one system message because OpenAI-compatible +//! gateways commonly optimize or validate a single leading system turn. +//! +//! ### Why direct construction (vs. the builder) +//! +//! `rig`'s `CompletionRequestBuilder` treats one message as the "prompt" and +//! [appends it to the END of `chat_history`][build_appends_prompt] at +//! `build()` time. Picking which message is the prompt has been a persistent +//! source of bugs: +//! +//! - **#43 family**: using `Message::user(".")` as a stub prompt caused the +//! LLM to interpret the bare period as a user reply, leading to +//! `"looks like you sent a period"` hallucination loops. +//! - **#86 / #88**: switching the stub to `messages[0]` rotated the leading +//! system message to the END of the request — `[Sys, User]` became +//! `[User, Sys]` on the wire. +//! +//! `CompletionRequest` has no `prompt` field; `chat_history: OneOrMany<Message>` +//! is literally what gets serialized. [`OneOrMany::many`] errors on an empty +//! iterator, so the degenerate "empty context" case surfaces as a `Fragment::hitch` +//! Result rather than a fabricated message. +//! +//! [build_appends_prompt]: https://docs.rs/rig-core/0.36/rig/completion/struct.CompletionRequest.html + +use http::{HeaderMap, HeaderName, HeaderValue}; use rig::OneOrMany; use rig::client::CompletionClient; -use rig::completion::{AssistantContent, CompletionModel, Message, ToolDefinition}; +use rig::completion::message::Reasoning; +use rig::completion::message::UserContent; +use rig::completion::{ + AssistantContent, CompletionError, CompletionModel, CompletionRequest, Message, + ToolDefinition as RigToolDefinition, +}; +use rig::http_client; use tokio::time::{Duration, timeout}; use crate::context::Context; use crate::fragment::{Content, Fragment, Role}; use crate::model::{Model, Protocol}; use crate::resources::Resources; +use crate::usage::TokenUsage; use tracing::{debug, warn}; /// The bare model name to send on the wire. When a `Model` is constructed via @@ -22,30 +61,31 @@ fn wire_name(model: &Model) -> &str { } /// Call the active LLM and return the response fragments or an error. -/// -/// Dispatches by `Protocol` (3 arms) to the corresponding rig module. -/// `endpoint` optionally overrides the provider's default base URL. -/// -/// On failure, returns `Content::Hitch` so the caller (Policy) can -/// decide to retry, switch model, or abort. If no model is registered or -/// active, returns a hitch instead of panicking. -pub async fn complete(ctx: &Context, resources: &Resources) -> Vec<Fragment> { +pub async fn complete( + ctx: &Context, + resources: &Resources, + overlay: &crate::overlay::Overlay, +) -> (Vec<Fragment>, TokenUsage) { let Some(model) = resources.active_model() else { - warn!("no active model registered"); - return vec![Fragment::hitch( - "no active model registered; configure a provider via --model or set a provider env var", - )]; + warn!("completion requested but no active model is set"); + let hitch = Fragment::hitch( + "no active model set; register and activate a model before completing", + None, + Role::System, + None::<&str>, + ); + return (vec![hitch], TokenUsage::empty()); }; - let messages: Vec<Message> = ctx.fragments().iter().filter_map(encode).collect(); + let messages = assemble_messages(ctx.fragments(), model.thinking, overlay); - let tools: Vec<ToolDefinition> = resources - .active_tools() + let tools: Vec<RigToolDefinition> = resources + .active_tool_definitions() .iter() - .map(|t| ToolDefinition { - name: t.name().to_string(), - description: t.description().to_string(), - parameters: t.parameters(), + .map(|definition| RigToolDefinition { + name: definition.name.clone(), + description: definition.description.clone(), + parameters: definition.parameters.clone(), }) .collect(); @@ -62,10 +102,13 @@ pub async fn complete(ctx: &Context, resources: &Resources) -> Vec<Fragment> { let result = match model.protocol { Protocol::OpenAI => { - let mut builder = rig::providers::openai::CompletionsClient::builder().api_key(api_key); + let mut builder = rig::providers::openai::CompletionsClient::builder() + .api_key(api_key) + .http_client(openai_http_client()); if let Some(endpoint) = endpoint_url { builder = builder.base_url(endpoint); } + builder = apply_headers(builder, &model.headers); let endpoint = builder .build() .expect("failed to build openai client") @@ -77,6 +120,7 @@ pub async fn complete(ctx: &Context, resources: &Resources) -> Vec<Fragment> { if let Some(endpoint) = endpoint_url { builder = builder.base_url(endpoint); } + builder = apply_headers(builder, &model.headers); let endpoint = builder .build() .expect("failed to build anthropic client") @@ -88,6 +132,7 @@ pub async fn complete(ctx: &Context, resources: &Resources) -> Vec<Fragment> { if let Some(endpoint) = endpoint_url { builder = builder.base_url(endpoint); } + builder = apply_headers(builder, &model.headers); let endpoint = builder .build() .expect("failed to build gemini client") @@ -97,7 +142,7 @@ pub async fn complete(ctx: &Context, resources: &Resources) -> Vec<Fragment> { }; match result { - Ok(choice) => { + Ok((choice, usage)) => { let text_fragments = choice .iter() .filter(|c| matches!(c, AssistantContent::Text(_))) @@ -107,82 +152,471 @@ pub async fn complete(ctx: &Context, resources: &Resources) -> Vec<Fragment> { .filter(|c| matches!(c, AssistantContent::ToolCall(_))) .count(); debug!(text_fragments, tool_calls, "completion response"); - decode(choice.iter()) + (decode(choice.iter()), usage) } Err(hitch) => { warn!(?hitch, "completion failed"); - vec![hitch] + (vec![hitch], TokenUsage::empty()) + } + } +} + +/// Build the transport used for OpenAI-compatible completion endpoints. +/// +/// Some compatible gateways advertise HTTP/2 but time out on a later, larger +/// tool-result request even though the same payload completes over HTTP/1.1. +/// RCM completion calls are independent, so keeping idle upstream connections +/// provides little benefit and makes gateway behaviour less deterministic. +/// Force HTTP/1.1 and close idle connections between calls. +fn openai_http_client() -> http_client::ReqwestClient { + http_client::ReqwestClient::builder() + .http1_only() + .pool_max_idle_per_host(0) + .build() + .expect("failed to build openai HTTP client") +} + +/// Encode the context while preserving provider-valid retry and tool-call turns. +/// +/// Assistant hitches are transport failures, not model output. Keeping one in +/// chat history would turn a retry into a new prompt containing the gateway +/// error instead of replaying the failed request. Tool hitches remain encodable +/// because they are real tool results the model may need to correct. +/// +/// A DeepSeek response may contain visible assistant text and one or more tool +/// calls with one shared reasoning block. The machine tape stores text as its +/// own fragment and each call beside its result for execution, so this function +/// reconstructs the original assistant turn before sending the next completion +/// request. +pub fn encode_context(fragments: &[Fragment], thinking: bool) -> Vec<Message> { + let mut messages = Vec::with_capacity(fragments.len()); + let mut index = 0; + + while index < fragments.len() { + let fragment = &fragments[index]; + + if fragment.role == Role::Assistant && matches!(fragment.content, Content::Hitch { .. }) { + index += 1; + continue; + } + + let mut tool_start = index; + let mut leading_text = Vec::new(); + while let Some(candidate) = fragments.get(tool_start) { + let Content::Text(text) = &candidate.content else { + break; + }; + if candidate.role != Role::Assistant { + break; + } + leading_text.push(AssistantContent::text(&text.text)); + tool_start += 1; + } + + let Some(tool_fragment) = fragments.get(tool_start) else { + if let Some(message) = encode(fragment, thinking) { + messages.push(message); + } + index += 1; + continue; + }; + let Content::ToolCall(first_call) = &tool_fragment.content else { + if let Some(message) = encode(fragment, thinking) { + messages.push(message); + } + index += 1; + continue; + }; + if tool_fragment.role != Role::Assistant { + if let Some(message) = encode(fragment, thinking) { + messages.push(message); + } + index += 1; + continue; } + let Some(shared_reasoning) = first_call.reasoning.as_deref() else { + if let Some(message) = encode(fragment, thinking) { + messages.push(message); + } + index += 1; + continue; + }; + + let mut assistant_content = leading_text; + let mut tool_results = Vec::new(); + let mut tool_call_count = 0; + let mut cursor = tool_start; + + while let Some(candidate) = fragments.get(cursor) { + let Content::ToolCall(call) = &candidate.content else { + break; + }; + if candidate.role != Role::Assistant + || call.reasoning.as_deref() != Some(shared_reasoning) + { + break; + } + + assistant_content.push(AssistantContent::tool_call( + &call.id, + &call.name, + call.arguments.clone(), + )); + tool_call_count += 1; + cursor += 1; + + let Some(result_fragment) = fragments.get(cursor) else { + break; + }; + let result_call_id = match &result_fragment.content { + Content::ToolResult(result) => Some(result.call_id.as_str()), + Content::Hitch { + call_id: Some(call_id), + .. + } => Some(call_id.as_str()), + _ => None, + }; + if result_fragment.role != Role::Tool || result_call_id != Some(call.id.as_str()) { + break; + } + if let Some(message) = encode(result_fragment, thinking) { + tool_results.push(message); + } + cursor += 1; + } + + let has_leading_text = tool_start > index; + if (has_leading_text || tool_call_count > 1) && tool_call_count == tool_results.len() { + assistant_content.push(AssistantContent::Reasoning(Reasoning::new( + shared_reasoning, + ))); + let content = OneOrMany::many(assistant_content) + .expect("parallel tool-call turn always contains at least two calls"); + messages.push(Message::Assistant { id: None, content }); + messages.extend(tool_results); + index = cursor; + continue; + } + + if let Some(message) = encode(fragment, thinking) { + messages.push(message); + } + index += 1; } + + messages } -// ── Internal ── +/// Assemble the full request message list: overlay prefix, encoded tape, +/// overlay tail. With an empty overlay this is exactly `encode_context` — +/// the golden-equivalence property the overlay layer must preserve. +pub fn assemble_messages( + fragments: &[Fragment], + thinking: bool, + overlay: &crate::overlay::Overlay, +) -> Vec<Message> { + let mut messages = + Vec::with_capacity(overlay.system_prefix.len() + overlay.tail.len() + fragments.len()); + for prefix in &overlay.system_prefix { + messages.push(Message::system(prefix.clone())); + } + messages.extend(encode_context(fragments, thinking)); + for tail in &overlay.tail { + messages.push(Message::system(tail.clone())); + } + messages +} -/// Send a request with a deadline. Returns an error if exceeding `model.timeout` seconds. +// `Fragment` as the error type is intentional (see `build_request` below); +// newer clippy (1.98+) flags its size — same waiver, same rationale. +#[allow(clippy::result_large_err)] async fn send( endpoint: &impl CompletionModel, model: &Model, messages: &[Message], - tools: &[ToolDefinition], -) -> Result<OneOrMany<AssistantContent>, Fragment> { - let mut request = endpoint - .completion_request(Message::user("")) - .messages(messages.to_vec()) - .tools(tools.to_vec()); - - if let Some(temp) = model.temperature { - request = request.temperature(temp); - } - if let Some(limit) = &model.limit { - request = request.max_tokens(limit.output); + tools: &[RigToolDefinition], +) -> Result<(OneOrMany<AssistantContent>, TokenUsage), Fragment> { + let request = build_request(messages, tools, model)?; + + match timeout( + Duration::from_secs(model.timeout), + endpoint.completion(request), + ) + .await + { + Ok(Ok(response)) => Ok(( + response.choice, + TokenUsage { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + total_tokens: response.usage.total_tokens, + cached_input_tokens: response.usage.cached_input_tokens, + cache_creation_input_tokens: response.usage.cache_creation_input_tokens, + }, + )), + Ok(Err(error)) => { + let code = match &error { + CompletionError::HttpError(http_client::Error::InvalidStatusCode(s)) => { + Some(s.as_u16()) + } + CompletionError::HttpError(http_client::Error::InvalidStatusCodeWithMessage( + s, + _, + )) => Some(s.as_u16()), + _ => None, + }; + Err(Fragment::hitch( + error.to_string(), + code, + Role::Assistant, + None::<&str>, + )) + } + Err(_) => Err(Fragment::hitch( + format!("request timed out after {}s", model.timeout), + None, + Role::Assistant, + None::<&str>, + )), } +} + +/// Construct a `CompletionRequest` whose `chat_history` is the encoded +/// fragments **in their original order**. +/// +/// Returns a `Fragment::hitch` when `messages` is empty — an empty context +/// has no meaningful prompt to send to the LLM, and we refuse to fabricate +/// one (see module-level contract). +/// +/// `Fragment` is the unified error type — boxing would lose the +/// ergonomics of returning `Fragment::hitch(...)` directly. The size is +/// accepted intentionally. +#[allow(clippy::result_large_err)] +pub fn build_request( + messages: &[Message], + tools: &[RigToolDefinition], + model: &Model, +) -> Result<CompletionRequest, Fragment> { + let normalized_messages = merge_adjacent_system_messages(messages); + let chat_history = OneOrMany::many(normalized_messages).map_err(|_| { + Fragment::hitch( + "completion called with empty context — no messages to send to LLM", + None, + Role::System, + None::<&str>, + ) + })?; - match timeout(Duration::from_secs(model.timeout), request.send()).await { - Ok(Ok(response)) => Ok(response.choice), - Ok(Err(error)) => Err(Fragment::hitch(format!("{}", error))), - Err(_) => Err(Fragment::hitch(format!( - "request timed out after {}s", - model.timeout - ))), + Ok(CompletionRequest { + model: None, + preamble: None, + chat_history, + documents: Vec::new(), + tools: tools.to_vec(), + temperature: model.temperature, + max_tokens: model.limit.as_ref().map(|l| l.output), + tool_choice: None, + additional_params: None, + output_schema: None, + }) +} + +fn merge_adjacent_system_messages(messages: &[Message]) -> Vec<Message> { + let mut normalized = Vec::with_capacity(messages.len()); + for message in messages { + if let Message::System { content } = message + && let Some(Message::System { + content: previous_content, + }) = normalized.last_mut() + { + previous_content.push_str("\n\n"); + previous_content.push_str(content); + continue; + } + normalized.push(message.clone()); } + normalized } -/// Decode rig assistant content into fragments. -fn decode<'a>(choice: impl Iterator<Item = &'a AssistantContent>) -> Vec<Fragment> { +pub fn decode<'a>(choice: impl Iterator<Item = &'a AssistantContent>) -> Vec<Fragment> { + use rig::completion::message::MimeType; let mut fragments = Vec::new(); + // Thinking-mode providers (DeepSeek, Kimi) emit one reasoning block per + // assistant turn and require it to be echoed back on every assistant + // message we resend from that turn. A turn may contain multiple parallel + // tool_calls — we replicate the same reasoning onto each of them, so the + // turn survives being split into one Fragment per call. + // + // A completion response is one assistant turn. Providers may emit visible + // text before a tool call in that same turn, so its reasoning must remain + // available until every tool call in the response has been decoded. + let mut pending_reasoning: Vec<String> = Vec::new(); for content in choice { match content { AssistantContent::Text(text) => { fragments.push(Fragment::assistant(&text.text)); } + AssistantContent::Reasoning(reasoning) => { + let text = reasoning.display_text(); + if !text.is_empty() { + pending_reasoning.push(text); + } + } AssistantContent::ToolCall(tc) => { - fragments.push(Fragment::tool_call( - &tc.id, - &tc.function.name, - tc.function.arguments.clone(), - )); + let mut frag = + Fragment::tool_call(&tc.id, &tc.function.name, tc.function.arguments.clone()); + if !pending_reasoning.is_empty() { + frag = frag.with_reasoning(pending_reasoning.join("\n")); + } + fragments.push(frag); + } + AssistantContent::Image(content_image) => { + let source = match &content_image.data { + rig::completion::message::DocumentSourceKind::Url(url) => { + crate::fragment::DataSource::Url(url.clone()) + } + rig::completion::message::DocumentSourceKind::Base64(data) => { + crate::fragment::DataSource::Base64(data.clone()) + } + _ => { + warn!( + "unrecognized DocumentSourceKind in Image, falling back to empty data" + ); + crate::fragment::DataSource::String(String::new()) + } + }; + let media_type = content_image + .media_type + .as_ref() + .map(|m| m.to_mime_type().to_string()); + fragments.push(Fragment::image(source, media_type)); } - _ => {} } } fragments } +fn apply_headers<Ext, ApiKey, H>( + mut builder: rig::client::ClientBuilder<Ext, ApiKey, H>, + headers: &Option<std::collections::HashMap<String, String>>, +) -> rig::client::ClientBuilder<Ext, ApiKey, H> +where + Ext: Clone, +{ + if let Some(headers_map) = headers { + let mut header_map = HeaderMap::new(); + for (key, value) in headers_map { + if let (Ok(name), Ok(val)) = ( + HeaderName::from_bytes(key.as_bytes()), + HeaderValue::from_str(value), + ) { + header_map.insert(name, val); + } + } + if !header_map.is_empty() { + builder = builder.http_headers(header_map); + } + } + builder +} + +/// Resolve a `fragment::DataSource` into a rig `UserContent`, converting +/// `Raw` bytes to Base64. +fn encode_user_content( + kind: &str, + source: &crate::fragment::DataSource, + media_type: &Option<String>, +) -> UserContent { + use crate::fragment::DataSource; + use base64::Engine as _; + use rig::completion::message::MimeType; + use rig::completion::message::{AudioMediaType, DocumentMediaType, ImageMediaType}; + + let image_mime = media_type + .as_deref() + .and_then(ImageMediaType::from_mime_type); + let audio_mime = media_type + .as_deref() + .and_then(AudioMediaType::from_mime_type); + let doc_mime = media_type + .as_deref() + .and_then(DocumentMediaType::from_mime_type); + + match source { + DataSource::Url(url) => UserContent::image_url(url.clone(), image_mime, None), + DataSource::Base64(data) | DataSource::String(data) => match kind { + "audio" => UserContent::audio(data.clone(), audio_mime), + "document" => UserContent::document(data.clone(), doc_mime), + _ => UserContent::image_base64(data.clone(), image_mime, None), + }, + DataSource::Raw(bytes) => { + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + match kind { + "audio" => UserContent::audio(b64, audio_mime), + _ => UserContent::image_base64(b64, image_mime, None), + } + } + } +} + /// Encode a context fragment into a rig message. -fn encode(frag: &Fragment) -> Option<Message> { +pub fn encode(frag: &Fragment, thinking: bool) -> Option<Message> { match frag.role { - Role::System => frag.as_text().map(Message::system), - Role::User => frag.as_text().map(Message::user), + Role::System => { + if let Content::Hitch { message, .. } = &frag.content { + Some(Message::system(message)) + } else { + frag.as_text().map(Message::system) + } + } + Role::User => match &frag.content { + Content::Image(img) => Some(Message::User { + content: OneOrMany::one(encode_user_content("image", &img.source, &img.media_type)), + }), + Content::Audio(audio) => Some(Message::User { + content: OneOrMany::one(encode_user_content( + "audio", + &audio.source, + &audio.media_type, + )), + }), + Content::Video(video) => Some(Message::User { + content: OneOrMany::one(encode_user_content( + "video", + &video.source, + &video.media_type, + )), + }), + Content::Document(document) => Some(Message::User { + content: OneOrMany::one(encode_user_content( + "document", + &document.source, + &document.media_type, + )), + }), + _ => frag.as_text().map(Message::user), + }, Role::Assistant => { if let Content::ToolCall(tc) = &frag.content { - Some(Message::Assistant { - id: None, - content: OneOrMany::one(AssistantContent::tool_call( - &tc.id, - &tc.name, - tc.arguments.clone(), - )), - }) + let mut content = OneOrMany::one(AssistantContent::tool_call( + &tc.id, + &tc.name, + tc.arguments.clone(), + )); + // Echo the model's original reasoning when present — thinking-mode + // providers (DeepSeek, Kimi) validate that assistant tool-call + // turns carry the same `reasoning_content` they previously emitted. + // Falls back to a single-dot placeholder only when `thinking=true` + // and no reasoning was captured (legacy Kimi path where the model + // accepts any non-empty reasoning). + if let Some(reasoning_text) = tc.reasoning.as_deref() { + content.push(AssistantContent::Reasoning(Reasoning::new(reasoning_text))); + } else if thinking { + content.push(AssistantContent::Reasoning(Reasoning::new("."))); + } + Some(Message::Assistant { id: None, content }) + } else if let Content::Hitch { message, .. } = &frag.content { + Some(Message::assistant(message)) } else { frag.as_text().map(Message::assistant) } @@ -190,9 +624,24 @@ fn encode(frag: &Fragment) -> Option<Message> { Role::Tool => { if let Content::ToolResult(tr) = &frag.content { Some(Message::tool_result(&tr.call_id, &tr.content)) + } else if let Content::Hitch { + message, + call_id: Some(call_id), + .. + } = &frag.content + { + Some(Message::tool_result(call_id, message)) } else { None } } } } + +#[cfg(test)] +mod transport_tests { + #[test] + fn openai_http_client_builds_with_gateway_safe_settings() { + let _client = super::openai_http_client(); + } +} diff --git a/crates/machine/src/context.rs b/crates/machine/src/context.rs index a9013868..1644426a 100644 --- a/crates/machine/src/context.rs +++ b/crates/machine/src/context.rs @@ -1,14 +1,24 @@ +use serde::{Deserialize, Serialize}; + use crate::fragment::Fragment; -/// The machine's context — an ordered sequence of fragments. -/// -/// Each fragment is assigned a unique `id` when stored. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Context { cells: Vec<Fragment>, next_id: u64, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextIdNotFound(pub u64); + +impl std::fmt::Display for ContextIdNotFound { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "fragment id {} not found in context", self.0) + } +} + +impl std::error::Error for ContextIdNotFound {} + impl Default for Context { fn default() -> Self { Self::new() @@ -16,7 +26,6 @@ impl Default for Context { } impl Context { - /// Create an empty context. The first assigned id will be 1. pub fn new() -> Self { Self { cells: Vec::new(), @@ -24,72 +33,60 @@ impl Context { } } - /// Append a fragment to the end, assigning it a fresh id. - /// - /// Returns the assigned id. - pub fn append(&mut self, mut fragment: Fragment) -> u64 { - let id = self.assign_id(&mut fragment); - self.cells.push(fragment); + pub fn append(&mut self, fragment: Fragment) -> u64 { + let id = self.next_id; + self.append_with_id(id, fragment); id } - /// Insert a fragment after the cell with the given id. - /// - /// Returns the assigned id of the new fragment. - /// - /// # Panics - /// - /// Panics if `after` is not found in the context. - pub fn insert(&mut self, after: u64, mut fragment: Fragment) -> u64 { - let pos = self - .position_of(after) - .unwrap_or_else(|| panic!("id {} not found in context", after)); - let new_id = self.assign_id(&mut fragment); - self.cells.insert(pos + 1, fragment); - new_id - } - - /// Replace the fragment at the given id, preserving the id. - /// - /// # Panics - /// - /// Panics if `id` is not found in the context. - pub fn replace(&mut self, id: u64, mut fragment: Fragment) { - let pos = self - .position_of(id) - .unwrap_or_else(|| panic!("id {} not found in context", id)); + pub fn append_with_id(&mut self, id: u64, mut fragment: Fragment) { + self.assign_specific_id(id, &mut fragment); + self.cells.push(fragment); + } + + pub fn insert(&mut self, after: u64, fragment: Fragment) -> Result<u64, ContextIdNotFound> { + let new_id = self.next_id; + self.insert_with_id(after, new_id, fragment)?; + Ok(new_id) + } + + pub fn insert_with_id( + &mut self, + after: u64, + id: u64, + mut fragment: Fragment, + ) -> Result<(), ContextIdNotFound> { + let position = self.position_of(after).ok_or(ContextIdNotFound(after))?; + self.assign_specific_id(id, &mut fragment); + self.cells.insert(position + 1, fragment); + Ok(()) + } + + pub fn replace(&mut self, id: u64, mut fragment: Fragment) -> Result<(), ContextIdNotFound> { + let position = self.position_of(id).ok_or(ContextIdNotFound(id))?; fragment.id = id; - self.cells[pos] = fragment; - } - - /// Remove the fragment with the given id. - /// - /// # Panics - /// - /// Panics if `id` is not found in the context. - pub fn remove(&mut self, id: u64) { - let pos = self - .position_of(id) - .unwrap_or_else(|| panic!("id {} not found in context", id)); - self.cells.remove(pos); - } - - /// Swap the positions of two fragments by id. - /// - /// # Panics - /// - /// Panics if either id is not found in the context. - pub fn swap(&mut self, id1: u64, id2: u64) { - let i = self - .position_of(id1) - .unwrap_or_else(|| panic!("id {} not found in context", id1)); - let j = self - .position_of(id2) - .unwrap_or_else(|| panic!("id {} not found in context", id2)); - self.cells.swap(i, j); - } - - /// Clear all fragments. Id allocation continues (avoids collisions with external references). + self.cells[position] = fragment; + self.next_id = self.next_id.max(id + 1); + Ok(()) + } + + pub fn remove(&mut self, id: u64) -> Result<(), ContextIdNotFound> { + let position = self.position_of(id).ok_or(ContextIdNotFound(id))?; + self.cells.remove(position); + Ok(()) + } + + pub fn swap(&mut self, first_id: u64, second_id: u64) -> Result<(), ContextIdNotFound> { + let first_position = self + .position_of(first_id) + .ok_or(ContextIdNotFound(first_id))?; + let second_position = self + .position_of(second_id) + .ok_or(ContextIdNotFound(second_id))?; + self.cells.swap(first_position, second_position); + Ok(()) + } + pub fn clear(&mut self) { self.cells.clear(); } @@ -102,35 +99,28 @@ impl Context { self.cells.is_empty() } - /// All fragments in insertion order. pub fn fragments(&self) -> &[Fragment] { &self.cells } - /// Find the index of a fragment by id. pub fn position_of(&self, id: u64) -> Option<usize> { self.cells.iter().position(|cell| cell.id == id) } - /// Get a fragment by id. pub fn get(&self, id: u64) -> Option<&Fragment> { - self.position_of(id).map(|i| &self.cells[i]) + self.position_of(id).map(|index| &self.cells[index]) } - /// Get a mutable reference to a fragment by id. pub fn get_mut(&mut self, id: u64) -> Option<&mut Fragment> { - self.position_of(id).map(|i| &mut self.cells[i]) + self.position_of(id).map(|index| &mut self.cells[index]) } - /// The next id that will be assigned. pub fn next_id(&self) -> u64 { self.next_id } - fn assign_id(&mut self, fragment: &mut Fragment) -> u64 { - let id = self.next_id; + fn assign_specific_id(&mut self, id: u64, fragment: &mut Fragment) { fragment.id = id; - self.next_id += 1; - id + self.next_id = self.next_id.max(id + 1); } } diff --git a/crates/machine/src/env.rs b/crates/machine/src/env.rs index 05db6eba..d9c6e52d 100644 --- a/crates/machine/src/env.rs +++ b/crates/machine/src/env.rs @@ -22,6 +22,10 @@ pub struct Environment { pub vars: HashMap<String, String>, pub root: Option<PathBuf>, pub platform: String, + /// Override for the run directory. When set, injected into the env context + /// fragment and exported as `RCM_RUN_DIR` for subprocess/shell tools. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub run_dir: Option<PathBuf>, } impl Environment { @@ -29,11 +33,40 @@ impl Environment { &self.id } + /// An honest snapshot of the host the agent is currently running on: + /// inherits process env vars and the host platform tag. + /// + /// For sandboxed scenarios where the agent should not see host state, use + /// [`Environment::empty`]. pub fn new(cwd: impl Into<PathBuf>) -> Self { Self::named("environment", cwd) } + /// Same as [`new`] but with an explicit name. Inherits host env vars. pub fn named(name: impl Into<String>, cwd: impl Into<PathBuf>) -> Self { + Self { + id: EnvironmentId::new(), + name: Name::new(name).expect("environment name must be valid"), + cwd: cwd.into(), + vars: std::env::vars().collect(), + root: None, + platform: std::env::consts::OS.to_string(), + run_dir: None, + } + } + + /// A deliberately empty environment for sandbox scenarios — no inherited + /// env vars, no platform tag (defaults to the host OS string for + /// compatibility but env vars stay empty). + /// + /// Callers that want to lie about the platform too should set the field + /// directly after construction. + pub fn empty(cwd: impl Into<PathBuf>) -> Self { + Self::empty_named("environment", cwd) + } + + /// Same as [`empty`] but with an explicit name. + pub fn empty_named(name: impl Into<String>, cwd: impl Into<PathBuf>) -> Self { Self { id: EnvironmentId::new(), name: Name::new(name).expect("environment name must be valid"), @@ -41,6 +74,7 @@ impl Environment { vars: HashMap::new(), root: None, platform: std::env::consts::OS.to_string(), + run_dir: None, } } } diff --git a/crates/machine/src/event.rs b/crates/machine/src/event.rs index cf7f1678..fb2721e5 100644 --- a/crates/machine/src/event.rs +++ b/crates/machine/src/event.rs @@ -1,5 +1,85 @@ use crate::fragment::{Content, Fragment, Role}; +/// Sanitized metadata describing one model completion attempt. +/// +/// The classification intentionally contains no provider response text. It is +/// safe for external stream consumers and operational logs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompletionTelemetry { + pub outcome: &'static str, + pub http_status: Option<u16>, + pub failure_kind: Option<&'static str>, + pub retryable: Option<bool>, +} + +/// Classify the completion result without exposing hitch content. +pub fn completion_telemetry(fragments: &[Fragment]) -> CompletionTelemetry { + let failure = fragments + .iter() + .find_map(|fragment| match &fragment.content { + Content::Hitch { message, code, .. } => Some((fragment.role, message.as_str(), *code)), + _ => None, + }); + let Some((role, message, http_status)) = failure else { + return CompletionTelemetry { + outcome: "success", + http_status: None, + failure_kind: None, + retryable: None, + }; + }; + + let (failure_kind, retryable) = classify_completion_failure(role, message, http_status); + CompletionTelemetry { + outcome: "failure", + http_status, + failure_kind: Some(failure_kind), + retryable: Some(retryable), + } +} + +fn classify_completion_failure( + role: Role, + message: &str, + http_status: Option<u16>, +) -> (&'static str, bool) { + if let Some(status) = http_status { + return match status { + 401 | 403 => ("authentication", false), + 408 => ("timeout", true), + 425 => ("provider_unavailable", true), + 429 => ("rate_limited", true), + 500..=599 => ("provider_error", true), + 400..=499 => ("invalid_request", false), + _ => ("http_error", false), + }; + } + + if role == Role::System { + return ("configuration", false); + } + + let normalized = message.to_ascii_lowercase(); + if normalized.contains("timed out") || normalized.contains("timeout") { + return ("timeout", true); + } + if [ + "connection", + "network", + "dns", + "transport", + "error sending request", + "request error", + ] + .iter() + .any(|marker| normalized.contains(marker)) + { + return ("network", true); + } + + ("unknown", false) +} + /// Generate a short preview of a fragment's content (max 60 chars). pub(crate) fn preview(frag: &Fragment) -> String { match &frag.content { @@ -14,7 +94,7 @@ pub(crate) fn preview(frag: &Fragment) -> String { } } -pub(crate) fn role_name(role: Role) -> &'static str { +pub fn role_name(role: Role) -> &'static str { match role { Role::System => "system", Role::User => "user", diff --git a/crates/machine/src/fragment.rs b/crates/machine/src/fragment.rs index 508b4906..10d48a92 100644 --- a/crates/machine/src/fragment.rs +++ b/crates/machine/src/fragment.rs @@ -65,6 +65,15 @@ pub struct ToolCall { pub id: String, pub name: String, pub arguments: Value, + /// Reasoning text the model emitted alongside this tool call. + /// + /// Thinking-mode providers (DeepSeek, Kimi Coding, …) return reasoning + /// content on assistant turns and **require it to be echoed back** on + /// subsequent requests that include this turn as history. Captured by + /// `completion::decode` from `AssistantContent::Reasoning` and re-emitted + /// by `completion::encode`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning: Option<String>, } /// Outcome of a tool execution. @@ -88,21 +97,18 @@ pub enum Content { Document(Document), ToolCall(ToolCall), ToolResult(ToolResult), - /// Execution error that Policy may intercept for retry decisions. Hitch { message: String, - retryable: bool, + /// ID of the originating tool call, if this hitch is a tool error. + call_id: Option<String>, + /// HTTP status code for retry strategy (Policy decides permanent vs transient). code: Option<u16>, }, } /// A symbol on the context tape. /// -/// `id` is assigned by [`Context`](crate::Context) on storage and should not be -/// set directly — mutating the id would break the context's internal invariants. -/// The `role`, `tag`, and `content` fields are public but effectively read-only -/// after construction; the only way to modify a fragment in a context is through -/// [`Action`](crate::Action) variants applied by [`Machine`](crate::Machine). +/// `id` is assigned by [`Context`](crate::Context) on storage. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Fragment { pub(crate) id: u64, @@ -162,15 +168,28 @@ impl Fragment { } /// Creates a [`Content::Hitch`] fragment. - pub fn hitch(message: impl Into<String>) -> Self { + /// + /// `code` is an optional HTTP status code from the failed request. + /// `call_id` is the originating tool call ID when this hitch represents a + /// tool error; required for `completion::encode` to emit a valid + /// `role: "tool"` message. + /// `code` and `role` convey the failure context for Policy. Role should + /// match the origin: tool errors → [`Role::Tool`], LLM errors → + /// [`Role::Assistant`], system errors → [`Role::System`]. + pub fn hitch( + message: impl Into<String>, + code: Option<u16>, + role: Role, + call_id: Option<impl Into<String>>, + ) -> Self { Self { id: 0, - role: Role::System, + role, tag: "hitch".into(), content: Content::Hitch { message: message.into(), - retryable: false, - code: None, + call_id: call_id.map(Into::into), + code, }, } } @@ -184,10 +203,61 @@ impl Fragment { id: id.into(), name: name.into(), arguments, + reasoning: None, }), } } + /// Attach reasoning text to this fragment when it carries a [`Content::ToolCall`]. + /// + /// No-op on other content variants. Used by `completion::decode` to + /// preserve the model's `reasoning_content` so it can be echoed back on + /// the next request for thinking-mode providers. + pub fn with_reasoning(mut self, reasoning: impl Into<String>) -> Self { + if let Content::ToolCall(ref mut tc) = self.content { + tc.reasoning = Some(reasoning.into()); + } + self + } + + // ── Multi-modal constructors (P2) ── + + pub fn image(source: DataSource, media_type: Option<String>) -> Self { + Self { + id: 0, + role: Role::User, + tag: "image".into(), + content: Content::Image(Image { source, media_type }), + } + } + + pub fn audio(source: DataSource, media_type: Option<String>) -> Self { + Self { + id: 0, + role: Role::User, + tag: "audio".into(), + content: Content::Audio(Audio { source, media_type }), + } + } + + pub fn video(source: DataSource, media_type: Option<String>) -> Self { + Self { + id: 0, + role: Role::User, + tag: "video".into(), + content: Content::Video(Video { source, media_type }), + } + } + + pub fn document(source: DataSource, media_type: Option<String>) -> Self { + Self { + id: 0, + role: Role::User, + tag: "document".into(), + content: Content::Document(Document { source, media_type }), + } + } + /// Assign a custom tag. pub fn with_tag(mut self, tag: impl Into<String>) -> Self { self.tag = tag.into(); @@ -201,4 +271,27 @@ impl Fragment { _ => None, } } + + /// Full text content suitable for external consumption (embedding, logging). + /// + /// Returns the complete text content for all Fragment types: + /// - Text/ToolResult/Hitch: the actual text content + /// - ToolCall: `tool_call: name(arguments)` + /// - Multi-modal (Image, Audio, Video, Document): placeholder tags like + /// `"<image>"`, `"<audio>"`. These are **not** suitable for direct + /// embedding — downstream consumers (RL observations, prompt builders) + /// should replace placeholders with actual content or metadata from + /// the Content variant as needed. + pub fn content_as_text(&self) -> String { + match &self.content { + Content::Text(t) => t.text.clone(), + Content::ToolCall(tc) => format!("tool_call: {}({})", tc.name, tc.arguments), + Content::ToolResult(tr) => tr.content.clone(), + Content::Hitch { message, .. } => message.clone(), + Content::Image(_) => "<image>".into(), + Content::Audio(_) => "<audio>".into(), + Content::Video(_) => "<video>".into(), + Content::Document(_) => "<document>".into(), + } + } } diff --git a/crates/machine/src/inbox.rs b/crates/machine/src/inbox.rs index c37ce4cd..217dda86 100644 --- a/crates/machine/src/inbox.rs +++ b/crates/machine/src/inbox.rs @@ -1,57 +1,83 @@ use std::collections::VecDeque; +use serde::{Deserialize, Serialize}; + use crate::fragment::Fragment; +use crate::usage::CompletionId; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InboxItem { + pub fragment: Fragment, + pub source_completion: Option<CompletionId>, +} + +impl InboxItem { + pub fn new(fragment: Fragment, source_completion: Option<CompletionId>) -> Self { + Self { + fragment, + source_completion, + } + } +} -/// Inbox — the pending queue between reactor and policy. -/// -/// The reactor produces fragments and places them into the inbox. -/// The machine drains them into the context. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Inbox { - fragments: VecDeque<Fragment>, + items: VecDeque<InboxItem>, } impl Inbox { pub fn new() -> Self { Self { - fragments: VecDeque::new(), + items: VecDeque::new(), } } pub fn push(&mut self, fragment: Fragment) { - self.fragments.push_back(fragment); + self.push_item(InboxItem::new(fragment, None)); + } + + pub fn push_item(&mut self, item: InboxItem) { + self.items.push_back(item); } - pub fn pop(&mut self) -> Option<Fragment> { - self.fragments.pop_front() + pub fn extend_items(&mut self, items: impl IntoIterator<Item = InboxItem>) { + self.items.extend(items); } - pub fn peek(&self) -> Option<&Fragment> { - self.fragments.front() + pub fn pop(&mut self) -> Option<InboxItem> { + self.items.pop_front() + } + + pub fn peek(&self) -> Option<&InboxItem> { + self.items.front() } pub fn is_empty(&self) -> bool { - self.fragments.is_empty() + self.items.is_empty() } pub fn len(&self) -> usize { - self.fragments.len() + self.items.len() + } + + pub fn items(&self) -> impl Iterator<Item = &InboxItem> { + self.items.iter() } } impl IntoIterator for Inbox { - type Item = Fragment; - type IntoIter = std::collections::vec_deque::IntoIter<Fragment>; + type Item = InboxItem; + type IntoIter = std::collections::vec_deque::IntoIter<InboxItem>; fn into_iter(self) -> Self::IntoIter { - self.fragments.into_iter() + self.items.into_iter() } } -impl FromIterator<Fragment> for Inbox { - fn from_iter<I: IntoIterator<Item = Fragment>>(iter: I) -> Self { +impl FromIterator<InboxItem> for Inbox { + fn from_iter<Items: IntoIterator<Item = InboxItem>>(items: Items) -> Self { Self { - fragments: VecDeque::from_iter(iter), + items: VecDeque::from_iter(items), } } } diff --git a/crates/machine/src/lib.rs b/crates/machine/src/lib.rs index 1262b529..16572f48 100644 --- a/crates/machine/src/lib.rs +++ b/crates/machine/src/lib.rs @@ -1,4 +1,4 @@ -//! Core primitives for the Context Machine. +//! Core primitives for the Recursive Context Machine. //! //! | Component | Role | //! |-----------|------| @@ -8,10 +8,10 @@ //! | [`Environment`] | External world | //! | [`Resources`] | Available pool | //! | [`Inbox`] | Pending queue | -//! | [`Policy`] | Context engineering | -//! | [`Machine`] | Policy + Reactor composition | +//! | [`Policy`] | Owned by caller | +//! | [`Machine`] | Interpreter that turns actions into recorded effects | -pub(crate) mod completion; +pub mod completion; pub mod context; pub mod env; pub mod event; @@ -20,21 +20,32 @@ pub mod hook; pub mod inbox; pub mod machine; pub mod model; +pub mod obs; +pub mod overlay; pub mod policy; pub mod purpose; pub(crate) mod reactor; +pub mod record; pub mod resources; pub mod tool; +pub mod usage; -pub use context::Context; +pub use context::{Context, ContextIdNotFound}; pub use env::Environment; pub use fragment::{ Audio, Content, DataSource, Document, Fragment, Image, Role, Text, ToolCall, ToolResult, Video, }; -pub use inbox::Inbox; -pub use machine::Machine; -pub use model::{Cost, Limit, Modalities, Modality, Model, Protocol}; -pub use policy::{Action, Phase, PhaseOutcome, Policy}; +pub use inbox::{Inbox, InboxItem}; +pub use machine::{ExecutionMode, Machine, MachineFrame, MachineState, MachineStatus, RunState}; +pub use model::{Cost, DEFAULT_MODEL_TIMEOUT_SECS, Limit, Modalities, Modality, Model, Protocol}; +pub use obs::{ + Budget, LedgerDigest, LedgerDigestEntry, LedgerTransition, Obs, OverlayStatus, + ledger_transitions_in, +}; +pub use overlay::Overlay; +pub use policy::{ACTION_VERBS, Action, Policy, PolicyView}; pub use purpose::Purpose; -pub use resources::{ModelNotRegistered, Resources}; -pub use tool::Tool; +pub use record::{Effect, StepResult, StoredEvent}; +pub use resources::{LookupResult, ModelNotRegistered, Resources, ToolNotRegistered}; +pub use tool::{DEFAULT_TOOL_TIMEOUT_SECS, Tool, ToolDefinition, ToolRuntime}; +pub use usage::{CompletionId, CompletionRecord, Telemetry, TokenUsage}; diff --git a/crates/machine/src/machine.rs b/crates/machine/src/machine.rs index 5354f290..1cd7d46b 100644 --- a/crates/machine/src/machine.rs +++ b/crates/machine/src/machine.rs @@ -1,214 +1,454 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + use crate::context::Context; use crate::env::Environment; use crate::event::{content_kind, preview, role_name}; use crate::fragment::Fragment; use crate::hook; -use crate::inbox::Inbox; -use crate::policy::{Action, Phase, PhaseOutcome, Policy}; +use crate::inbox::{Inbox, InboxItem}; +use crate::policy::Action; use crate::purpose::Purpose; use crate::reactor; +use crate::record::{Effect, StepResult, StoredEvent}; use crate::resources::Resources; -use tracing::{trace, warn}; +use crate::tool::ToolRuntime; +use crate::usage::{CompletionRecord, Telemetry}; +use utils::{MachineId, Name}; pub struct Machine { - policy: Box<dyn Policy>, + pub id: MachineId, + pub name: Name, } -impl Machine { - pub fn new(policy: Box<dyn Policy>) -> Self { - Self { policy } - } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunState { + pub purpose: Purpose, + pub run_dir: Option<PathBuf>, + pub context: Context, + pub environment: Environment, + pub resources: Resources, + pub telemetry: Telemetry, +} - pub async fn run( - &self, - purpose: &Purpose, - ctx: &mut Context, - env: &mut Environment, - resources: &mut Resources, - ) { - let mut inbox = Inbox::new(); - let mut round = 0u32; - - hook!(event = "machine_start", purpose = %purpose.text); - - if self - .run_phases(purpose, ctx, env, resources, &mut inbox, self.policy.pre()) - .await - { - return; +impl Default for RunState { + fn default() -> Self { + Self { + purpose: Purpose::default(), + run_dir: None, + context: Context::new(), + environment: Environment::new("."), + resources: Resources::new(), + telemetry: Telemetry::default(), } + } +} - loop { - let action = self - .policy - .decide(purpose, ctx, env, resources, &inbox) - .await; - trace!(?action, "machine step"); +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MachineState { + pub run: RunState, + pub frame: MachineFrame, +} - if self - .apply(action, purpose, ctx, env, resources, &mut inbox, &mut round) - .await - { - break; - } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MachineFrame { + pub inbox: Inbox, + pub step: u64, + pub status: MachineStatus, +} + +impl Default for MachineFrame { + fn default() -> Self { + Self { + inbox: Inbox::new(), + step: 0, + status: MachineStatus::Running, } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MachineStatus { + Running, + Done, +} - self.run_phases(purpose, ctx, env, resources, &mut inbox, self.policy.post()) - .await; +impl MachineStatus { + pub fn is_done(self) -> bool { + self == Self::Done } +} - async fn run_phases( - &self, - purpose: &Purpose, - ctx: &mut Context, - env: &mut Environment, - resources: &mut Resources, - inbox: &mut Inbox, - phases: Vec<Box<dyn Phase>>, - ) -> bool { - for phase in phases { - loop { - match phase.decide(purpose, ctx, env, resources) { - PhaseOutcome::Action(Action::Halt) => { - warn!(phase = phase.name(), "phase produced Halt, ignoring"); - } - PhaseOutcome::Action(action) => { - if self.apply_action(action, ctx, resources, inbox).await { - return true; - } - } - PhaseOutcome::Done => break, - } - } +pub enum ExecutionMode<'a> { + Live { + tool_runtime: &'a ToolRuntime, + overlay: &'a crate::overlay::Overlay, + }, +} + +impl Machine { + pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self { + Self { + id: MachineId::from_raw(id.into()).unwrap_or_else(|_| MachineId::new()), + name: Name::new(name).unwrap_or_else(|_| Name::from_static("rcm")), } - false } - async fn apply( - &self, + pub async fn apply( + &mut self, action: Action, - purpose: &Purpose, - ctx: &mut Context, - env: &mut Environment, - resources: &mut Resources, - inbox: &mut Inbox, - round: &mut u32, - ) -> bool { - match action { - Action::Halt => { - if self - .run_phases(purpose, ctx, env, resources, inbox, self.policy.pre_halt()) - .await - { - return true; - } + state: &mut MachineState, + mode: ExecutionMode<'_>, + ) -> StepResult { + state.frame.step += 1; + let step = state.frame.step; + let mut effects = vec![Effect::ActionCounted { + action: action.name().to_string(), + }]; - *round += 1; - let model_name = resources + match &action { + Action::Halt => { + let ExecutionMode::Live { + tool_runtime, + overlay, + } = mode; + let machine_id = self.id.to_string(); + let model_name = state + .run + .resources .active_model() .map(|model| model.name.as_str()) - .unwrap_or("<none>"); + .unwrap_or("none"); hook!( event = "halt", - round = *round, + machine_id, + step, model = %model_name, - messages = ctx.fragments().len(), - tools = resources.active_tools.len(), + messages = state.run.context.fragments().len(), + tools = state.run.resources.active_tools.len(), ); - reactor::react(ctx, env, resources, inbox).await; - self.run_phases(purpose, ctx, env, resources, inbox, self.policy.post_halt()) - .await + let (fragments, tokens) = reactor::react( + &machine_id, + &state.run.context, + &state.run.environment, + &state.run.resources, + tool_runtime, + overlay, + ) + .await; + let completion_id = state.run.telemetry.next_completion_id(); + let record = CompletionRecord { + id: completion_id, + step, + model: state + .run + .resources + .active_model() + .map(|model| model.name.clone()), + tokens, + output_fragment_ids: Vec::new(), + }; + let inbox_items = fragments + .into_iter() + .map(|fragment| InboxItem::new(fragment, Some(completion_id))) + .collect(); + effects.push(Effect::CompletionRecorded { + record, + inbox_items, + }); } - other => self.apply_action(other, ctx, resources, inbox).await, - } - } - - async fn apply_action( - &self, - action: Action, - ctx: &mut Context, - resources: &mut Resources, - inbox: &mut Inbox, - ) -> bool { - match action { - Action::Append(frag) => { - let id = ctx.append(frag); - let frag = ctx.get(id).expect("just appended"); - hook!( - event = "appended", - id, - role = role_name(frag.role), - kind = content_kind(frag), - preview = %preview(frag), - ); + Action::Append(fragment) => { + effects.push(Effect::ContextAppended { + id: state.run.context.next_id(), + fragment: fragment.clone(), + }); } Action::Insert { after, fragment } => { - let id = ctx.insert(after, fragment); - let frag = ctx.get(id).expect("just inserted"); - hook!( - event = "inserted", - id, - role = role_name(frag.role), - kind = content_kind(frag), - preview = %preview(frag), - ); + if state.run.context.get(*after).is_some() { + effects.push(Effect::ContextInserted { + id: state.run.context.next_id(), + after: *after, + fragment: fragment.clone(), + }); + } else { + push_system_hitch_effect( + &mut effects, + format!("fragment id {} not found in context", after), + ); + } } Action::Replace { id, fragment } => { - ctx.replace(id, fragment); - let frag = ctx.get(id).expect("just replaced"); - hook!( - event = "replaced", - id, - role = role_name(frag.role), - kind = content_kind(frag), - preview = %preview(frag), - ); + if state.run.context.get(*id).is_some() { + effects.push(Effect::ContextReplaced { + id: *id, + fragment: fragment.clone(), + }); + } else { + push_system_hitch_effect( + &mut effects, + format!("fragment id {} not found in context", id), + ); + } } Action::Remove(id) => { - ctx.remove(id); - hook!(event = "removed", id); + if state.run.context.get(*id).is_some() { + effects.push(Effect::ContextRemoved { id: *id }); + } else { + push_system_hitch_effect( + &mut effects, + format!("fragment id {} not found in context", id), + ); + } } - Action::Swap(id1, id2) => { - ctx.swap(id1, id2); - hook!(event = "swapped", id1, id2); + Action::Swap(first_id, second_id) => { + if state.run.context.get(*first_id).is_none() { + push_system_hitch_effect( + &mut effects, + format!("fragment id {} not found in context", first_id), + ); + } else if state.run.context.get(*second_id).is_none() { + push_system_hitch_effect( + &mut effects, + format!("fragment id {} not found in context", second_id), + ); + } else { + effects.push(Effect::ContextSwapped { + first: *first_id, + second: *second_id, + }); + } } Action::Model(name) => { - hook!(event = "model", name); - if let Err(error) = resources.use_model(&name) { - warn!(?error, "use_model failed"); - inbox.push(Fragment::hitch(error.to_string())); + if state.run.resources.models.contains_key(name) { + effects.push(Effect::ModelSelected { name: name.clone() }); + } else { + push_system_hitch_effect( + &mut effects, + format!("model '{}' not registered", name), + ); } } Action::Activate(name) => { - hook!(event = "activate", name); - resources.enable(name); + if state.run.resources.tool_definitions.contains_key(name) { + effects.push(Effect::ToolActivated { name: name.clone() }); + } else { + push_system_hitch_effect( + &mut effects, + format!("tool '{}' not registered", name), + ); + } } Action::Deactivate(name) => { - hook!(event = "deactivate", name); - resources.disable(name); + effects.push(Effect::ToolDeactivated { name: name.clone() }); } Action::Take => { - if let Some(frag) = inbox.pop() { - let id = ctx.append(frag); - let frag = ctx.get(id).expect("just appended"); + if let Some(item) = state.frame.inbox.peek() { + effects.push(Effect::InboxTaken { + source_completion: item.source_completion, + fragment_id: state.run.context.next_id(), + }); + } + } + Action::Done => { + effects.push(Effect::StatusChanged { + status: MachineStatus::Done, + }); + } + } + + self.apply_live_effects(state, &effects); + let done = state.frame.status.is_done(); + StepResult { + done, + event: StoredEvent::new(step, action, effects), + } + } + + pub fn replay_effects(&self, state: &mut MachineState, effects: &[Effect]) { + self.apply_effects(state, effects, HookMode::Suppress); + } + + fn apply_live_effects(&self, state: &mut MachineState, effects: &[Effect]) { + self.apply_effects(state, effects, HookMode::Emit); + } + + fn apply_effects(&self, state: &mut MachineState, effects: &[Effect], hook_mode: HookMode) { + for effect in effects { + self.apply_effect(state, effect, hook_mode); + } + } + + fn apply_effect(&self, state: &mut MachineState, effect: &Effect, hook_mode: HookMode) { + match effect { + Effect::ActionCounted { action } => state.run.telemetry.count_action(action.clone()), + Effect::ContextAppended { id, fragment } => { + state.run.context.append_with_id(*id, fragment.clone()); + if hook_mode.emits() { + self.hook_fragment("appended", state.frame.step, *id, &state.run.context); + } + } + Effect::ContextInserted { + id, + after, + fragment, + } => { + let _ = state + .run + .context + .insert_with_id(*after, *id, fragment.clone()); + if hook_mode.emits() { + self.hook_fragment("inserted", state.frame.step, *id, &state.run.context); + } + } + Effect::ContextReplaced { id, fragment } => { + let _ = state.run.context.replace(*id, fragment.clone()); + if hook_mode.emits() { + self.hook_fragment("replaced", state.frame.step, *id, &state.run.context); + } + } + Effect::ContextRemoved { id } => { + let _ = state.run.context.remove(*id); + if hook_mode.emits() { + let machine_id = self.id.to_string(); hook!( - event = "taken", + event = "removed", + machine_id = machine_id, id, - role = role_name(frag.role), - kind = content_kind(frag), - preview = %preview(frag), + step = state.frame.step ); } } - Action::Done => { - hook!(event = "done"); - return true; + Effect::ContextSwapped { first, second } => { + let _ = state.run.context.swap(*first, *second); + if hook_mode.emits() { + let machine_id = self.id.to_string(); + hook!( + event = "swapped", + machine_id = machine_id, + id1 = first, + id2 = second, + step = state.frame.step + ); + } } - Action::Halt => { - warn!("apply_action received Halt, this should be handled by apply()"); + Effect::ModelSelected { name } => { + let _ = state.run.resources.use_model(name); + if hook_mode.emits() { + let machine_id = self.id.to_string(); + hook!( + event = "model", + machine_id = machine_id, + name = name.as_str(), + step = state.frame.step + ); + } + } + Effect::ToolActivated { name } => { + let _ = state.run.resources.enable(name); + if hook_mode.emits() { + let machine_id = self.id.to_string(); + hook!( + event = "activate", + machine_id = machine_id, + name = name.as_str(), + step = state.frame.step + ); + } + } + Effect::ToolDeactivated { name } => { + state.run.resources.disable(name); + if hook_mode.emits() { + let machine_id = self.id.to_string(); + hook!( + event = "deactivate", + machine_id = machine_id, + name = name.as_str(), + step = state.frame.step + ); + } + } + Effect::InboxPushed { item } => state.frame.inbox.push_item(item.clone()), + Effect::CompletionRecorded { + record, + inbox_items, + } => { + state.run.telemetry.completions.push(record.clone()); + state.frame.inbox.extend_items(inbox_items.clone()); + } + Effect::InboxTaken { + source_completion, + fragment_id, + } => { + if let Some(item) = state.frame.inbox.pop() { + state + .run + .context + .append_with_id(*fragment_id, item.fragment); + if let Some(completion_id) = source_completion { + state + .run + .telemetry + .record_output_fragment(*completion_id, *fragment_id); + } + if hook_mode.emits() { + self.hook_fragment( + "taken", + state.frame.step, + *fragment_id, + &state.run.context, + ); + } + } + } + Effect::StatusChanged { status } => { + state.frame.status = *status; + if status.is_done() && hook_mode.emits() { + let machine_id = self.id.to_string(); + hook!( + event = "done", + machine_id = machine_id, + step = state.frame.step + ); + } } } - false } + + fn hook_fragment(&self, event: &'static str, step: u64, id: u64, context: &Context) { + let Some(fragment) = context.get(id) else { + return; + }; + let machine_id = self.id.to_string(); + hook!( + event = event, + machine_id, + id, + step, + role = role_name(fragment.role), + kind = content_kind(fragment), + tag = fragment.tag.as_str(), + preview = %preview(fragment), + ); + } +} + +#[derive(Clone, Copy)] +enum HookMode { + Emit, + Suppress, +} + +impl HookMode { + fn emits(self) -> bool { + matches!(self, Self::Emit) + } +} + +fn push_system_hitch_effect(effects: &mut Vec<Effect>, message: String) { + let fragment = Fragment::hitch(message, None, crate::fragment::Role::System, None::<&str>); + effects.push(Effect::InboxPushed { + item: InboxItem::new(fragment, None), + }); } diff --git a/crates/machine/src/model.rs b/crates/machine/src/model.rs index 8f0f374d..3e9b04e8 100644 --- a/crates/machine/src/model.rs +++ b/crates/machine/src/model.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -const DEFAULT_TIMEOUT_SECS: u64 = 180; +pub const DEFAULT_MODEL_TIMEOUT_SECS: u64 = 1_800; /// LLM configuration. /// @@ -29,6 +29,14 @@ pub struct Model { pub modalities: Option<Modalities>, /// Request timeout in seconds. pub timeout: u64, + /// Extra HTTP headers sent with every request. + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option<HashMap<String, String>>, + /// Provider requires a `reasoning_content` field alongside tool calls + /// (e.g. Kimi Coding with thinking enabled). Off by default; most + /// OpenAI-compatible providers reject or ignore the field. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub thinking: bool, #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")] pub extra: HashMap<String, Value>, } @@ -44,12 +52,24 @@ impl Default for Model { limit: None, cost: None, modalities: None, - timeout: DEFAULT_TIMEOUT_SECS, + timeout: DEFAULT_MODEL_TIMEOUT_SECS, + headers: None, + thinking: false, extra: HashMap::new(), } } } +#[cfg(test)] +mod tests { + use super::Model; + + #[test] + fn model_default_timeout_allows_thirty_minute_requests() { + assert_eq!(Model::default().timeout, 1_800); + } +} + /// Wire protocol. /// /// Three protocols are supported. Most providers are OpenAI-compatible; diff --git a/crates/machine/src/obs.rs b/crates/machine/src/obs.rs new file mode 100644 index 00000000..ba6229b4 --- /dev/null +++ b/crates/machine/src/obs.rs @@ -0,0 +1,228 @@ +//! Controller observation channel. +//! +//! [`Obs`] is the policy-facing sensory layer derived from [`RunState`] +//! immediately before every `decide` call. It is never stored on the machine +//! state: every read is freshly computed, so it can never drift from the +//! state it summarizes. +//! +//! Budget estimation uses the chars-per-token heuristic shared with +//! production harnesses (synergy's prompt budgeter): +//! `(Σ text chars + Σ active tool manifest chars) / 4 + 500 per media +//! fragment + 4 per fragment`. Consumers that need exact numbers should +//! prefer `last_actual_input` — the most recent API-measured input token +//! count — over the estimate. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::fragment::Content; +use crate::machine::RunState; + +/// Fraction (in percent) of the context window at which compaction-style +/// action becomes advisable; mirrors synergy's production soft threshold +/// of 0.85. Integer percent keeps the math exact for realistic limits. +const SOFT_THRESHOLD_PERCENT: u64 = 85; +/// Heuristic chars-per-token divisor for plain-text estimates. +const CHARS_PER_TOKEN: u64 = 4; +/// Fixed token estimate for one media (image/audio/video/document) fragment. +const MEDIA_FRAGMENT_TOKEN_ESTIMATE: u64 = 500; +/// Per-fragment framing overhead: role tags, separators, tool-call ids. +const PER_FRAGMENT_OVERHEAD: u64 = 4; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Budget { + /// Context window of the active model; 0 when no model is active. + pub context_limit: u64, + /// Heuristic input-token estimate of the next completion request. + pub estimated_input: u64, + /// `context_limit × 0.85` — the soft compaction threshold. + pub soft_threshold: u64, + /// `context_limit − estimated_input`, saturating at zero. + pub headroom: u64, + /// Most recent API-measured input tokens, when a completion succeeded. + pub last_actual_input: Option<u64>, +} + +/// Digest of ledger state. Filled by the accelerator once the ledger tool +/// reports it — the machine itself performs no IO. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LedgerDigest { + pub total: u64, + pub by_status: HashMap<String, u64>, + pub current_entry: Option<LedgerDigestEntry>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LedgerDigestEntry { + pub id: String, + pub title: String, + pub status: String, +} + +/// One ledger state migration, recorded into the trajectory envelope at +/// the step whose tool call caused it. Future per-node credit-assignment +/// anchor for offline policy learning. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LedgerTransition { + pub entry_id: String, + pub from_status: String, + pub to_status: String, +} + +/// Extract ledger transitions from a step's effects by parsing tool-result +/// fragments emitted by the ledger tool (results whose JSON body carries +/// `"tool": "ledger"`). Kept here so both the accelerator loop and the +/// gRPC server share one parser; the machine itself does no IO. +pub fn ledger_transitions_in(effects: &[crate::record::Effect]) -> Vec<LedgerTransition> { + let mut transitions = Vec::new(); + for effect in effects { + match effect { + crate::record::Effect::CompletionRecorded { inbox_items, .. } => { + for item in inbox_items { + collect_from_tool_result(&item.fragment, &mut transitions); + } + } + crate::record::Effect::InboxPushed { item } => { + collect_from_tool_result(&item.fragment, &mut transitions); + } + _ => {} + } + } + transitions +} + +fn collect_from_tool_result( + fragment: &crate::fragment::Fragment, + sink: &mut Vec<LedgerTransition>, +) { + let crate::fragment::Content::ToolResult(tool_result) = &fragment.content else { + return; + }; + let Ok(value) = serde_json::from_str::<serde_json::Value>(&tool_result.content) else { + return; + }; + if value.get("tool").and_then(|tool| tool.as_str()) != Some("ledger") { + return; + } + let Some(entries) = value.get("transitions").and_then(|list| list.as_array()) else { + return; + }; + for entry in entries { + let (Some(entry_id), Some(from_status), Some(to_status)) = ( + entry.get("id").and_then(|field| field.as_str()), + entry.get("from").and_then(|field| field.as_str()), + entry.get("to").and_then(|field| field.as_str()), + ) else { + continue; + }; + sink.push(LedgerTransition { + entry_id: entry_id.to_string(), + from_status: from_status.to_string(), + to_status: to_status.to_string(), + }); + } +} + +/// Digest of the policy-declared overlay for this turn. Counts only — +/// projected content itself never enters observation. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct OverlayStatus { + pub declared: bool, + pub system_prefix_count: u64, + pub tail_count: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Obs { + pub budget: Budget, + pub ledger_digest: Option<LedgerDigest>, + pub overlay_status: OverlayStatus, +} + +/// Derive a fresh observation from the run state. Pure: no IO, no caching. +pub fn measure(run: &RunState) -> Obs { + Obs { + budget: measure_budget(run), + // The ledger lives in accelerator tool state, not RunState; the + // accelerator enriches this field after calling measure. + ledger_digest: None, + // The overlay is policy-declared and cannot be derived from state; + // the caller fills this once the declaration is known. + overlay_status: OverlayStatus::default(), + } +} + +fn measure_budget(run: &RunState) -> Budget { + let context_limit = run + .resources + .active_model() + .and_then(|model| model.limit.as_ref()) + .map(|limit| limit.context) + .unwrap_or(0); + let estimated_input = estimate_input_tokens(run); + Budget { + context_limit, + estimated_input, + soft_threshold: context_limit.saturating_mul(SOFT_THRESHOLD_PERCENT) / 100, + headroom: context_limit.saturating_sub(estimated_input), + last_actual_input: last_measured_input(run), + } +} + +fn estimate_input_tokens(run: &RunState) -> u64 { + let fragments = run.context.fragments(); + let mut text_chars: u64 = 0; + let mut media_fragments: u64 = 0; + for fragment in fragments { + match &fragment.content { + Content::Text(text) => text_chars = text_chars.saturating_add(text.text.len() as u64), + Content::Hitch { message, .. } => { + text_chars = text_chars.saturating_add(message.len() as u64); + } + Content::ToolResult(result) => { + text_chars = text_chars.saturating_add(result.content.len() as u64); + } + Content::ToolCall(tool_call) => { + let arguments_len = serde_json::to_string(&tool_call.arguments) + .map(|encoded| encoded.len() as u64) + .unwrap_or(0); + text_chars = text_chars + .saturating_add(tool_call.name.len() as u64) + .saturating_add(arguments_len); + } + Content::Image(_) | Content::Audio(_) | Content::Video(_) | Content::Document(_) => { + media_fragments = media_fragments.saturating_add(1); + } + } + } + let tool_manifest_chars = + run.resources + .active_tool_definitions() + .iter() + .fold(0u64, |accumulator, definition| { + let parameters_len = serde_json::to_string(&definition.parameters) + .map(|encoded| encoded.len() as u64) + .unwrap_or(0); + accumulator + .saturating_add(definition.name.len() as u64) + .saturating_add(definition.description.len() as u64) + .saturating_add(parameters_len) + }); + text_chars + .saturating_add(tool_manifest_chars) + .saturating_div(CHARS_PER_TOKEN) + .saturating_add(media_fragments.saturating_mul(MEDIA_FRAGMENT_TOKEN_ESTIMATE)) + .saturating_add((fragments.len() as u64).saturating_mul(PER_FRAGMENT_OVERHEAD)) +} + +fn last_measured_input(run: &RunState) -> Option<u64> { + // Failed completions are recorded with empty usage; a zero reading is + // not a measurement, so walk back to the newest record carrying one. + run.telemetry + .completions + .iter() + .rev() + .map(|record| record.tokens.input_tokens) + .find(|input_tokens| *input_tokens > 0) +} diff --git a/crates/machine/src/overlay.rs b/crates/machine/src/overlay.rs new file mode 100644 index 00000000..d4310afa --- /dev/null +++ b/crates/machine/src/overlay.rs @@ -0,0 +1,25 @@ +//! Policy-declared projection layer for completion requests. +//! +//! An [`Overlay`] never touches the tape: its contents exist only for the +//! duration of one Halt→request cycle and are re-derived by the policy on +//! every turn. `system_prefix` lands before the tape's messages (stable, +//! cache-friendly); `tail` lands after them (volatile, cache-busting). +//! Empty by default — existing policies see byte-identical requests. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Overlay { + /// System content placed before the encoded context. Stable across + /// turns for cache hits (synergy's early-system layers). + pub system_prefix: Vec<String>, + /// System content placed after the encoded context. Recomputed each + /// turn by design (synergy's late-system layers). + pub tail: Vec<String>, +} + +impl Overlay { + pub fn is_empty(&self) -> bool { + self.system_prefix.is_empty() && self.tail.is_empty() + } +} diff --git a/crates/machine/src/policy.rs b/crates/machine/src/policy.rs index 925aa841..3cb2070a 100644 --- a/crates/machine/src/policy.rs +++ b/crates/machine/src/policy.rs @@ -1,14 +1,15 @@ use std::future::Future; use std::pin::Pin; -use crate::context::Context; -use crate::env::Environment; +use serde::{Deserialize, Serialize}; + use crate::fragment::Fragment; use crate::inbox::Inbox; -use crate::purpose::Purpose; -use crate::resources::Resources; +use crate::machine::{MachineStatus, RunState}; +use crate::obs::Obs; +use crate::overlay::Overlay; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum Action { Append(Fragment), Insert { after: u64, fragment: Fragment }, @@ -23,29 +24,64 @@ pub enum Action { Done, } -#[derive(Debug, Clone, PartialEq)] -pub enum PhaseOutcome { - Action(Action), - Done, -} +impl Action { + pub fn name(&self) -> &'static str { + match self { + Action::Append(_) => "append", + Action::Insert { .. } => "insert", + Action::Replace { .. } => "replace", + Action::Remove(_) => "remove", + Action::Swap(..) => "swap", + Action::Model(_) => "model", + Action::Activate(_) => "activate", + Action::Deactivate(_) => "deactivate", + Action::Take => "take", + Action::Halt => "halt", + Action::Done => "done", + } + } -pub trait Phase: Send + Sync { - fn clone_box(&self) -> Box<dyn Phase>; - fn name(&self) -> &str; + pub fn is_done(&self) -> bool { + matches!(self, Action::Done) + } - fn decide( - &self, - purpose: &Purpose, - ctx: &Context, - env: &Environment, - resources: &Resources, - ) -> PhaseOutcome; + pub fn verb(&self) -> &'static str { + match self { + Action::Append(_) => "Append", + Action::Insert { .. } => "Insert", + Action::Replace { .. } => "Replace", + Action::Remove(_) => "Remove", + Action::Swap(..) => "Swap", + Action::Model(_) => "Model", + Action::Activate(_) => "Activate", + Action::Deactivate(_) => "Deactivate", + Action::Take => "Take", + Action::Halt => "Halt", + Action::Done => "Done", + } + } } -impl Clone for Box<dyn Phase> { - fn clone(&self) -> Self { - self.clone_box() - } +pub const ACTION_VERBS: &[&str] = &[ + "Append", + "Insert", + "Replace", + "Remove", + "Swap", + "Model", + "Activate", + "Deactivate", + "Take", + "Halt", + "Done", +]; + +pub struct PolicyView<'a> { + pub run: &'a RunState, + pub inbox: &'a Inbox, + pub step: u64, + pub status: MachineStatus, + pub obs: &'a Obs, } pub trait Policy: Send + Sync { @@ -55,30 +91,18 @@ pub trait Policy: Send + Sync { "policy" } - fn pre(&self) -> Vec<Box<dyn Phase>> { - Vec::new() - } - - fn post(&self) -> Vec<Box<dyn Phase>> { - Vec::new() - } - - fn pre_halt(&self) -> Vec<Box<dyn Phase>> { - Vec::new() - } - - fn post_halt(&self) -> Vec<Box<dyn Phase>> { - Vec::new() - } - fn decide<'a>( &'a self, - purpose: &'a Purpose, - ctx: &'a Context, - env: &'a Environment, - resources: &'a Resources, - inbox: &'a Inbox, + view: PolicyView<'a>, ) -> Pin<Box<dyn Future<Output = Action> + Send + 'a>>; + + /// Projection declared for the next completion request. Consumed only + /// at Halt; never materialized on the tape; re-derived every turn. + /// Empty by default — policies that do not opt in produce requests + /// byte-identical to pre-overlay behavior. + fn overlay(&self, _view: &PolicyView<'_>) -> Overlay { + Overlay::default() + } } impl Clone for Box<dyn Policy> { diff --git a/crates/machine/src/reactor.rs b/crates/machine/src/reactor.rs index b076202f..0939b605 100644 --- a/crates/machine/src/reactor.rs +++ b/crates/machine/src/reactor.rs @@ -1,116 +1,248 @@ +use std::sync::Arc; use std::time::Instant; use crate::completion; use crate::context::Context; use crate::env::Environment; -use crate::fragment::{Content, Fragment}; +use crate::fragment::{Content, Fragment, Role}; use crate::hook; -use crate::inbox::Inbox; -use crate::resources::Resources; +use crate::resources::{LookupResult, Resources}; +use crate::tool::ToolRuntime; +use crate::usage::TokenUsage; +use futures_util::future::join_all; use tokio::time::{Duration, timeout}; use tracing::{debug, info, warn}; -/// Call the LLM and execute ToolCalls. All fragments (original LLM text, -/// ToolCalls, and their ToolResults/Hitches) are pushed into the inbox. -/// The Policy drains them via [`Take`](crate::Action::Take). -pub async fn react(ctx: &Context, env: &Environment, resources: &Resources, inbox: &mut Inbox) { - let t0 = Instant::now(); +pub async fn react( + machine_id: &str, + ctx: &Context, + env: &Environment, + resources: &Resources, + tool_runtime: &ToolRuntime, + overlay: &crate::overlay::Overlay, +) -> (Vec<Fragment>, TokenUsage) { + let started_at = Instant::now(); - hook!(event = "completion_start"); - - let fragments = completion::complete(ctx, resources).await; + hook!(event = "completion_start", machine_id); + let (fragments, usage) = completion::complete(ctx, resources, overlay).await; + let elapsed = started_at.elapsed(); + let telemetry = crate::event::completion_telemetry(&fragments); hook!( event = "completion_end", - duration = %humantime(t0.elapsed()), + machine_id, + duration = %humantime(elapsed), + duration_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX), + outcome = telemetry.outcome, + http_status = telemetry.http_status.unwrap_or_default(), + failure_kind = telemetry.failure_kind.unwrap_or_default(), + retryable = telemetry.retryable.unwrap_or(false), fragments = fragments.len(), + input_tokens = usage.input_tokens, + output_tokens = usage.output_tokens, + total_tokens = usage.total_tokens, + cached_input_tokens = usage.cached_input_tokens, + cache_creation_input_tokens = usage.cache_creation_input_tokens, ); - for frag in fragments { - let mut result: Option<Fragment> = None; + let mut output = Vec::new(); + let mut tool_call_ids: Vec<String> = Vec::new(); + let mut tool_names: Vec<String> = Vec::new(); + let mut tool_fragments: Vec<Fragment> = Vec::new(); + #[allow(clippy::type_complexity)] + let mut tool_futures: Vec< + std::pin::Pin< + Box< + dyn std::future::Future<Output = Result<(Fragment, Duration), (String, Duration)>> + + Send + + '_, + >, + >, + > = Vec::new(); + + for fragment in fragments { + let tool_call = match fragment.content { + Content::ToolCall(ref tool_call) => Some(( + tool_call.id.clone(), + tool_call.name.clone(), + tool_call.arguments.clone(), + )), + _ => None, + }; + let Some((call_id, tool_name, arguments)) = tool_call else { + output.push(fragment); + continue; + }; + + debug!(tool = tool_name, args = %arguments, "tool call"); - if let Content::ToolCall(tc) = &frag.content { - debug!(tool = tc.name, args = %tc.arguments, "tool call"); + hook!( + event = "tool_call", + machine_id, + call_id, + tool = tool_name, + arguments = %arguments, + ); - hook!( - event = "tool_call", - call_id = tc.id, - tool = tc.name, - arguments = %tc.arguments, - ); + match resources.lookup(&tool_name) { + LookupResult::NotFound => { + let mut available: Vec<&str> = + resources.active_tools.iter().map(String::as_str).collect(); + available.sort_unstable(); + output.push(fragment); + output.push(Fragment::hitch( + format!( + "tool '{}' not found. Available tools: {}", + tool_name, + available.join(", ") + ), + None, + Role::Tool, + Some(call_id), + )); + } + LookupResult::Inactive => { + output.push(fragment); + output.push(Fragment::hitch( + format!("tool '{}' is disabled — activate it before use", tool_name), + None, + Role::Tool, + Some(call_id), + )); + } + LookupResult::Active => { + let Some(tool_arc) = tool_runtime.get_arc(&tool_name) else { + output.push(fragment); + output.push(Fragment::hitch( + format!("tool '{}' has no runtime executor", tool_name), + None, + Role::Tool, + Some(call_id), + )); + continue; + }; + let deadline = Duration::from_secs(tool_arc.timeout().as_secs()); + let env_arc = Arc::new(env.clone()); - result = Some(match resources.lookup(&tc.name) { - None => Fragment::hitch(format!("tool '{}' not found", tc.name)), - Some(tool) => { - let deadline = Duration::from_secs(tool.timeout().as_secs()); - let t1 = Instant::now(); - match timeout(deadline, tool.execute(tc.arguments.clone(), env)).await { - Ok(Ok(tool_result)) => { + tool_call_ids.push(call_id.clone()); + tool_names.push(tool_name.clone()); + tool_futures.push(Box::pin(async move { + let started_at = Instant::now(); + let handle = tokio::spawn(async move { + timeout(deadline, tool_arc.execute(arguments, &env_arc)).await + }); + let result = handle.await; + let elapsed = started_at.elapsed(); + match result { + Ok(Ok(Ok(tool_result))) => { info!( - tool = tc.name, + tool = tool_name, result = tool_result.content, "tool executed" ); - hook!( - event = "tool_result", - call_id = tc.id, - tool = tc.name, - result = %tool_result.content, - duration = %humantime(t1.elapsed()), - ); - Fragment::tool_result( - tc.id.clone(), - tool_result.content, - tool_result.title, - ) + Ok(( + Fragment::tool_result( + call_id, + tool_result.content, + tool_result.title, + ), + elapsed, + )) } - Ok(Err(msg)) => { - warn!(tool = tc.name, msg, "tool failed"); - hook!( - event = "tool_error", - call_id = tc.id, - tool = tc.name, - error = %msg, - duration = %humantime(t1.elapsed()), - ); - Fragment::hitch(format!("tool '{}' error: {}", tc.name, msg)) + Ok(Ok(Err(message))) => { + warn!(tool = tool_name, message, "tool failed"); + Err((format!("tool '{}' error: {}", tool_name, message), elapsed)) } - Err(_) => { + Ok(Err(_)) => { warn!( - tool = tc.name, - timeout = tool.timeout().as_secs(), + tool = tool_name, + timeout = deadline.as_secs(), "tool timed out" ); - hook!( - event = "tool_error", - call_id = tc.id, - tool = tc.name, - error = "timeout", - timeout = tool.timeout().as_secs(), - ); - Fragment::hitch(format!( - "tool '{}' timed out after {}s", - tc.name, - tool.timeout().as_secs() + Err(( + format!( + "tool '{}' timed out after {}s", + tool_name, + deadline.as_secs() + ), + elapsed, )) } + Err(join_error) => { + let message = if join_error.is_panic() { + let payload = join_error.into_panic(); + payload + .downcast_ref::<&str>() + .map(|panic_message| { + format!("tool '{}' panicked: {}", tool_name, panic_message) + }) + .or_else(|| { + payload.downcast_ref::<String>().map(|panic_message| { + format!( + "tool '{}' panicked: {}", + tool_name, panic_message + ) + }) + }) + .unwrap_or_else(|| format!("tool '{}' panicked", tool_name)) + } else { + format!("tool '{}' cancelled", tool_name) + }; + warn!(message); + Err((message, elapsed)) + } } - } - }); + })); + tool_fragments.push(fragment); + } } + } - inbox.push(frag); - if let Some(f) = result { - inbox.push(f); + let results = join_all(tool_futures).await; + + for (((fragment, call_id), tool_name), result) in tool_fragments + .into_iter() + .zip(tool_call_ids) + .zip(tool_names) + .zip(results) + { + output.push(fragment); + + match result { + Ok((result_fragment, duration)) => { + hook!( + event = "tool_result", + machine_id, + call_id, + tool = tool_name, + result = %result_fragment.content_as_text(), + duration = %humantime(duration), + ); + output.push(result_fragment); + } + Err((message, duration)) => { + warn!(tool = tool_name, message, "tool failed"); + hook!( + event = "tool_error", + machine_id, + call_id, + tool = tool_name, + error = %message, + duration = %humantime(duration), + ); + output.push(Fragment::hitch(message, None, Role::Tool, Some(call_id))); + } } } + + (output, usage) } -fn humantime(d: Duration) -> String { - let ms = d.as_millis(); - if ms < 1000 { - format!("{}ms", ms) +fn humantime(duration: Duration) -> String { + let milliseconds = duration.as_millis(); + if milliseconds < 1000 { + format!("{}ms", milliseconds) } else { - format!("{:.1}s", ms as f64 / 1000.0) + format!("{:.1}s", milliseconds as f64 / 1000.0) } } diff --git a/crates/machine/src/record.rs b/crates/machine/src/record.rs new file mode 100644 index 00000000..cb9cfb1d --- /dev/null +++ b/crates/machine/src/record.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; + +use crate::fragment::Fragment; +use crate::inbox::InboxItem; +use crate::policy::Action; +use crate::usage::CompletionRecord; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StepResult { + pub done: bool, + pub event: StoredEvent, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StoredEvent { + pub step: u64, + pub action: Action, + pub effects: Vec<Effect>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Effect { + ActionCounted { + action: String, + }, + ContextAppended { + id: u64, + fragment: Fragment, + }, + ContextInserted { + id: u64, + after: u64, + fragment: Fragment, + }, + ContextReplaced { + id: u64, + fragment: Fragment, + }, + ContextRemoved { + id: u64, + }, + ContextSwapped { + first: u64, + second: u64, + }, + ModelSelected { + name: String, + }, + ToolActivated { + name: String, + }, + ToolDeactivated { + name: String, + }, + InboxPushed { + item: InboxItem, + }, + CompletionRecorded { + record: CompletionRecord, + inbox_items: Vec<InboxItem>, + }, + InboxTaken { + source_completion: Option<crate::usage::CompletionId>, + fragment_id: u64, + }, + StatusChanged { + status: crate::machine::MachineStatus, + }, +} + +impl StoredEvent { + pub fn new(step: u64, action: Action, effects: Vec<Effect>) -> Self { + Self { + step, + action, + effects, + } + } +} diff --git a/crates/machine/src/resources.rs b/crates/machine/src/resources.rs index b7bc5758..aac50a52 100644 --- a/crates/machine/src/resources.rs +++ b/crates/machine/src/resources.rs @@ -1,21 +1,19 @@ use std::collections::{HashMap, HashSet}; -use std::sync::Arc; +use std::fmt; +use serde::{Deserialize, Serialize}; use utils::{Name, ResourcesId}; use crate::model::Model; -use crate::tool::Tool; +use crate::tool::ToolDefinition; -/// Resources — the pool of available tools and models with activation state. -/// -/// The Policy switches models and toggles tools via [`Action`](crate::Action). -/// The completion reads the active state directly. -#[derive(Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Resources { id: ResourcesId, pub name: Name, - pub tools: HashMap<String, Arc<dyn Tool>>, + pub tool_definitions: HashMap<String, ToolDefinition>, pub models: HashMap<String, Model>, + pub model_order: Vec<String>, pub active_model: String, pub active_tools: HashSet<String>, pub prompts: HashMap<String, String>, @@ -40,103 +38,112 @@ impl Resources { Self { id: ResourcesId::new(), name: Name::new(name).expect("resources name must be valid"), - tools: HashMap::new(), + tool_definitions: HashMap::new(), models: HashMap::new(), + model_order: Vec::new(), active_model: String::new(), active_tools: HashSet::new(), prompts: HashMap::new(), } } - /// Register a tool. Overwrites any tool with the same name. - pub fn with_tool(mut self, tool: Arc<dyn Tool>) -> Self { - let name = tool.name().to_string(); - self.tools.insert(name, tool); + pub fn with_tool_definition(mut self, definition: ToolDefinition) -> Self { + self.tool_definitions + .insert(definition.name.clone(), definition); self } - /// Register a model. The first model registered becomes the active model. - /// Overwrites any model with the same name. pub fn with_model(mut self, model: Model) -> Self { - if self.active_model.is_empty() { - self.active_model.clone_from(&model.name); + if !self.models.contains_key(&model.name) { + self.model_order.push(model.name.clone()); } self.models.insert(model.name.clone(), model); self } - /// Enable a tool. Idempotent. - /// - /// # Panics - /// - /// Panics if the tool is not registered. - pub fn enable(&mut self, name: impl Into<String>) { + pub fn deactivate_model(&mut self) { + self.active_model.clear(); + } + + pub fn deactivate_tools(&mut self) { + self.active_tools.clear(); + } + + pub fn enable(&mut self, name: impl Into<String>) -> Result<(), ToolNotRegistered> { let name = name.into(); - assert!( - self.tools.contains_key(&name), - "tool '{name}' not registered" - ); - self.active_tools.insert(name); + if self.tool_definitions.contains_key(&name) { + self.active_tools.insert(name); + Ok(()) + } else { + Err(ToolNotRegistered(name)) + } } - /// Disable a tool. pub fn disable(&mut self, name: impl Into<String>) { self.active_tools.remove(&name.into()); } - /// Switch the active model. Returns the previous active-model name on - /// success, or [`ModelNotRegistered`] if no model with that name has been - /// registered. - pub fn use_model(&mut self, name: impl Into<String>) -> Result<String, ModelNotRegistered> { + pub fn use_model(&mut self, name: impl Into<String>) -> Result<(), ModelNotRegistered> { let name = name.into(); - if !self.models.contains_key(&name) { - return Err(ModelNotRegistered(name)); + if self.models.contains_key(&name) { + self.active_model = name; + Ok(()) + } else { + Err(ModelNotRegistered(name)) } - let previous = std::mem::replace(&mut self.active_model, name); - Ok(previous) } - /// The currently active model, if any has been registered and selected. - /// - /// Returns `None` when no model has been registered, or when the active - /// name does not point at a registered model (which can only happen if - /// `active_model` was mutated through public field access — kept here for - /// safety). pub fn active_model(&self) -> Option<&Model> { - if self.active_model.is_empty() { - return None; - } self.models.get(&self.active_model) } - /// All active tools. - pub fn active_tools(&self) -> Vec<&dyn Tool> { + pub fn active_tool_definitions(&self) -> Vec<&ToolDefinition> { self.active_tools .iter() - .filter_map(|name| self.tools.get(name)) - .map(|t| t.as_ref()) + .filter_map(|name| self.tool_definitions.get(name)) .collect() } - /// Look up an active tool by name. - pub fn lookup(&self, name: &str) -> Option<&dyn Tool> { - if !self.active_tools.contains(name) { - return None; + pub fn lookup(&self, name: &str) -> LookupResult { + if !self.tool_definitions.contains_key(name) { + LookupResult::NotFound + } else if self.active_tools.contains(name) { + LookupResult::Active + } else { + LookupResult::Inactive } - self.tools.get(name).map(|t| t.as_ref()) + } + + pub fn tool_definition(&self, name: &str) -> Option<&ToolDefinition> { + self.tool_definitions.get(name) } } -/// Returned by [`Resources::use_model`] when the requested model has not been -/// registered. Carries the offending name so the caller can build a useful -/// hitch / error message. #[derive(Debug, Clone, PartialEq, Eq)] +pub enum LookupResult { + Active, + Inactive, + NotFound, +} + +#[derive(Debug)] pub struct ModelNotRegistered(pub String); -impl std::fmt::Display for ModelNotRegistered { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ModelNotRegistered { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!(formatter, "model '{}' not registered", self.0) } } impl std::error::Error for ModelNotRegistered {} + +#[derive(Debug)] +pub struct ToolNotRegistered(pub String); + +impl fmt::Display for ToolNotRegistered { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "tool '{}' not registered", self.0) + } +} + +impl std::error::Error for ToolNotRegistered {} diff --git a/crates/machine/src/tool.rs b/crates/machine/src/tool.rs index 95fabd4b..e3e90e5c 100644 --- a/crates/machine/src/tool.rs +++ b/crates/machine/src/tool.rs @@ -1,15 +1,34 @@ +use std::collections::HashMap; use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use std::time::Duration; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::env::Environment; use crate::fragment::ToolResult; -const DEFAULT_TIMEOUT_SECS: u64 = 180; +pub const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 1_800; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolDefinition { + pub name: String, + pub description: String, + pub parameters: Value, +} + +impl ToolDefinition { + pub fn from_tool(tool: &dyn Tool) -> Self { + Self { + name: tool.name().to_string(), + description: tool.description().to_string(), + parameters: tool.parameters(), + } + } +} -/// Tool — an executable capability. pub trait Tool: Send + Sync { fn name(&self) -> &str; @@ -18,7 +37,7 @@ pub trait Tool: Send + Sync { fn parameters(&self) -> Value; fn timeout(&self) -> Duration { - Duration::from_secs(DEFAULT_TIMEOUT_SECS) + Duration::from_secs(DEFAULT_TOOL_TIMEOUT_SECS) } fn execute<'a>( @@ -27,3 +46,54 @@ pub trait Tool: Send + Sync { env: &'a Environment, ) -> Pin<Box<dyn Future<Output = Result<ToolResult, String>> + Send + 'a>>; } + +#[cfg(test)] +mod tests { + use super::DEFAULT_TOOL_TIMEOUT_SECS; + + #[test] + fn tool_default_timeout_allows_thirty_minute_calls() { + assert_eq!(DEFAULT_TOOL_TIMEOUT_SECS, 1_800); + } +} + +#[derive(Clone, Default)] +pub struct ToolRuntime { + executors: HashMap<String, Arc<dyn Tool>>, +} + +impl ToolRuntime { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, tool: Arc<dyn Tool>) { + self.executors.insert(tool.name().to_string(), tool); + } + + pub fn get(&self, name: &str) -> Option<&dyn Tool> { + self.executors.get(name).map(|tool| tool.as_ref()) + } + + pub fn get_arc(&self, name: &str) -> Option<Arc<dyn Tool>> { + self.executors.get(name).cloned() + } + + pub fn contains(&self, name: &str) -> bool { + self.executors.contains_key(name) + } + + pub fn names(&self) -> Vec<String> { + let mut names: Vec<String> = self.executors.keys().cloned().collect(); + names.sort(); + names + } + + pub fn merge(&mut self, other: &ToolRuntime) { + for (name, tool) in &other.executors { + self.executors + .entry(name.clone()) + .or_insert_with(|| tool.clone()); + } + } +} diff --git a/crates/machine/src/usage.rs b/crates/machine/src/usage.rs new file mode 100644 index 00000000..faf7da6b --- /dev/null +++ b/crates/machine/src/usage.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct CompletionId(pub u64); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub total_tokens: u64, + pub cached_input_tokens: u64, + pub cache_creation_input_tokens: u64, +} + +impl TokenUsage { + pub fn empty() -> Self { + Self { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompletionRecord { + pub id: CompletionId, + pub step: u64, + pub model: Option<String>, + pub tokens: TokenUsage, + pub output_fragment_ids: Vec<u64>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Telemetry { + pub completions: Vec<CompletionRecord>, + pub action_counts: std::collections::HashMap<String, u64>, +} + +impl Telemetry { + pub fn next_completion_id(&self) -> CompletionId { + CompletionId(self.completions.len() as u64 + 1) + } + + pub fn count_action(&mut self, action: impl Into<String>) { + *self.action_counts.entry(action.into()).or_default() += 1; + } + + pub fn record_output_fragment(&mut self, completion_id: CompletionId, fragment_id: u64) { + if let Some(record) = self + .completions + .iter_mut() + .find(|record| record.id == completion_id) + { + record.output_fragment_ids.push(fragment_id); + } + } +} diff --git a/crates/machine/tests/common.rs b/crates/machine/tests/common.rs index 90867c04..2450d044 100644 --- a/crates/machine/tests/common.rs +++ b/crates/machine/tests/common.rs @@ -1,55 +1,5 @@ -use machine::Purpose; -use machine::{Action, Context, Environment, Inbox, Model, Policy, Resources}; -use std::future::Future; -use std::pin::Pin; -use std::sync::atomic::{AtomicUsize, Ordering}; - -/// A policy that replays a fixed sequence of actions, then Done. -pub struct SeqPolicy { - actions: Vec<Action>, - pos: AtomicUsize, -} - -impl Clone for SeqPolicy { - fn clone(&self) -> Self { - Self { - actions: self.actions.clone(), - pos: AtomicUsize::new(self.pos.load(Ordering::SeqCst)), - } - } -} - -impl SeqPolicy { - pub fn new(actions: Vec<Action>) -> Self { - Self { - actions, - pos: AtomicUsize::new(0), - } - } -} - -impl Policy for SeqPolicy { - fn clone_box(&self) -> Box<dyn Policy> { - Box::new(self.clone()) - } - - fn decide<'a>( - &'a self, - _purpose: &'a Purpose, - _ctx: &'a Context, - _env: &'a Environment, - _resources: &'a Resources, - _inbox: &'a Inbox, - ) -> Pin<Box<dyn Future<Output = Action> + Send + 'a>> { - let pos = self.pos.fetch_add(1, Ordering::SeqCst); - let action = if pos >= self.actions.len() { - Action::Done - } else { - self.actions[pos].clone() - }; - Box::pin(async move { action }) - } -} +use machine::Model; +use machine::Resources; pub fn test_model() -> Model { Model { diff --git a/crates/machine/tests/completion.rs b/crates/machine/tests/completion.rs new file mode 100644 index 00000000..9371ddd3 --- /dev/null +++ b/crates/machine/tests/completion.rs @@ -0,0 +1,646 @@ +use machine::completion::{build_request, decode, encode, encode_context}; +use machine::{Content, Fragment, Limit, Model, Protocol, Role}; +use rig::completion::message::{Reasoning, Text as RigText, ToolCall as RigToolCall, ToolFunction}; +use rig::completion::{AssistantContent, Message}; +use serde_json::json; + +fn tool_call_fragment() -> Fragment { + Fragment::tool_call("call_123", "shell", json!({"command": "ls"})) +} + +#[test] +fn encode_user_text_maps_to_user_message() { + let frag = Fragment::user("hello"); + let msg = encode(&frag, false).expect("user encodes"); + assert!(matches!(msg, Message::User { .. })); +} + +#[test] +fn encode_assistant_text_maps_to_assistant_message() { + let frag = Fragment::assistant("ok"); + let msg = encode(&frag, false).expect("assistant encodes"); + assert!(matches!(msg, Message::Assistant { .. })); +} + +#[test] +fn encode_tool_result_maps_to_user_tool_result() { + let frag = Fragment::tool_result("call_123", "stdout content", None); + let msg = encode(&frag, false).expect("tool result encodes"); + // rig wraps tool results as User messages carrying ToolResult content. + assert!(matches!(msg, Message::User { .. })); +} + +#[test] +fn encode_context_drops_assistant_transport_hitches_for_exact_retry() { + let fragments = vec![ + Fragment::system("system"), + Fragment::user("request"), + Fragment::hitch( + "HTTP 504 Gateway Timeout", + Some(504), + Role::Assistant, + None::<&str>, + ), + ]; + + let messages = encode_context(&fragments, false); + + assert_eq!(messages.len(), 2); + assert!(matches!(&messages[0], Message::System { .. })); + assert!(matches!(&messages[1], Message::User { .. })); +} + +#[test] +fn encode_context_reconstructs_parallel_tool_call_turn() { + let reasoning = "Search both aspects before drafting"; + let fragments = vec![ + Fragment::system("system"), + Fragment::user("request"), + Fragment::tool_call("call_1", "search", json!({"query": "first"})) + .with_reasoning(reasoning), + Fragment::tool_result("call_1", "first result", None), + Fragment::tool_call("call_2", "search", json!({"query": "second"})) + .with_reasoning(reasoning), + Fragment::tool_result("call_2", "second result", None), + ]; + + let messages = encode_context(&fragments, false); + + assert_eq!(messages.len(), 5); + let Message::Assistant { content, .. } = &messages[2] else { + panic!("parallel calls must be reconstructed as one assistant message"); + }; + assert_eq!( + content + .iter() + .filter(|item| matches!(item, AssistantContent::ToolCall(_))) + .count(), + 2, + ); + assert_eq!( + content + .iter() + .filter(|item| matches!(item, AssistantContent::Reasoning(_))) + .count(), + 1, + ); + assert!(matches!(&messages[3], Message::User { .. })); + assert!(matches!(&messages[4], Message::User { .. })); +} + +#[test] +fn encode_context_reconstructs_text_and_tool_call_as_one_assistant_turn() { + let reasoning = "Read the canary input before writing the output"; + let response = [ + assistant_reasoning(reasoning), + AssistantContent::Text(RigText { + text: "I will read the fixed input first.".into(), + }), + assistant_tool_call("call_1", "fs"), + ]; + let mut fragments = vec![Fragment::system("system"), Fragment::user("request")]; + fragments.extend(decode(response.iter())); + fragments.push(Fragment::tool_result("call_1", "CANARY INPUT", None)); + + let messages = encode_context(&fragments, true); + + assert_eq!(messages.len(), 4); + let Message::Assistant { content, .. } = &messages[2] else { + panic!("mixed response must be reconstructed as one assistant message"); + }; + assert_eq!( + content + .iter() + .filter(|item| matches!(item, AssistantContent::Text(_))) + .count(), + 1, + ); + assert_eq!( + content + .iter() + .filter(|item| matches!(item, AssistantContent::ToolCall(_))) + .count(), + 1, + ); + assert!(content.iter().any(|item| { + matches!(item, AssistantContent::Reasoning(value) if value.display_text() == reasoning) + })); + assert!(matches!(&messages[3], Message::User { .. })); +} + +#[test] +fn encode_context_reconstructs_mixed_turn_with_failed_tool_result() { + let reasoning = "Read the input before writing the output"; + let fragments = vec![ + Fragment::system("system"), + Fragment::user("request"), + Fragment::assistant("I will read the input first."), + Fragment::tool_call("call_1", "fs", json!({"path": "input.txt"})).with_reasoning(reasoning), + Fragment::hitch( + "tool request timed out", + Some(504), + Role::Tool, + Some("call_1"), + ), + ]; + + let messages = encode_context(&fragments, true); + + assert_eq!(messages.len(), 4); + let Message::Assistant { content, .. } = &messages[2] else { + panic!("failed mixed turn must remain one assistant message"); + }; + assert_eq!( + content + .iter() + .filter(|item| matches!(item, AssistantContent::Text(_))) + .count(), + 1, + ); + assert_eq!( + content + .iter() + .filter(|item| matches!(item, AssistantContent::ToolCall(_))) + .count(), + 1, + ); + assert!(content.iter().any(|item| { + matches!(item, AssistantContent::Reasoning(value) if value.display_text() == reasoning) + })); + assert!(matches!(&messages[3], Message::User { .. })); +} + +#[test] +fn encode_context_reconstructs_parallel_turn_with_failed_tool_result() { + let reasoning = "Search both sources before drafting"; + let fragments = vec![ + Fragment::system("system"), + Fragment::user("request"), + Fragment::tool_call("call_1", "search", json!({"query": "first"})) + .with_reasoning(reasoning), + Fragment::tool_result("call_1", "first result", None), + Fragment::tool_call("call_2", "search", json!({"query": "second"})) + .with_reasoning(reasoning), + Fragment::hitch( + "upstream search unavailable", + Some(503), + Role::Tool, + Some("call_2"), + ), + ]; + + let messages = encode_context(&fragments, true); + + assert_eq!(messages.len(), 5); + let Message::Assistant { content, .. } = &messages[2] else { + panic!("failed parallel turn must remain one assistant message"); + }; + assert_eq!( + content + .iter() + .filter(|item| matches!(item, AssistantContent::ToolCall(_))) + .count(), + 2, + ); + assert!(content.iter().any(|item| { + matches!(item, AssistantContent::Reasoning(value) if value.display_text() == reasoning) + })); + assert!(matches!(&messages[3], Message::User { .. })); + assert!(matches!(&messages[4], Message::User { .. })); +} + +#[test] +fn encode_tool_call_without_thinking_omits_reasoning() { + let frag = tool_call_fragment(); + let msg = encode(&frag, false).expect("tool call encodes"); + + let Message::Assistant { content, .. } = msg else { + panic!("expected Assistant message for tool call"); + }; + let has_reasoning = content + .iter() + .any(|item| matches!(item, AssistantContent::Reasoning(_))); + assert!( + !has_reasoning, + "thinking=false must not attach reasoning placeholder — would pollute providers like DeepSeek/OpenAI" + ); + let has_tool_call = content + .iter() + .any(|item| matches!(item, AssistantContent::ToolCall(_))); + assert!(has_tool_call, "tool_call content still present"); +} + +#[test] +fn encode_tool_call_with_thinking_attaches_reasoning_placeholder() { + let frag = tool_call_fragment(); + let msg = encode(&frag, true).expect("tool call encodes"); + + let Message::Assistant { content, .. } = msg else { + panic!("expected Assistant message for tool call"); + }; + let reasoning_count = content + .iter() + .filter(|item| matches!(item, AssistantContent::Reasoning(_))) + .count(); + assert_eq!( + reasoning_count, 1, + "thinking=true must attach exactly one reasoning placeholder for providers like Kimi" + ); + let tool_call_count = content + .iter() + .filter(|item| matches!(item, AssistantContent::ToolCall(_))) + .count(); + assert_eq!( + tool_call_count, 1, + "tool_call content preserved alongside reasoning" + ); +} + +/// DeepSeek thinking mode rejects requests whose assistant tool-call turn +/// carries a placeholder reasoning. When the fragment was decoded from a real +/// LLM response, `encode` must replay the model's original reasoning text +/// verbatim — not the `.` stub. Regression for the 400 loop observed when +/// running grpc_demo against `deepseek-v4-flash`. +#[test] +fn encode_tool_call_emits_stored_reasoning_not_placeholder() { + let frag = Fragment::tool_call("call_x", "shell", json!({"command": "ls"})) + .with_reasoning("the user asked for a directory listing"); + + for thinking in [true, false] { + let msg = encode(&frag, thinking).expect("encodes"); + let Message::Assistant { content, .. } = msg else { + panic!("expected Assistant message"); + }; + let reasoning_texts: Vec<String> = content + .iter() + .filter_map(|item| match item { + AssistantContent::Reasoning(r) => Some(r.display_text()), + _ => None, + }) + .collect(); + assert_eq!( + reasoning_texts.len(), + 1, + "thinking={thinking}: exactly one reasoning block emitted", + ); + assert_eq!( + reasoning_texts[0], "the user asked for a directory listing", + "thinking={thinking}: reasoning text must come from fragment, not '.'", + ); + } +} + +/// Symmetric: text-only assistant fragments never carry reasoning, even when +/// `thinking=true`. The placeholder is tool-call-only. +#[test] +fn encode_assistant_text_with_thinking_does_not_synthesize_reasoning() { + let frag = Fragment::assistant("here is your answer"); + let msg = encode(&frag, true).expect("encodes"); + let Message::Assistant { content, .. } = msg else { + panic!("expected Assistant message"); + }; + assert!( + !content + .iter() + .any(|item| matches!(item, AssistantContent::Reasoning(_))), + "text assistant turns must not gain reasoning content", + ); +} + +#[test] +fn encode_assistant_non_tool_call_ignores_thinking_flag() { + let frag = Fragment::assistant("plain text response"); + let msg_off = encode(&frag, false).expect("encodes"); + let msg_on = encode(&frag, true).expect("encodes"); + + // Plain assistant text never gets a Reasoning attached — that path is + // tool-call-only. + for (label, msg) in [("thinking=false", msg_off), ("thinking=true", msg_on)] { + let Message::Assistant { content, .. } = msg else { + panic!("expected Assistant message ({label})"); + }; + let has_reasoning = content + .iter() + .any(|item| matches!(item, AssistantContent::Reasoning(_))); + assert!( + !has_reasoning, + "{label}: plain assistant text must not gain reasoning" + ); + } +} + +// ── Hitch encoding (issue #43 subproblem 3) ── + +#[test] +fn encode_system_hitch_maps_to_system_message() { + let frag = Fragment::hitch( + "model 'xyz' not registered", + None, + machine::Role::System, + None::<&str>, + ); + let msg = encode(&frag, false).expect("system hitch encodes"); + assert!(matches!(msg, Message::System { .. })); + if let Message::System { content } = &msg { + assert!(content.contains("not registered")); + } +} + +#[test] +fn encode_assistant_hitch_maps_to_assistant_message() { + let frag = Fragment::hitch( + "HttpError: 502 Bad Gateway", + None, + machine::Role::Assistant, + None::<&str>, + ); + let msg = encode(&frag, false).expect("assistant hitch encodes"); + assert!(matches!(msg, Message::Assistant { .. })); + if let Message::Assistant { content, .. } = &msg { + let texts: Vec<_> = content + .iter() + .filter_map(|c| { + if let AssistantContent::Text(t) = c { + Some(t.text.as_str()) + } else { + None + } + }) + .collect(); + assert!(texts.iter().any(|t| t.contains("502"))); + } +} + +#[test] +fn encode_tool_hitch_without_call_id_returns_none() { + let frag = Fragment::hitch( + "tool 'unknown' not found", + None, + machine::Role::Tool, + None::<&str>, + ); + let msg = encode(&frag, false); + assert!( + msg.is_none(), + "Tool hitch without call_id cannot be encoded" + ); +} + +#[test] +fn encode_system_text_unchanged_alongside_hitch() { + let frag = Fragment::system("you are a helper"); + let msg = encode(&frag, false).expect("system encodes"); + assert!(matches!(msg, Message::System { .. })); + if let Message::System { content } = &msg { + assert_eq!(content, "you are a helper"); + } +} + +// ── build_request: chat_history shape ── + +fn dummy_model() -> Model { + Model { + name: "test".into(), + protocol: Protocol::OpenAI, + ..Default::default() + } +} + +/// The whole point of building `CompletionRequest` directly: the +/// `chat_history` we send to the provider matches input order byte-for-byte. +/// Guards against the #43 / #86 regressions where a builder-managed +/// "initial prompt" rotated messages. +#[test] +fn build_request_preserves_message_order() { + let msgs = vec![ + Message::system("you are a helper"), + Message::user("search for papers"), + Message::assistant("let me check"), + ]; + + let req = build_request(&msgs, &[], &dummy_model()).expect("non-empty builds"); + let history: Vec<Message> = req.chat_history.into_iter().collect(); + + assert_eq!(history.len(), 3, "no message dropped or duplicated"); + assert!( + matches!(&history[0], Message::System { content } if content == "you are a helper"), + "system message stays at the head" + ); + assert!(matches!(&history[1], Message::User { .. })); + assert!(matches!(&history[2], Message::Assistant { .. })); +} + +#[test] +fn build_request_merges_adjacent_system_messages_without_reordering_turns() { + let messages = vec![ + Message::system("agent instructions"), + Message::system("cwd: .\nplatform: linux"), + Message::user("search for papers"), + Message::system("later instruction"), + ]; + + let request = build_request(&messages, &[], &dummy_model()).expect("non-empty builds"); + let history: Vec<Message> = request.chat_history.into_iter().collect(); + + assert_eq!(history.len(), 3); + assert!(matches!( + &history[0], + Message::System { content } + if content == "agent instructions\n\ncwd: .\nplatform: linux" + )); + assert!(matches!(&history[1], Message::User { .. })); + assert!(matches!( + &history[2], + Message::System { content } if content == "later instruction" + )); +} + +#[test] +fn build_request_empty_messages_returns_hitch() { + let result = build_request(&[], &[], &dummy_model()); + let Err(hitch) = result else { + panic!("expected Err(hitch) for empty messages"); + }; + assert!(matches!(hitch.role, Role::System)); + assert!( + matches!(&hitch.content, Content::Hitch { message, .. } if message.contains("empty context")) + ); +} + +#[test] +fn build_request_single_message_chat_history_has_one() { + let msgs = vec![Message::user("hello")]; + let req = build_request(&msgs, &[], &dummy_model()).expect("single builds"); + let history: Vec<Message> = req.chat_history.into_iter().collect(); + assert_eq!(history.len(), 1); + assert!(matches!(&history[0], Message::User { .. })); +} + +#[test] +fn build_request_passes_model_temperature_and_max_tokens() { + let mut model = dummy_model(); + model.temperature = Some(0.42); + model.limit = Some(Limit { + context: 100_000, + input: None, + output: 4096, + }); + + let msgs = vec![Message::user("hi")]; + let req = build_request(&msgs, &[], &model).expect("builds"); + + assert_eq!(req.temperature, Some(0.42)); + assert_eq!(req.max_tokens, Some(4096)); +} + +// ── decode: reasoning preservation ── + +fn assistant_reasoning(text: &str) -> AssistantContent { + AssistantContent::Reasoning(Reasoning::new(text)) +} + +fn assistant_tool_call(id: &str, name: &str) -> AssistantContent { + AssistantContent::ToolCall(RigToolCall { + id: id.into(), + call_id: None, + function: ToolFunction { + name: name.into(), + arguments: json!({}), + }, + signature: None, + additional_params: None, + }) +} + +/// Regression for the deepseek-v4-flash 400 loop: when the model emits +/// reasoning followed by a tool_call in the same turn, decode must store +/// the reasoning on the ToolCall fragment so the next request can echo it. +#[test] +fn decode_attaches_reasoning_to_following_tool_call() { + let response = [ + assistant_reasoning("I should list the files"), + assistant_tool_call("call_1", "shell"), + ]; + + let fragments = decode(response.iter()); + + assert_eq!(fragments.len(), 1, "reasoning is folded into the tool call"); + let Content::ToolCall(tc) = &fragments[0].content else { + panic!("expected ToolCall content"); + }; + assert_eq!(tc.reasoning.as_deref(), Some("I should list the files")); +} + +#[test] +fn decode_concatenates_multi_block_reasoning_before_tool_call() { + let response = [ + assistant_reasoning("first thought"), + assistant_reasoning("refinement"), + assistant_tool_call("call_1", "shell"), + ]; + + let fragments = decode(response.iter()); + + let Content::ToolCall(tc) = &fragments[0].content else { + panic!("expected ToolCall"); + }; + assert_eq!(tc.reasoning.as_deref(), Some("first thought\nrefinement")); +} + +#[test] +fn decode_discards_reasoning_before_text_turn() { + let response = [ + assistant_reasoning("musing aloud"), + AssistantContent::Text(RigText { + text: "here is the answer".into(), + }), + ]; + + let fragments = decode(response.iter()); + + assert_eq!(fragments.len(), 1); + assert!(matches!(&fragments[0].content, Content::Text(t) if t.text == "here is the answer")); +} + +#[test] +fn decode_preserves_reasoning_across_text_before_tool_call() { + let response = [ + assistant_reasoning("I should explain before writing"), + AssistantContent::Text(RigText { + text: "The schema is clear.".into(), + }), + assistant_tool_call("call_1", "fs"), + ]; + + let mut fragments = decode(response.iter()); + + assert_eq!(fragments.len(), 2); + assert!(matches!(&fragments[0].content, Content::Text(t) if t.text == "The schema is clear.")); + let Content::ToolCall(tool_call) = &fragments[1].content else { + panic!("expected ToolCall after visible assistant text"); + }; + assert_eq!( + tool_call.reasoning.as_deref(), + Some("I should explain before writing") + ); + + fragments.push(Fragment::tool_result("call_1", "written", None)); + let messages = encode_context(&fragments, true); + assert_eq!(messages.len(), 2); + let Message::Assistant { content, .. } = &messages[0] else { + panic!("expected reconstructed mixed assistant message"); + }; + assert!(content.iter().any(|item| { + matches!(item, AssistantContent::Text(text) if text.text == "The schema is clear.") + })); + assert!( + content + .iter() + .any(|item| matches!(item, AssistantContent::ToolCall(_))) + ); + assert!(content.iter().any(|item| { + matches!(item, AssistantContent::Reasoning(reasoning) + if reasoning.display_text() == "I should explain before writing") + })); +} + +#[test] +fn decode_tool_call_without_reasoning_has_none() { + let response = [assistant_tool_call("call_1", "shell")]; + + let fragments = decode(response.iter()); + + let Content::ToolCall(tc) = &fragments[0].content else { + panic!("expected ToolCall"); + }; + assert!(tc.reasoning.is_none(), "no reasoning emitted, none stored"); +} + +/// When the LLM emits parallel tool_calls in one turn (one reasoning +/// block, multiple tool_calls), every fragment must carry the same +/// reasoning. Otherwise the second fragment re-encodes with a placeholder +/// and providers like DeepSeek reject the request with HTTP 400 +/// "reasoning_content must be passed back". Regression for paper_digest +/// demo at 2026-05-27T14:10:36. +#[test] +fn decode_parallel_tool_calls_share_one_reasoning() { + let response = [ + assistant_reasoning("I will run two searches in parallel"), + assistant_tool_call("call_1", "arxiv_search"), + assistant_tool_call("call_2", "arxiv_search"), + ]; + + let fragments = decode(response.iter()); + + assert_eq!(fragments.len(), 2); + for (idx, frag) in fragments.iter().enumerate() { + let Content::ToolCall(tc) = &frag.content else { + panic!("fragment {idx} is not a ToolCall"); + }; + assert_eq!( + tc.reasoning.as_deref(), + Some("I will run two searches in parallel"), + "fragment {idx}: both tool_calls in the same turn must carry the same reasoning", + ); + } +} diff --git a/crates/machine/tests/completion_telemetry.rs b/crates/machine/tests/completion_telemetry.rs new file mode 100644 index 00000000..5d693ba2 --- /dev/null +++ b/crates/machine/tests/completion_telemetry.rs @@ -0,0 +1,69 @@ +use machine::event::completion_telemetry; +use machine::{Fragment, Role}; + +#[test] +fn successful_completion_has_no_failure_metadata() { + let telemetry = completion_telemetry(&[Fragment::assistant("done")]); + + assert_eq!(telemetry.outcome, "success"); + assert_eq!(telemetry.http_status, None); + assert_eq!(telemetry.failure_kind, None); + assert_eq!(telemetry.retryable, None); +} + +#[test] +fn http_failures_are_stably_classified() { + let cases = [ + (400, "invalid_request", false), + (401, "authentication", false), + (403, "authentication", false), + (408, "timeout", true), + (425, "provider_unavailable", true), + (429, "rate_limited", true), + (503, "provider_error", true), + ]; + + for (status, expected_kind, expected_retryable) in cases { + let telemetry = completion_telemetry(&[Fragment::hitch( + "provider response omitted from telemetry", + Some(status), + Role::Assistant, + None::<&str>, + )]); + + assert_eq!(telemetry.outcome, "failure"); + assert_eq!(telemetry.http_status, Some(status)); + assert_eq!(telemetry.failure_kind, Some(expected_kind)); + assert_eq!(telemetry.retryable, Some(expected_retryable)); + } +} + +#[test] +fn statusless_transport_failures_are_classified_without_exposing_the_message() { + let timeout = completion_telemetry(&[Fragment::hitch( + "request timed out after 240s", + None, + Role::Assistant, + None::<&str>, + )]); + assert_eq!(timeout.failure_kind, Some("timeout")); + assert_eq!(timeout.retryable, Some(true)); + + let network = completion_telemetry(&[Fragment::hitch( + "error sending request: connection reset", + None, + Role::Assistant, + None::<&str>, + )]); + assert_eq!(network.failure_kind, Some("network")); + assert_eq!(network.retryable, Some(true)); + + let configuration = completion_telemetry(&[Fragment::hitch( + "no active model set", + None, + Role::System, + None::<&str>, + )]); + assert_eq!(configuration.failure_kind, Some("configuration")); + assert_eq!(configuration.retryable, Some(false)); +} diff --git a/crates/machine/tests/context.rs b/crates/machine/tests/context.rs index ce39c58e..810a6fb3 100644 --- a/crates/machine/tests/context.rs +++ b/crates/machine/tests/context.rs @@ -1,4 +1,4 @@ -use machine::{Context, Fragment}; +use machine::{Context, ContextIdNotFound, Fragment}; #[test] fn empty_context() { @@ -21,7 +21,7 @@ fn append_assigns_ids() { fn ids_never_reused_after_remove() { let mut ctx = Context::new(); let id1 = ctx.append(Fragment::system("a")); - ctx.remove(id1); + ctx.remove(id1).unwrap(); let id2 = ctx.append(Fragment::system("b")); assert_ne!(id1, id2); assert_eq!(id2, 2); @@ -31,49 +31,49 @@ fn ids_never_reused_after_remove() { fn insert_after_id() { let mut ctx = Context::new(); let sys = ctx.append(Fragment::system("a")); - let user = ctx.insert(sys, Fragment::user("b")); + let user = ctx.insert(sys, Fragment::user("b")).unwrap(); assert_eq!(ctx.len(), 2); assert_eq!(ctx.get(sys).unwrap().as_text(), Some("a")); assert_eq!(ctx.get(user).unwrap().as_text(), Some("b")); } #[test] -#[should_panic(expected = "not found")] -fn insert_unknown_id_panics() { +fn insert_unknown_id_returns_error() { let mut ctx = Context::new(); - ctx.insert(999, Fragment::user("x")); + let err = ctx.insert(999, Fragment::user("x")).unwrap_err(); + assert_eq!(err, ContextIdNotFound(999)); } #[test] fn replace_preserves_id() { let mut ctx = Context::new(); let id = ctx.append(Fragment::system("old")); - ctx.replace(id, Fragment::system("new")); + ctx.replace(id, Fragment::system("new")).unwrap(); assert_eq!(ctx.get(id).unwrap().as_text(), Some("new")); assert_eq!(ctx.get(id).unwrap().id(), id); } #[test] -#[should_panic(expected = "not found")] -fn replace_unknown_panics() { +fn replace_unknown_returns_error() { let mut ctx = Context::new(); - ctx.replace(999, Fragment::user("x")); + let err = ctx.replace(999, Fragment::user("x")).unwrap_err(); + assert_eq!(err, ContextIdNotFound(999)); } #[test] fn remove_single_and_verify() { let mut ctx = Context::new(); let id = ctx.append(Fragment::system("a")); - ctx.remove(id); + ctx.remove(id).unwrap(); assert!(ctx.get(id).is_none()); assert_eq!(ctx.len(), 0); } #[test] -#[should_panic(expected = "not found")] -fn remove_unknown_panics() { +fn remove_unknown_returns_error() { let mut ctx = Context::new(); - ctx.remove(999); + let err = ctx.remove(999).unwrap_err(); + assert_eq!(err, ContextIdNotFound(999)); } #[test] @@ -104,3 +104,29 @@ fn fragments_in_order() { assert_eq!(frags[1].id(), id2); assert_eq!(frags[2].id(), id3); } + +#[test] +fn swap_unknown_returns_error() { + let mut ctx = Context::new(); + let id = ctx.append(Fragment::system("a")); + let err = ctx.swap(id, 999).unwrap_err(); + assert_eq!(err, ContextIdNotFound(999)); +} + +#[test] +fn swap_works() { + let mut ctx = Context::new(); + let a = ctx.append(Fragment::system("a")); + let b = ctx.append(Fragment::user("b")); + ctx.swap(a, b).unwrap(); + assert_eq!(ctx.position_of(a), Some(1)); + assert_eq!(ctx.position_of(b), Some(0)); + assert_eq!(ctx.fragments()[0].id(), b); + assert_eq!(ctx.fragments()[1].id(), a); +} + +#[test] +fn display_not_found() { + let err = ContextIdNotFound(42); + assert_eq!(err.to_string(), "fragment id 42 not found in context"); +} diff --git a/crates/machine/tests/env.rs b/crates/machine/tests/env.rs new file mode 100644 index 00000000..819a4725 --- /dev/null +++ b/crates/machine/tests/env.rs @@ -0,0 +1,88 @@ +//! Tests for `Environment::new` (host snapshot) vs `Environment::empty` +//! (sandbox). The contract: `new` must give the agent an honest view of the +//! host; `empty` must give a deliberately blank slate. + +use machine::Environment; +use std::sync::Mutex; + +/// Env-mutating tests in this file race against any other test that touches +/// the same vars; serialize them through a module-level mutex. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +#[test] +fn new_inherits_host_path() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let key = "RCM_ENV_TEST_NEW_INHERITS"; + unsafe { + std::env::set_var(key, "yes"); + } + let env = Environment::new("."); + let inherited = env.vars.get(key).map(|s| s.as_str()); + unsafe { + std::env::remove_var(key); + } + assert_eq!(inherited, Some("yes")); +} + +#[test] +fn new_inherits_path_for_subprocess_use() { + // PATH is the canonical example; without it shell tools can't find any + // binary. This is the bug shell tool was hitting before the fix. + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let host_path = std::env::var("PATH").ok(); + let env = Environment::new("."); + if host_path.is_some() { + assert!( + env.vars.contains_key("PATH"), + "Environment::new must inherit PATH from the host" + ); + } +} + +#[test] +fn empty_keeps_vars_empty() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let env = Environment::empty("."); + assert!( + env.vars.is_empty(), + "Environment::empty must not inherit vars" + ); +} + +#[test] +fn empty_does_not_leak_when_host_has_vars() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let key = "RCM_ENV_TEST_EMPTY_NO_LEAK"; + unsafe { + std::env::set_var(key, "leaked"); + } + let env = Environment::empty("."); + unsafe { + std::env::remove_var(key); + } + assert!(!env.vars.contains_key(key)); +} + +#[test] +fn named_inherits_too() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let key = "RCM_ENV_TEST_NAMED_INHERITS"; + unsafe { + std::env::set_var(key, "yes"); + } + let env = Environment::named("scope", "."); + let inherited = env.vars.get(key).map(|s| s.as_str()); + unsafe { + std::env::remove_var(key); + } + assert_eq!(env.name.as_str(), "scope"); + assert_eq!(inherited, Some("yes")); +} + +#[test] +fn empty_named_keeps_vars_empty() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let env = Environment::empty_named("sandbox", "."); + assert_eq!(env.name.as_str(), "sandbox"); + assert!(env.vars.is_empty()); +} diff --git a/crates/machine/tests/fragment.rs b/crates/machine/tests/fragment.rs index f83a47bf..b9552144 100644 --- a/crates/machine/tests/fragment.rs +++ b/crates/machine/tests/fragment.rs @@ -20,7 +20,7 @@ fn as_text_none_for_nontinuitive_types() { #[test] fn hitch_content() { - let f = Fragment::hitch("broken"); + let f = Fragment::hitch("broken", None, Role::System, None::<&str>); assert!(matches!(f.content, Content::Hitch { message, .. } if message == "broken")); assert_eq!(f.role, Role::System); } @@ -37,3 +37,29 @@ fn tool_call_preserves_data() { let f = Fragment::tool_call("tc1", "add", serde_json::json!({"a": 1})); assert!(matches!(f.content, Content::ToolCall(ref tc) if tc.id == "tc1" && tc.name == "add")); } + +#[test] +fn hitch_with_call_id() { + let f = Fragment::hitch("tool error", None, Role::Tool, Some("tc_42")); + assert!(matches!( + f.content, + Content::Hitch { + message, + call_id: Some(ref id), + .. + } if message == "tool error" && id == "tc_42" + )); +} + +#[test] +fn hitch_without_call_id() { + let f = Fragment::hitch("network error", None, Role::Assistant, None::<&str>); + assert!(matches!( + f.content, + Content::Hitch { + message, + call_id: None, + .. + } if message == "network error" + )); +} diff --git a/crates/machine/tests/inbox.rs b/crates/machine/tests/inbox.rs index 8400c0fd..f14b554f 100644 --- a/crates/machine/tests/inbox.rs +++ b/crates/machine/tests/inbox.rs @@ -7,9 +7,9 @@ fn fifo_order() { inbox.push(Fragment::user("second")); inbox.push(Fragment::assistant("third")); - assert_eq!(inbox.pop().unwrap().as_text(), Some("first")); - assert_eq!(inbox.pop().unwrap().as_text(), Some("second")); - assert_eq!(inbox.pop().unwrap().as_text(), Some("third")); + assert_eq!(inbox.pop().unwrap().fragment.as_text(), Some("first")); + assert_eq!(inbox.pop().unwrap().fragment.as_text(), Some("second")); + assert_eq!(inbox.pop().unwrap().fragment.as_text(), Some("third")); assert!(inbox.is_empty()); } @@ -23,7 +23,7 @@ fn pop_empty_is_none() { fn peek_preserves_length() { let mut inbox = Inbox::new(); inbox.push(Fragment::user("q")); - assert_eq!(inbox.peek().unwrap().as_text(), Some("q")); + assert_eq!(inbox.peek().unwrap().fragment.as_text(), Some("q")); assert_eq!(inbox.len(), 1); } diff --git a/crates/machine/tests/machine.rs b/crates/machine/tests/machine.rs index 1fb8f1a6..e119eb9f 100644 --- a/crates/machine/tests/machine.rs +++ b/crates/machine/tests/machine.rs @@ -1,103 +1,94 @@ mod common; -use machine::{Action, Content, Context, Environment, Fragment, Inbox, Machine, Purpose}; +use machine::{ + Action, ExecutionMode, Fragment, Inbox, Machine, MachineState, Overlay, ToolRuntime, +}; + use serde_json::json; +async fn run_actions(actions: &[Action]) -> MachineState { + let mut state = MachineState::default(); + state.run.environment = machine::Environment::new("/tmp"); + state.run.resources = common::test_resources(); + let mut machine = Machine::new("test", "test-machine"); + let tool_runtime = ToolRuntime::new(); + for action in actions { + let result = machine + .apply( + action.clone(), + &mut state, + ExecutionMode::Live { + tool_runtime: &tool_runtime, + overlay: &Overlay::default(), + }, + ) + .await; + if result.done { + break; + } + } + state +} + #[tokio::test] async fn done_stops_immediately() { - let policy = common::SeqPolicy::new(vec![Action::Done]); - let machine = Machine::new(Box::new(policy)); - let mut ctx = Context::new(); - let mut env = Environment::new("/tmp"); - let mut resources = common::test_resources(); - - machine - .run(&Purpose::default(), &mut ctx, &mut env, &mut resources) - .await; - assert!(ctx.is_empty()); + let state = run_actions(&[Action::Done, Action::Append(Fragment::user("ignored"))]).await; + assert!(state.run.context.is_empty()); + assert!(state.frame.status.is_done()); } #[tokio::test] async fn append_and_take_flow() { - let policy = common::SeqPolicy::new(vec![ + let state = run_actions(&[ Action::Append(Fragment::system("sys")), Action::Append(Fragment::user("hello")), Action::Done, - ]); - let machine = Machine::new(Box::new(policy)); - let mut ctx = Context::new(); - let mut env = Environment::new("/tmp"); - let mut resources = common::test_resources(); - - machine - .run(&Purpose::default(), &mut ctx, &mut env, &mut resources) - .await; - assert_eq!(ctx.len(), 2); - assert_eq!(ctx.fragments()[0].as_text(), Some("sys")); - assert_eq!(ctx.fragments()[1].as_text(), Some("hello")); + ]) + .await; + assert_eq!(state.run.context.len(), 2); + assert_eq!(state.run.context.fragments()[0].as_text(), Some("sys")); + assert_eq!(state.run.context.fragments()[1].as_text(), Some("hello")); } #[tokio::test] async fn take_empty_inbox_is_noop() { - let policy = common::SeqPolicy::new(vec![Action::Take, Action::Done]); - let machine = Machine::new(Box::new(policy)); - let mut ctx = Context::new(); - let mut env = Environment::new("/tmp"); - let mut resources = common::test_resources(); - - machine - .run(&Purpose::default(), &mut ctx, &mut env, &mut resources) - .await; - assert!(ctx.is_empty()); + let state = run_actions(&[Action::Take, Action::Done]).await; + assert!(state.run.context.is_empty()); } #[tokio::test] async fn swap_preserves_count() { - let policy = common::SeqPolicy::new(vec![ + let state = run_actions(&[ Action::Append(Fragment::system("first")), Action::Append(Fragment::system("second")), Action::Swap(1, 2), Action::Done, - ]); - let machine = Machine::new(Box::new(policy)); - let mut ctx = Context::new(); - let mut env = Environment::new("/tmp"); - let mut resources = common::test_resources(); - - machine - .run(&Purpose::default(), &mut ctx, &mut env, &mut resources) - .await; - assert_eq!(ctx.len(), 2); - assert_eq!(ctx.fragments()[0].as_text(), Some("second")); - assert_eq!(ctx.fragments()[1].as_text(), Some("first")); + ]) + .await; + assert_eq!(state.run.context.len(), 2); + assert_eq!(state.run.context.fragments()[0].as_text(), Some("second")); + assert_eq!(state.run.context.fragments()[1].as_text(), Some("first")); } #[tokio::test] async fn replace_preserves_id() { - let policy = common::SeqPolicy::new(vec![ + let state = run_actions(&[ Action::Append(Fragment::system("old")), Action::Replace { id: 1, fragment: Fragment::system("new"), }, Action::Done, - ]); - let machine = Machine::new(Box::new(policy)); - let mut ctx = Context::new(); - let mut env = Environment::new("/tmp"); - let mut resources = common::test_resources(); - - machine - .run(&Purpose::default(), &mut ctx, &mut env, &mut resources) - .await; - assert_eq!(ctx.len(), 1); - assert_eq!(ctx.fragments()[0].as_text(), Some("new")); - assert_eq!(ctx.fragments()[0].id(), 1); + ]) + .await; + assert_eq!(state.run.context.len(), 1); + assert_eq!(state.run.context.fragments()[0].as_text(), Some("new")); + assert_eq!(state.run.context.fragments()[0].id(), 1); } #[tokio::test] async fn insert_after_id() { - let policy = common::SeqPolicy::new(vec![ + let state = run_actions(&[ Action::Append(Fragment::system("first")), Action::Append(Fragment::system("third")), Action::Insert { @@ -105,95 +96,180 @@ async fn insert_after_id() { fragment: Fragment::system("second"), }, Action::Done, - ]); - let machine = Machine::new(Box::new(policy)); - let mut ctx = Context::new(); - let mut env = Environment::new("/tmp"); - let mut resources = common::test_resources(); - - machine - .run(&Purpose::default(), &mut ctx, &mut env, &mut resources) - .await; - assert_eq!(ctx.len(), 3); - assert_eq!(ctx.fragments()[0].as_text(), Some("first")); - assert_eq!(ctx.fragments()[1].as_text(), Some("second")); - assert_eq!(ctx.fragments()[2].as_text(), Some("third")); + ]) + .await; + assert_eq!(state.run.context.len(), 3); + assert_eq!(state.run.context.fragments()[0].as_text(), Some("first")); + assert_eq!(state.run.context.fragments()[1].as_text(), Some("second")); + assert_eq!(state.run.context.fragments()[2].as_text(), Some("third")); } #[tokio::test] async fn remove_and_check_context() { - let policy = common::SeqPolicy::new(vec![ + let state = run_actions(&[ Action::Append(Fragment::system("a")), Action::Append(Fragment::user("b")), Action::Remove(1), Action::Done, - ]); - let machine = Machine::new(Box::new(policy)); - let mut ctx = Context::new(); - let mut env = Environment::new("/tmp"); - let mut resources = common::test_resources(); + ]) + .await; + assert_eq!(state.run.context.len(), 1); + assert_eq!(state.run.context.fragments()[0].as_text(), Some("b")); +} - machine - .run(&Purpose::default(), &mut ctx, &mut env, &mut resources) - .await; - assert_eq!(ctx.len(), 1); - assert_eq!(ctx.fragments()[0].as_text(), Some("b")); +#[tokio::test] +async fn remove_unknown_returns_hitch() { + let state = run_actions(&[ + Action::Append(Fragment::system("existing")), + Action::Remove(999), + ]) + .await; + assert_eq!(state.frame.inbox.len(), 1); + let fragment = &state.frame.inbox.peek().unwrap().fragment; + assert!(matches!(fragment.content, machine::Content::Hitch { .. })); + assert!( + fragment + .content_as_text() + .contains("fragment id 999 not found") + ); } #[tokio::test] -#[should_panic(expected = "not found")] -async fn remove_unknown_panics() { - let policy = common::SeqPolicy::new(vec![Action::Remove(999), Action::Done]); - let machine = Machine::new(Box::new(policy)); - let mut ctx = Context::new(); - let mut env = Environment::new("/tmp"); - let mut resources = common::test_resources(); +async fn take_drains_inbox_into_context() { + let mut state = MachineState::default(); + state.run.environment = machine::Environment::new("/tmp"); + state.run.resources = common::test_resources(); + state.frame.inbox.push(Fragment::assistant("reply")); + state + .frame + .inbox + .push(Fragment::tool_result("1", "5", None)); + let mut machine = Machine::new("test", "test-machine"); + let tool_runtime = ToolRuntime::new(); machine - .run(&Purpose::default(), &mut ctx, &mut env, &mut resources) + .apply( + Action::Take, + &mut state, + ExecutionMode::Live { + tool_runtime: &tool_runtime, + overlay: &Overlay::default(), + }, + ) + .await; + machine + .apply( + Action::Take, + &mut state, + ExecutionMode::Live { + tool_runtime: &tool_runtime, + overlay: &Overlay::default(), + }, + ) .await; -} -// ── Mixed-content response flow ── + assert_eq!(state.run.context.len(), 2); + assert_eq!(state.run.context.fragments()[0].as_text(), Some("reply")); + assert!(matches!( + state.run.context.fragments()[1].content, + machine::Content::ToolResult(_) + )); + assert!(state.frame.inbox.is_empty()); +} #[test] fn context_holds_mixed_assistant_fragments() { - let mut ctx = Context::new(); - let id1 = ctx.append(Fragment::assistant("我来计算")); - let id2 = ctx.append(Fragment::tool_call( + let mut context = machine::Context::new(); + let text_id = context.append(Fragment::assistant("我来计算")); + let call_id = context.append(Fragment::tool_call( "call_1", "add", json!({"a": 3, "b": 2}), )); - assert_eq!(ctx.len(), 2); - assert_eq!(ctx.get(id1).unwrap().as_text(), Some("我来计算")); + assert_eq!(context.len(), 2); + assert_eq!(context.get(text_id).unwrap().as_text(), Some("我来计算")); assert!(matches!( - ctx.get(id2).unwrap().content, - Content::ToolCall(_) + context.get(call_id).unwrap().content, + machine::Content::ToolCall(_) )); } #[test] fn inbox_drain_order_with_mixed_response() { let mut inbox = Inbox::new(); - - // Simulate reactor output for a mixed LLM response: - // assistant text → tool call → tool result inbox.push(Fragment::assistant("我来计算")); inbox.push(Fragment::tool_call("1", "add", json!({"a": 3, "b": 2}))); inbox.push(Fragment::tool_result("1", "5", None)); - // Simulate Drain (Take) behavior - let mut ctx = Context::new(); + let mut context = machine::Context::new(); while inbox.peek().is_some() { - if let Some(frag) = inbox.pop() { - ctx.append(frag); + if let Some(item) = inbox.pop() { + context.append(item.fragment); } } - assert_eq!(ctx.len(), 3); - assert_eq!(ctx.fragments()[0].as_text(), Some("我来计算")); - assert!(matches!(ctx.fragments()[1].content, Content::ToolCall(_))); - assert!(matches!(ctx.fragments()[2].content, Content::ToolResult(_))); + assert_eq!(context.len(), 3); + assert_eq!(context.fragments()[0].as_text(), Some("我来计算")); + assert!(matches!( + context.fragments()[1].content, + machine::Content::ToolCall(_) + )); + assert!(matches!( + context.fragments()[2].content, + machine::Content::ToolResult(_) + )); +} + +#[test] +fn use_model_nonexistent_returns_error() { + let mut resources = common::test_resources(); + let error = resources.use_model("nonexistent").unwrap_err(); + assert_eq!(error.to_string(), "model 'nonexistent' not registered"); +} + +#[test] +fn enable_nonexistent_tool_returns_error() { + let mut resources = common::test_resources(); + let error = resources.enable("nonexistent").unwrap_err(); + assert_eq!(error.to_string(), "tool 'nonexistent' not registered"); +} + +#[tokio::test] +async fn dispatch_model_nonexistent_pushes_hitch() { + let state = run_actions(&[Action::Model("nonexistent".to_string())]).await; + assert_eq!(state.frame.inbox.len(), 1); + let fragment = &state.frame.inbox.peek().unwrap().fragment; + assert!(matches!(fragment.content, machine::Content::Hitch { .. })); + assert_eq!(fragment.role, machine::Role::System); + assert!(fragment.content_as_text().contains("nonexistent")); +} + +#[tokio::test] +async fn dispatch_activate_nonexistent_pushes_hitch() { + let state = run_actions(&[Action::Activate("unknown".to_string())]).await; + assert_eq!(state.frame.inbox.len(), 1); + let fragment = &state.frame.inbox.peek().unwrap().fragment; + assert!(matches!(fragment.content, machine::Content::Hitch { .. })); + assert_eq!(fragment.role, machine::Role::System); + assert!(fragment.content_as_text().contains("unknown")); +} + +#[tokio::test] +async fn complete_no_active_model_returns_hitch() { + let mut context = machine::Context::new(); + context.append(Fragment::user("hello")); + let mut resources = common::test_resources(); + resources.deactivate_model(); + + let (fragments, tokens) = + machine::completion::complete(&context, &resources, &Overlay::default()).await; + assert_eq!(fragments.len(), 1); + assert_eq!(fragments[0].role, machine::Role::System); + assert_eq!(tokens.total_tokens, 0); + assert!(matches!( + fragments[0].content, + machine::Content::Hitch { .. } + )); + assert!(fragments[0].content_as_text().contains("no active model")); } diff --git a/crates/machine/tests/multi_modal.rs b/crates/machine/tests/multi_modal.rs new file mode 100644 index 00000000..c7811223 --- /dev/null +++ b/crates/machine/tests/multi_modal.rs @@ -0,0 +1,136 @@ +use machine::Fragment; +use machine::completion::encode; +use machine::fragment::DataSource; +use rig::completion::Message; +use rig::completion::message::UserContent; + +fn image_url() -> Fragment { + Fragment::image(DataSource::Url("https://example.com/img.png".into()), None) +} + +fn image_base64() -> Fragment { + Fragment::image(DataSource::Base64("SGVsbG8=".into()), None) +} + +fn image_raw() -> Fragment { + Fragment::image(DataSource::Raw(vec![0x89, 0x50, 0x4e, 0x47]), None) +} + +fn image_string() -> Fragment { + Fragment::image(DataSource::String("data:image/png;base64,abc".into()), None) +} + +fn audio_url() -> Fragment { + Fragment::audio( + DataSource::Url("https://example.com/audio.mp3".into()), + None, + ) +} + +fn audio_base64() -> Fragment { + Fragment::audio(DataSource::Base64("AAAA".into()), None) +} + +fn audio_raw() -> Fragment { + Fragment::audio(DataSource::Raw(vec![0xff, 0xfb, 0x90, 0x00]), None) +} + +fn document_base64() -> Fragment { + Fragment::document( + DataSource::Base64("JVBERi0xLjQK".into()), + Some("application/pdf".into()), + ) +} + +fn video_url() -> Fragment { + Fragment::video(DataSource::Url("https://example.com/vid.mp4".into()), None) +} + +/// Assert that encode returns Some(Message::User { content }) and that the +/// content contains exactly one item matching the expected UserContent variant. +fn assert_user_content(frag: &Fragment, kind: &str) { + let msg = encode(frag, false).expect("multi-modal fragment encodes"); + let Message::User { content } = &msg else { + panic!("expected Message::User for {kind}, got {:?}", msg); + }; + assert_eq!( + content.len(), + 1, + "content should have exactly 1 item for {kind}" + ); + let actual = content.iter().next().unwrap(); + let variant = match actual { + UserContent::Image(_) => "image", + UserContent::Audio(_) => "audio", + UserContent::Video(_) => "video", + UserContent::Document(_) => "document", + _ => "other", + }; + assert_eq!(variant, kind, "expected {kind} content, got {variant}"); +} + +#[test] +fn encode_image_url() { + assert_user_content(&image_url(), "image"); +} + +#[test] +fn encode_image_base64() { + assert_user_content(&image_base64(), "image"); +} + +#[test] +fn encode_image_raw() { + assert_user_content(&image_raw(), "image"); +} + +#[test] +fn encode_image_string() { + assert_user_content(&image_string(), "image"); +} + +#[test] +fn encode_audio_url() { + assert_user_content(&audio_url(), "image"); // Url always maps to image +} + +#[test] +fn encode_audio_base64() { + assert_user_content(&audio_base64(), "audio"); +} + +#[test] +fn encode_audio_raw() { + assert_user_content(&audio_raw(), "audio"); +} + +#[test] +fn encode_document_base64() { + assert_user_content(&document_base64(), "document"); +} + +#[test] +fn encode_video_url() { + assert_user_content(&video_url(), "image"); // Url always maps to image +} + +#[test] +fn content_as_text_returns_placeholder() { + assert_eq!(image_url().content_as_text(), "<image>"); + assert_eq!(audio_url().content_as_text(), "<audio>"); + assert_eq!(video_url().content_as_text(), "<video>"); + assert_eq!(document_base64().content_as_text(), "<document>"); +} + +#[test] +fn encode_text_fallback_unchanged() { + let frag = Fragment::user("hello"); + let msg = encode(&frag, false).expect("user text encodes"); + let Message::User { content } = &msg else { + panic!("expected Message::User"); + }; + assert!(matches!( + content.iter().next().unwrap(), + UserContent::Text(_) + )); +} diff --git a/crates/machine/tests/obs_budget.rs b/crates/machine/tests/obs_budget.rs new file mode 100644 index 00000000..f31a87ea --- /dev/null +++ b/crates/machine/tests/obs_budget.rs @@ -0,0 +1,186 @@ +use machine::obs::{self, Obs}; +use machine::{ + CompletionId, CompletionRecord, Fragment, Limit, Model, Resources, Role, RunState, Telemetry, + TokenUsage, +}; + +fn run_state_with_model(context_limit: u64) -> RunState { + let mut resources = Resources::named("test"); + resources = resources.with_model(Model { + name: "flash".into(), + limit: Some(Limit { + context: context_limit, + input: None, + output: 4096, + }), + ..Default::default() + }); + let active = resources.use_model("flash"); + assert!(active.is_ok()); + + RunState { + resources, + ..RunState::default() + } +} + +fn record_with_input(step: u64, input_tokens: u64) -> CompletionRecord { + CompletionRecord { + id: CompletionId(step), + step, + model: Some("flash".into()), + tokens: TokenUsage { + input_tokens, + output_tokens: 10, + total_tokens: input_tokens + 10, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + output_fragment_ids: Vec::new(), + } +} + +#[test] +fn budget_is_zero_without_active_model() { + let run = RunState::default(); + let observation = obs::measure(&run); + assert_eq!(observation.budget.context_limit, 0); + assert_eq!(observation.budget.soft_threshold, 0); + assert_eq!(observation.budget.headroom, 0); + assert_eq!(observation.budget.last_actual_input, None); +} + +#[test] +fn budget_derives_limit_soft_threshold_and_headroom() { + let mut run = run_state_with_model(1_000_000); + run.context.append(Fragment::user("hello world")); + + let observation = obs::measure(&run); + assert_eq!(observation.budget.context_limit, 1_000_000); + assert_eq!(observation.budget.soft_threshold, 850_000); + // 11 chars / 4 = 2 tokens of text + 4 framing tokens for one fragment. + assert_eq!(observation.budget.estimated_input, 2 + 4); + assert_eq!( + observation.budget.headroom, + 1_000_000u64.saturating_sub(2 + 4) + ); +} + +#[test] +fn estimated_input_grows_monotonically_with_fragments() { + let mut run = run_state_with_model(100_000); + let empty = obs::measure(&run).budget.estimated_input; + + run.context.append(Fragment::user("first")); + let one = obs::measure(&run).budget.estimated_input; + assert!(one >= empty); + + run.context.append(Fragment::assistant("second message")); + let two = obs::measure(&run).budget.estimated_input; + assert!(two > one); +} + +#[test] +fn estimated_input_counts_active_tool_manifest_only() { + let mut run = run_state_with_model(100_000); + run.context.append(Fragment::user("task")); + + let before = obs::measure(&run).budget.estimated_input; + + let definition = machine::ToolDefinition { + name: "lookup".into(), + description: "look things up in the index".into(), + parameters: serde_json::json!({"type": "object"}), + }; + let mut resources = run.resources.clone(); + resources = resources.with_tool_definition(definition.clone()); + let inactive = { + let mut state = run.clone(); + state.resources = resources.clone(); + obs::measure(&state).budget.estimated_input + }; + assert_eq!(inactive, before, "inactive tools must not cost tokens"); + + let enabled = resources.enable("lookup"); + assert!(enabled.is_ok()); + let mut state = run.clone(); + state.resources = resources; + let after = obs::measure(&state).budget.estimated_input; + assert!(after > before, "active tool manifest must add tokens"); +} + +#[test] +fn media_fragments_carry_fixed_token_estimate() { + let mut run = run_state_with_model(100_000); + run.context.append(Fragment::user("text only")); + let text_only = obs::measure(&run).budget.estimated_input; + + let image = Fragment::image( + machine::DataSource::Base64("aGVsbG8=".into()), + Some("image/png".into()), + ); + run.context.append(image); + let with_image = obs::measure(&run).budget.estimated_input; + + // 500 for the image + 4 framing tokens for the extra fragment. + assert!(with_image > text_only); + assert!(with_image - text_only >= 500); +} + +#[test] +fn calibration_prefers_newest_nonzero_measurement() { + let mut run = run_state_with_model(100_000); + let mut telemetry = Telemetry::default(); + telemetry.completions.push(record_with_input(1, 1_200)); + telemetry.completions.push(record_with_input(2, 0)); + telemetry.completions.push(record_with_input(3, 2_400)); + run.telemetry = telemetry; + + let observation = obs::measure(&run); + assert_eq!(observation.budget.last_actual_input, Some(2_400)); +} +#[test] +fn headroom_saturates_at_zero_when_estimate_exceeds_limit() { + let mut run = run_state_with_model(10); + let big_text = "x".repeat(400); + run.context.append(Fragment::user(big_text)); + + let observation = obs::measure(&run); + assert_eq!(observation.budget.headroom, 0); + assert!(observation.budget.estimated_input > observation.budget.context_limit); +} + +#[test] +fn obs_default_enrichment_slots_are_empty() { + let run = run_state_with_model(1_000); + let observation: Obs = obs::measure(&run); + assert_eq!(observation.ledger_digest, None); + assert!(!observation.overlay_status.declared); + assert_eq!(observation.overlay_status.system_prefix_count, 0); + assert_eq!(observation.overlay_status.tail_count, 0); +} + +#[test] +fn hitch_and_tool_result_text_counted_in_estimate() { + let mut run = run_state_with_model(100_000); + run.context.append(Fragment::user("task")); + let baseline = obs::measure(&run).budget.estimated_input; + + let hitch = Fragment::hitch( + "transport failed after retries", + None, + Role::Assistant, + None::<&str>, + ); + run.context.append(hitch); + + let tool_result = Fragment::tool_result( + "call-1", + "the quick brown fox jumps over the lazy dog", + None, + ); + run.context.append(tool_result); + + let after = obs::measure(&run).budget.estimated_input; + assert!(after > baseline); +} diff --git a/crates/machine/tests/overlay.rs b/crates/machine/tests/overlay.rs new file mode 100644 index 00000000..d71bbf50 --- /dev/null +++ b/crates/machine/tests/overlay.rs @@ -0,0 +1,117 @@ +use machine::completion::{assemble_messages, encode_context}; +use machine::{Fragment, Overlay, Role}; +use rig::completion::Message; + +fn sample_tape() -> Vec<Fragment> { + vec![ + Fragment::system("agent prompt"), + Fragment::user("task"), + Fragment::assistant("working on it"), + ] +} + +#[test] +fn empty_overlay_is_byte_identical_to_encode_context() { + let fragments = sample_tape(); + let plain = encode_context(&fragments, false); + let with_empty_overlay = assemble_messages(&fragments, false, &Overlay::default()); + assert_eq!( + plain.len(), + with_empty_overlay.len(), + "empty overlay must not add messages" + ); + // rig Message implements Debug; compare through Debug for byte equality. + assert_eq!( + format!("{:?}", plain), + format!("{:?}", with_empty_overlay), + "empty overlay must produce byte-identical encoding" + ); +} + +#[test] +fn overlay_prefix_lands_before_tape_and_tail_after() { + let fragments = sample_tape(); + let overlay = Overlay { + system_prefix: vec!["cached identity block".into()], + tail: vec!["volatile reminder".into()], + }; + + let messages = assemble_messages(&fragments, false, &overlay); + + assert_eq!(messages.len(), fragments.len() + 2); + let Message::System { content } = &messages[0] else { + panic!("first message must be the overlay prefix"); + }; + assert_eq!(content, "cached identity block"); + + // Middle of the list is the encoded tape, untouched and in order. + let Message::User { .. } = &messages[2] else { + panic!("tape messages must follow the prefix unchanged"); + }; + let Message::Assistant { .. } = &messages[3] else { + panic!("assistant tape message must stay in position"); + }; + + let Message::System { content } = &messages[4] else { + panic!("last message must be the overlay tail"); + }; + assert_eq!(content, "volatile reminder"); +} + +#[test] +fn overlay_multiple_prefixes_preserve_declaration_order() { + let fragments = vec![Fragment::user("task")]; + let overlay = Overlay { + system_prefix: vec!["first block".into(), "second block".into()], + tail: vec![], + }; + + let messages = assemble_messages(&fragments, false, &overlay); + assert_eq!(messages.len(), 3); + let Message::System { content } = &messages[0] else { + panic!("expected first prefix block"); + }; + assert_eq!(content, "first block"); + let Message::System { content } = &messages[1] else { + panic!("expected second prefix block"); + }; + assert_eq!(content, "second block"); +} + +#[test] +fn overlay_default_is_empty() { + let overlay = Overlay::default(); + assert!(overlay.is_empty()); + assert!(overlay.system_prefix.is_empty()); + assert!(overlay.tail.is_empty()); +} + +#[test] +fn overlay_survives_serde_round_trip() { + let overlay = Overlay { + system_prefix: vec!["a".into()], + tail: vec!["b".into(), "c".into()], + }; + let encoded = serde_json::to_string(&overlay).unwrap(); + let decoded: Overlay = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, overlay); +} + +#[test] +fn empty_overlay_with_hitch_tape_matches_plain_encoding() { + // Hitch filtering is encode_context's job; the overlay wrapper must not + // disturb it — verify on a tape containing a filtered assistant hitch. + let fragments = vec![ + Fragment::user("request"), + Fragment::hitch( + "HTTP 504 Gateway Timeout", + Some(504), + Role::Assistant, + None::<&str>, + ), + ]; + let plain = encode_context(&fragments, false); + let wrapped = assemble_messages(&fragments, false, &Overlay::default()); + assert_eq!(plain.len(), wrapped.len()); + assert_eq!(format!("{:?}", plain), format!("{:?}", wrapped)); +} diff --git a/crates/machine/tests/reactor.rs b/crates/machine/tests/reactor.rs new file mode 100644 index 00000000..2dfc69dd --- /dev/null +++ b/crates/machine/tests/reactor.rs @@ -0,0 +1,327 @@ +use machine::fragment::{Content, Fragment}; +use machine::{Environment, Resources, Tool, ToolDefinition, ToolResult, ToolRuntime}; +use std::sync::Arc; +use std::time::Instant; +use tokio::time::{Duration, sleep}; + +/// A tool that simply sleeps for the specified milliseconds. +struct DelayTool { + name: String, + ms: u64, +} + +impl DelayTool { + fn new(name: &str, ms: u64) -> Self { + Self { + name: name.into(), + ms, + } + } +} + +impl Tool for DelayTool { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + "delays for N ms" + } + + fn parameters(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "ms": { "type": "integer" } + } + }) + } + + fn execute<'a>( + &'a self, + _args: serde_json::Value, + _env: &'a Environment, + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ToolResult, String>> + Send + 'a>> + { + let ms = self.ms; + Box::pin(async move { + sleep(Duration::from_millis(ms)).await; + Ok(ToolResult { + call_id: String::new(), + content: format!("slept {}ms", ms), + title: None, + }) + }) + } +} + +fn make_sleep_tool(name: &str, ms: u64) -> Arc<dyn Tool> { + Arc::new(DelayTool::new(name, ms)) +} + +fn resources_and_runtime(tools: Vec<Arc<dyn Tool>>) -> (Resources, ToolRuntime) { + let mut resources = Resources::new(); + let mut tool_runtime = ToolRuntime::new(); + for tool in tools { + resources = resources.with_tool_definition(ToolDefinition::from_tool(tool.as_ref())); + tool_runtime.insert(tool); + } + (resources, tool_runtime) +} + +/// Parallel execution: 3 × 100ms via join_all → ≈100ms (not 300ms). +#[tokio::test] +async fn parallel_tool_execution() { + let env = Environment::new("/tmp"); + let (mut resources, tool_runtime) = resources_and_runtime(vec![ + make_sleep_tool("a", 100), + make_sleep_tool("b", 100), + make_sleep_tool("c", 100), + ]); + resources.enable("a").unwrap(); + resources.enable("b").unwrap(); + resources.enable("c").unwrap(); + + let fragments = vec![ + Fragment::tool_call("c1", "a", serde_json::json!({})), + Fragment::tool_call("c2", "b", serde_json::json!({})), + Fragment::tool_call("c3", "c", serde_json::json!({})), + ]; + + use futures_util::future::join_all; + let mut futures = Vec::new(); + for frag in &fragments { + if let Content::ToolCall(tc) = &frag.content + && let Some(tool) = tool_runtime.get(&tc.name) + { + futures.push(tool.execute(tc.arguments.clone(), &env)); + } + } + + let t0 = Instant::now(); + let results = join_all(futures).await; + let elapsed = t0.elapsed(); + + assert!( + elapsed < Duration::from_millis(200), + "parallel 3 × 100ms should take < 200ms (was {:?})", + elapsed + ); + assert_eq!(results.len(), 3); + for result in results { + assert!(result.is_ok()); + } +} + +/// Baseline: serial execution (same as old reactor). 3 × 100ms → ≥ 250ms. +#[tokio::test] +async fn serial_tool_execution_baseline() { + let env = Environment::new("/tmp"); + let (mut resources, tool_runtime) = resources_and_runtime(vec![ + make_sleep_tool("a", 100), + make_sleep_tool("b", 100), + make_sleep_tool("c", 100), + ]); + resources.enable("a").unwrap(); + resources.enable("b").unwrap(); + resources.enable("c").unwrap(); + + let fragments = vec![ + Fragment::tool_call("c1", "a", serde_json::json!({})), + Fragment::tool_call("c2", "b", serde_json::json!({})), + Fragment::tool_call("c3", "c", serde_json::json!({})), + ]; + + let t0 = Instant::now(); + for frag in &fragments { + if let Content::ToolCall(tc) = &frag.content + && let Some(tool) = tool_runtime.get(&tc.name) + { + let _ = tool.execute(tc.arguments.clone(), &env).await; + } + } + let elapsed = t0.elapsed(); + assert!( + elapsed >= Duration::from_millis(250), + "serial 3 × 100ms should take >= 250ms (was {:?})", + elapsed + ); +} + +/// Text pushed before tools execute. +#[tokio::test] +async fn text_and_tools_mixed() { + let env = Environment::new("/tmp"); + let (mut resources, tool_runtime) = resources_and_runtime(vec![make_sleep_tool("slow", 50)]); + resources.enable("slow").unwrap(); + + let fragments = vec![ + Fragment::user("hello before"), + Fragment::tool_call("c1", "slow", serde_json::json!({})), + Fragment::assistant("text after"), + ]; + + let mut inbox = Vec::new(); + let mut futures = Vec::new(); + for frag in &fragments { + if let Content::ToolCall(tc) = &frag.content { + if let Some(tool) = tool_runtime.get(&tc.name) { + futures.push(tool.execute(tc.arguments.clone(), &env)); + } + } else { + inbox.push(frag.as_text().unwrap_or("").to_string()); + } + } + assert_eq!(inbox, vec!["hello before", "text after"]); + let _results = futures_util::future::join_all(futures).await; +} + +/// One failing tool does not block other tools. +#[tokio::test] +async fn one_failure_does_not_block_others() { + struct FailingTool; + impl Tool for FailingTool { + fn name(&self) -> &str { + "fail" + } + fn description(&self) -> &str { + "always fails" + } + fn parameters(&self) -> serde_json::Value { + serde_json::json!({}) + } + fn execute<'a>( + &'a self, + _args: serde_json::Value, + _env: &'a Environment, + ) -> std::pin::Pin< + Box<dyn std::future::Future<Output = Result<ToolResult, String>> + Send + 'a>, + > { + Box::pin(async { Err("intentional failure".into()) }) + } + } + + let env = Environment::new("/tmp"); + let (mut resources, tool_runtime) = resources_and_runtime(vec![ + make_sleep_tool("slow", 50), + Arc::new(FailingTool), + make_sleep_tool("also-slow", 50), + ]); + resources.enable("slow").unwrap(); + resources.enable("fail").unwrap(); + resources.enable("also-slow").unwrap(); + + let fragments = vec![ + Fragment::tool_call("c1", "slow", serde_json::json!({})), + Fragment::tool_call("c2", "fail", serde_json::json!({})), + Fragment::tool_call("c3", "also-slow", serde_json::json!({})), + ]; + + let mut futures = Vec::new(); + for frag in &fragments { + if let Content::ToolCall(tc) = &frag.content + && let Some(tool) = tool_runtime.get(&tc.name) + { + futures.push(tool.execute(tc.arguments.clone(), &env)); + } + } + + let t0 = Instant::now(); + let results = futures_util::future::join_all(futures).await; + let elapsed = t0.elapsed(); + assert!(elapsed < Duration::from_millis(150)); + assert_eq!(results.len(), 3); + assert!(results[0].is_ok()); + assert!(results[1].is_err()); + assert!(results[2].is_ok()); +} + +// ── Panic reproduction ── + +#[test] +#[should_panic(expected = "byte index")] +fn one_tool_panic_kills_all_join_all_tools() { + let padding = "x".repeat(58); + let s = format!("{padding}并"); + let _sliced = &s[..60]; +} + +#[tokio::test] +async fn spawn_isolates_tool_panic() { + struct SpawnPanicTool; + impl Tool for SpawnPanicTool { + fn name(&self) -> &str { + "spawn_panic" + } + fn description(&self) -> &str { + "panics via spawn" + } + fn parameters(&self) -> serde_json::Value { + serde_json::json!({}) + } + fn execute<'a>( + &'a self, + _args: serde_json::Value, + _env: &'a Environment, + ) -> std::pin::Pin< + Box<dyn std::future::Future<Output = Result<ToolResult, String>> + Send + 'a>, + > { + Box::pin(async move { + let s = format!("{}并", "x".repeat(58)); + let _ = &s[..60]; + unreachable!() + }) + } + } + + let env = Arc::new(Environment::new("/tmp")); + let (mut resources, tool_runtime) = resources_and_runtime(vec![ + make_sleep_tool("a", 50), + Arc::new(SpawnPanicTool), + make_sleep_tool("b", 50), + ]); + resources.enable("a").unwrap(); + resources.enable("spawn_panic").unwrap(); + resources.enable("b").unwrap(); + + let fragments = vec![ + Fragment::tool_call("c1", "a", serde_json::json!({})), + Fragment::tool_call("c2", "spawn_panic", serde_json::json!({})), + Fragment::tool_call("c3", "b", serde_json::json!({})), + ]; + + let mut tool_clones: Vec<Option<Arc<dyn Tool>>> = Vec::new(); + for frag in &fragments { + if let Content::ToolCall(tc) = &frag.content { + tool_clones.push(tool_runtime.get_arc(&tc.name)); + } else { + tool_clones.push(None); + } + } + + let mut futures = Vec::new(); + for tool in tool_clones { + let Some(tool) = tool else { continue }; + let env_arc = env.clone(); + futures.push(async move { + let _started_at = Instant::now(); + match tokio::spawn(async move { tool.execute(serde_json::json!({}), &env_arc).await }) + .await + { + Ok(Ok(tr)) => Ok(tr.content), + Ok(Err(msg)) => Err(msg), + Err(_join_err) => Err("tool panicked".into()), + } + }); + } + let results = futures_util::future::join_all(futures).await; + assert_eq!(results.len(), 3); + assert!(results[0].is_ok()); + assert!(results[1].is_err()); + assert!(results[2].is_ok()); + let error_msg = results[1].as_ref().unwrap_err(); + assert!( + error_msg.contains("panicked"), + "error should mention panic: {error_msg}" + ); +} diff --git a/crates/machine/tests/resources.rs b/crates/machine/tests/resources.rs index 0aa87165..e5c4fd71 100644 --- a/crates/machine/tests/resources.rs +++ b/crates/machine/tests/resources.rs @@ -1,54 +1,162 @@ -//! Tests for `Resources::active_model` and `Resources::use_model` after the -//! API hardening: they must return `Option` / `Result` rather than panic. +use machine::{LookupResult, Resources, Tool, ToolDefinition, ToolResult}; +use serde_json::json; +use std::sync::Arc; -#[allow(dead_code)] -mod common; +struct TestTool; +impl Tool for TestTool { + fn name(&self) -> &str { + "test" + } + fn description(&self) -> &str { + "test tool" + } + fn parameters(&self) -> serde_json::Value { + json!({}) + } + fn execute<'a>( + &'a self, + _args: serde_json::Value, + _env: &machine::Environment, + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ToolResult, String>> + Send + 'a>> + { + Box::pin(async { + Ok(ToolResult { + call_id: String::new(), + content: String::new(), + title: None, + }) + }) + } +} -use common::test_model; -use machine::{Model, ModelNotRegistered, Resources}; +struct AnotherTool; +impl Tool for AnotherTool { + fn name(&self) -> &str { + "other" + } + fn description(&self) -> &str { + "another tool" + } + fn parameters(&self) -> serde_json::Value { + json!({}) + } + fn execute<'a>( + &'a self, + _args: serde_json::Value, + _env: &machine::Environment, + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ToolResult, String>> + Send + 'a>> + { + Box::pin(async { + Ok(ToolResult { + call_id: String::new(), + content: String::new(), + title: None, + }) + }) + } +} -#[test] -fn active_model_is_none_when_empty() { - let resources = Resources::new(); - assert!(resources.active_model().is_none()); +fn tool() -> Arc<dyn Tool> { + Arc::new(TestTool) +} +fn other_tool() -> Arc<dyn Tool> { + Arc::new(AnotherTool) +} +fn tool_definition() -> ToolDefinition { + ToolDefinition::from_tool(tool().as_ref()) +} +fn other_tool_definition() -> ToolDefinition { + ToolDefinition::from_tool(other_tool().as_ref()) } #[test] -fn first_with_model_becomes_active() { - let resources = Resources::new().with_model(test_model()); - let active = resources.active_model().expect("should have active"); - assert_eq!(active.name, "test"); +fn enable_makes_lookup_active() { + let mut res = Resources::new().with_tool_definition(tool_definition()); + assert_eq!(res.lookup("test"), LookupResult::Inactive); + res.enable("test").unwrap(); + assert_eq!(res.lookup("test"), LookupResult::Active); } #[test] -fn use_model_switches_active() { - let mut resources = Resources::new() - .with_model(test_model()) - .with_model(Model { - name: "other".into(), - ..Default::default() - }); - assert_eq!(resources.active_model().unwrap().name, "test"); +fn disable_makes_lookup_inactive() { + let mut res = Resources::new().with_tool_definition(tool_definition()); + res.enable("test").unwrap(); + assert_eq!(res.lookup("test"), LookupResult::Active); + res.disable("test"); + assert_eq!(res.lookup("test"), LookupResult::Inactive); +} - let previous = resources.use_model("other").expect("registered"); - assert_eq!(previous, "test"); - assert_eq!(resources.active_model().unwrap().name, "other"); +#[test] +fn enable_nonexistent_returns_err() { + let mut res = Resources::new(); + assert!(res.enable("nonexistent").is_err()); } #[test] -fn use_model_unknown_returns_error_not_panic() { - let mut resources = Resources::new().with_model(test_model()); - let result = resources.use_model("nope"); - match result { - Err(ModelNotRegistered(name)) => assert_eq!(name, "nope"), - Ok(_) => panic!("expected error for unknown model"), +fn lookup_active_implies_definition_exists() { + let mut res = Resources::new() + .with_tool_definition(tool_definition()) + .with_tool_definition(other_tool_definition()); + res.enable("test").unwrap(); + res.enable("other").unwrap(); + for name in &["test", "other"] { + assert_eq!(res.lookup(name), LookupResult::Active); + assert!( + res.tool_definition(name).is_some(), + "tool_definition() must return Some for tool '{}' when lookup() == Active", + name + ); } - // Active model unchanged after the failed switch. - assert_eq!(resources.active_model().unwrap().name, "test"); } #[test] -fn model_not_registered_error_includes_name() { - let error = ModelNotRegistered("missing-x".into()); - assert!(error.to_string().contains("missing-x")); +fn lookup_inactive_still_has_definition() { + let res = Resources::new().with_tool_definition(tool_definition()); + assert_eq!(res.lookup("test"), LookupResult::Inactive); + assert!( + res.tool_definition("test").is_some(), + "inactive registered tools still keep their serializable definition" + ); +} + +#[test] +fn lookup_not_found_has_no_definition() { + let res = Resources::new(); + assert_eq!(res.lookup("nonexistent"), LookupResult::NotFound); + assert!( + res.tool_definition("nonexistent").is_none(), + "tool_definition() must return None when lookup() == NotFound" + ); +} + +#[test] +fn inactive_hitch_mentions_disabled() { + use machine::Fragment; + use machine::fragment::Content; + let res = Resources::new().with_tool_definition(tool_definition()); + assert_eq!(res.lookup("test"), LookupResult::Inactive); + let hitch = Fragment::hitch( + format!("tool '{}' is disabled — activate it before use", "test"), + None, + machine::Role::Tool, + Some(String::from("call_1")), + ); + assert!(matches!(hitch.content, Content::Hitch { .. })); + assert!(hitch.content_as_text().contains("is disabled")); +} + +#[test] +fn not_found_hitch_mentions_not_found() { + use machine::Fragment; + use machine::fragment::Content; + let res = Resources::new(); + assert_eq!(res.lookup("nonexistent"), LookupResult::NotFound); + let hitch = Fragment::hitch( + format!("tool '{}' not found", "nonexistent"), + None, + machine::Role::Tool, + None::<String>, + ); + assert!(matches!(hitch.content, Content::Hitch { .. })); + assert!(hitch.content_as_text().contains("not found")); } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 00000000..8b1159d5 --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "server" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "rcm-server" +path = "src/main.rs" + +[dependencies] +tonic = "0.13" +prost = "0.13" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +machine = { version = "0.1.0", path = "../machine" } +accelerator = { version = "0.1.0", path = "../accelerator" } +storage = { version = "0.1.0", path = "../storage" } +utils = { version = "0.1.0", path = "../utils" } +serde_json = "1" + +[build-dependencies] +tonic-build = "0.13" + +[package.metadata.cargo-machete] +ignored = ["prost", "tonic-build"] + +[dev-dependencies] +tempfile = "3" diff --git a/crates/server/DESIGN.md b/crates/server/DESIGN.md new file mode 100644 index 00000000..d9957a64 --- /dev/null +++ b/crates/server/DESIGN.md @@ -0,0 +1,415 @@ +# RCM gRPC Server Design + +## Overview + +The RCM gRPC server exposes `Machine` as a network service. Clients create a +run (a live machine instance), drive it step-by-step by sending actions, and +destroy it when done. The server does **not** own a policy, compute rewards, +or manage task semantics — it is a pure agent runtime. + +``` +┌─ Python (RL Trainer) ────────────────────────────┐ +│ │ +│ ┌──────────┐ ┌──────────────┐ │ +│ │ Policy │───►│ Environment │ ← external │ +│ │ Network │ │ (task-spec) │ │ +│ │ │◄───│ │ │ +│ └──────────┘ └──────────────┘ │ +│ │ │ +│ ┌────┴──────────────────────────────┐ │ +│ │ RCMClient │ │ +│ │ new() → (mid, state, actions) │ │ +│ │ step(mid, command) → (state, │ │ +│ │ actions) │ │ +│ │ destroy(mid) │ │ +│ └────┬──────────────────────────────┘ │ +└───────┼────────────────────────────────────────────┘ + │ + protobuf over gRPC + │ +┌───────┼────────────────────────────────────────────┐ +│ │ Rust RCM Server │ +│ │ │ +│ ┌────┴──────────────┐ ┌─────────────────────┐ │ +│ │ RCMService │ │ MachineManager │ │ +│ │ │───►│ HashMap<Mid, Run> │ │ +│ │ New() → Mid │ │ │ │ +│ │ Step(Mid, Cmd) │ │ Run { │ │ +│ │ Destroy(Mid) │ │ ctx, env, │ │ +│ └────────────────────┘ │ resources, │ │ +│ │ inbox, step │ │ +│ │ } │ │ +│ └──────────┬──────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ Machine::apply() │ │ +│ │ reactor::react() │ │ +│ └──────────────────┘ │ +└───────────────────────────────────────────────────────┘ +``` + +## RPCs + +Three methods. No Create/Reset/Observe/Context — `State` is the single data +model, and the external environment owns the task loop. + +| RPC | Purpose | +|-----|---------| +| `New` | Allocate a run. Returns `machine_id`, initial `State`, `ActionSpace`. | +| `Step` | Execute one action (including Halt → reactor). Returns new `State` and `ActionSpace`. | +| `Destroy` | Release the run and all held resources. | + +## Protobuf + +### Service + +```protobuf +syntax = "proto3"; +package rcm; + +import "google/protobuf/empty.proto"; + +service RCM { + rpc New(NewRequest) returns (NewResponse); + rpc Step(StepRequest) returns (StepResponse); + rpc Destroy(DestroyRequest) returns (google.protobuf.Empty); +} +``` + +### New + +```protobuf +message NewRequest { + string purpose = 1; + repeated string models = 2; + repeated string tools = 3; // tool names from the built-in catalog + map<string, string> prompts = 4; // prompt templates keyed by name +} + +message NewResponse { + string machine_id = 1; + State state = 2; + ActionSpace action_space = 3; +} +``` + +### Step + +```protobuf +message StepRequest { + string machine_id = 1; + ActionCommand command = 2; +} + +message StepResponse { + State state = 1; + ActionSpace action_space = 2; +} +``` + +`state.done == true` signals termination — no separate done field. + +### Destroy + +```protobuf +message DestroyRequest { + string machine_id = 1; +} +``` + +### ActionCommand + +The command carries the complete semantics of one machine action. The +client sends a full `ActionCommand`, not an opaque index — the server +decodes it directly to `machine::Action`. + +```protobuf +message ActionCommand { + string verb = 1; + + optional uint64 fragment_id = 2; // Remove / Replace / Swap(a) / Insert(after) + optional uint64 fragment_id2 = 3; // Swap(b) + optional FragmentContent fragment = 4; // Append / Insert / Replace payload + optional string name = 5; // Model / Activate / Deactivate target +} +``` + +Mapping to `machine::Action`: + +| verb | fragment_id | fragment_id2 | fragment | name | → Action | +|------|-------------|--------------|----------|------|----------| +| `Halt` | — | — | — | — | `Action::Halt` | +| `Done` | — | — | — | — | `Action::Done` | +| `Take` | — | — | — | — | `Action::Take` | +| `Append` | — | — | ✓ | — | `Action::Append(f)` | +| `Remove` | id | — | — | — | `Action::Remove(id)` | +| `Swap` | a | b | — | — | `Action::Swap(a, b)` | +| `Insert` | after | — | ✓ | — | `Action::Insert { after, fragment: f }` | +| `Replace` | id | — | ✓ | — | `Action::Replace { id, fragment: f }` | +| `Model` | — | — | — | ✓ | `Action::Model(name)` | +| `Activate` | — | — | — | ✓ | `Action::Activate(name)` | +| `Deactivate` | — | — | — | ✓ | `Action::Deactivate(name)` | + +### FragmentContent + +```protobuf +message FragmentContent { + string role = 1; // "system" | "user" + string text = 2; // full text content +} +``` + +Server constructs `machine::Fragment` from this: +- `role: "user"` → `Role::User`, otherwise `Role::System` +- `text` → `Content::Text(text.into())` + +### ActionSpace + +```protobuf +message ActionSpace { + repeated ActionItem actions = 1; +} + +message ActionItem { + ActionCommand command = 1; // the action to send back on Step + string label = 2; // human-readable label + FragmentContent sink = 3; // preview of the fragment (for Append-type) +} +``` + +The action space is built from the current `RunState`: + +- **Free mode** (inbox empty): Halt, Append (one per prompt resource), Remove (one + per context fragment), Replace (one per fragment × one per prompt), + Insert, Swap, Model, Activate, Deactivate, Done. +- **Consumption mode** (inbox pending): only Take and Remove. + +### State + +```protobuf +message State { + string purpose = 1; + + repeated Fragment fragments = 2; + + string workdir = 3; + map<string, string> env_vars = 4; + + string active_model = 5; + repeated string active_tools = 6; + repeated string available_models = 7; + repeated string available_tools = 8; + + bool done = 9; + uint64 step = 10; + + bool inbox_pending = 11; + optional Fragment inbox_peek = 12; +} + +message Fragment { + uint64 id = 1; + string role = 2; // system / user / assistant / tool + string kind = 3; // text / tool_call / tool_result / hitch / image + string text_preview = 4; // first ~200 chars + optional string tag = 5; +} +``` + +`State` corresponds to the four mutable inputs of `Machine::apply()`: +`ctx` → `fragments`, `env` → `workdir` + `env_vars`, `resources` → +`active_model` + `active_tools` + `available_*`, `inbox` → +`inbox_pending` + `inbox_peek`. + +## Rust Implementation + +### Crate layout + +``` +proto/ # shared proto source of truth +└── rcm.proto + +crates/server/ +├── Cargo.toml +├── build.rs # tonic-build from ../../proto/rcm.proto +└── src/ + ├── lib.rs + ├── service.rs # handlers + State/ActionSpace serialization + └── manager.rs # MachineManager — HashMap<MachineId, Run> + +sdks/python/ # Python SDK (pip install -e .) +├── pyproject.toml +├── generate.sh # regenerate stubs from proto/rcm.proto +└── src/rcm/ + ├── __init__.py # from rcm import RCMClient + ├── client.py # thin wrapper over gRPC stub + ├── _pb2.py # generated message classes + └── _pb2_grpc.py # generated client stub + +### Dependencies + +```toml +[dependencies] +tonic = "0.13" +prost = "0.14" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +uuid = { version = "1", features = ["v4"] } +machine = { path = "../machine" } +accelerator = { path = "../accelerator" } + +[build-dependencies] +tonic-build = "0.13" +``` + +### MachineManager + +```rust +use std::collections::HashMap; +use machine::{Context, Environment, Inbox, Resources}; +use uuid::Uuid; + +pub struct MachineManager { + machines: HashMap<MachineId, Run>, +} + +pub struct Run { + pub ctx: Context, + pub env: Environment, + pub resources: Resources, + pub inbox: Inbox, + pub step: u64, + pub done: bool, +} +``` + +A simple key-value store. `Run` holds the four mutable state components +and two counters. No policy, no phases — those are Rust-internal accelerator +concepts. + +### RCMService + +Three public handlers plus private helpers: + +``` +RCMService +├── new(request) → (machine_id, state, action_space) +│ ├── Build resources from request (models, tools, prompts) +│ ├── Create Run with empty ctx, local env, inbox +│ ├── build_state() + build_action_space() +│ +├── step(request) → (state, action_space) +│ ├── decode_command() → machine::Action +│ ├── Machine::apply() +│ ├── build_state() + build_action_space() +│ +├── destroy(request) +│ └── manager.destroy(mid) +│ +├── build_state(Run) → proto::State +├── build_action_space(Run) → proto::ActionSpace +└── decode_command(ActionCommand) → Result<machine::Action> +``` + +### Action decode + +`decode_command` maps `ActionCommand` fields to `machine::Action` variants: + +```rust +fn decode_command(cmd: &ActionCommand) -> Result<Action, Status> { + let build_frag = |fc: &FragmentContent| { + let role = match fc.role.as_str() { + "user" => Role::User, + _ => Role::System, + }; + Fragment::new(role, Content::Text(fc.text.clone().into())) + }; + + match cmd.verb.as_str() { + "Halt" => Ok(Action::Halt), + "Done" => Ok(Action::Done), + "Take" => Ok(Action::Take), + "Append" => { + let fc = cmd.fragment.as_ref().ok_or(Status::invalid_argument("fragment required"))?; + Ok(Action::Append(build_frag(fc))) + } + "Replace" => { + let id = cmd.fragment_id.ok_or(Status::invalid_argument("fragment_id required"))?; + let fc = cmd.fragment.as_ref().ok_or(Status::invalid_argument("fragment required"))?; + Ok(Action::Replace { id, fragment: build_frag(fc) }) + } + // ... etc + } +} +``` + +## Python Client + +```python +import grpc +from rcm.proto import rcm_pb2, rcm_pb2_grpc + +class RCMClient: + def __init__(self, endpoint: str = "localhost:50051"): + self.channel = grpc.insecure_channel(endpoint) + self.stub = rcm_pb2_grpc.RCMStub(self.channel) + + def new( + self, purpose: str, models: list[str], tools: list[str] | None = None, + prompts: dict[str, str] | None = None, + ) -> tuple[str, rcm_pb2.State, rcm_pb2.ActionSpace]: + ... + + def step( + self, machine_id: str, command: rcm_pb2.ActionCommand + ) -> tuple[rcm_pb2.State, rcm_pb2.ActionSpace]: + ... + + def destroy(self, machine_id: str): + ... +``` + +Training loop: + +```python +rcm = RCMClient() + +machine_id, state, actions = rcm.new( + purpose="fix the bug in auth.rs", + models=["fast"], + tools=["read", "edit", "shell"], + prompts={"captain": "You are a senior engineer..."}, +) + +for t in range(max_steps): + command = policy.pick(state, actions.actions) + state, actions = rcm.step(machine_id, command) + + if state.done: + reward = evaluate_task(state) + break + +rcm.destroy(machine_id) +``` + +The external trainer owns the RL loop: policy → action → Step → reward. +RCM is only the agent runtime. + +## Design decisions + +1. **No index-based actions.** `ActionCommand` carries verb + parameters + directly. The client sends back the same message it received from + `ActionSpace`. No mapping table between client and server. + +2. **No Reward / Reset / Context RPCs.** RCM is an agent runtime, not an RL + environment. Task semantics belong to the external benchmark/trainer. + `State` is the single data model — the trainer derives reward and reset + logic from it. + +3. **No phases in the gRPC layer.** Phases are a Rust-internal accelerator + concept (pre/post/pre_halt/post_halt). The gRPC server does not expose + them. If a client wants phase-like behavior (e.g. inject environment info + before each Halt), it issues the corresponding actions explicitly. + +4. **`Machine::apply()` as the single primitive.** Every `Step` calls + `apply(action, step, ctx, env, resources, inbox)`. Halt triggers + `reactor::react()` internally. Done sets `done = true`. diff --git a/crates/server/build.rs b/crates/server/build.rs new file mode 100644 index 00000000..dee2e888 --- /dev/null +++ b/crates/server/build.rs @@ -0,0 +1,4 @@ +fn main() -> Result<(), Box<dyn std::error::Error>> { + tonic_build::compile_protos("../../proto/rcm.proto")?; + Ok(()) +} diff --git a/crates/server/src/action_space.rs b/crates/server/src/action_space.rs new file mode 100644 index 00000000..802c849f --- /dev/null +++ b/crates/server/src/action_space.rs @@ -0,0 +1,184 @@ +use crate::manager::Run; +use crate::rcm::{ActionCommand, ActionItem, ActionSpace, FragmentContent}; + +pub fn build_action_space(run: &Run) -> ActionSpace { + let state = &run.state; + let context = &state.run.context; + let resources = &state.run.resources; + let mut actions = Vec::new(); + + if state.frame.inbox.is_empty() { + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Halt".into(), + ..Default::default() + }), + label: "Halt".into(), + sink: None, + }); + + for (name, text) in &resources.prompts { + let fragment = FragmentContent { + role: "system".into(), + text: text.clone(), + ..Default::default() + }; + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Append".into(), + fragment: Some(fragment.clone()), + ..Default::default() + }), + label: format!("Append {name}"), + sink: Some(sink_clip(fragment)), + }); + } + + for fragment in context.fragments() { + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Remove".into(), + fragment_id: Some(fragment.id()), + ..Default::default() + }), + label: format!("Remove #{}", fragment.id()), + sink: None, + }); + } + + for fragment in context.fragments() { + for (name, text) in &resources.prompts { + let content = FragmentContent { + role: "system".into(), + text: text.clone(), + ..Default::default() + }; + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Replace".into(), + fragment_id: Some(fragment.id()), + fragment: Some(content.clone()), + ..Default::default() + }), + label: format!("Replace #{} with {name}", fragment.id()), + sink: Some(sink_clip(content)), + }); + } + } + + for fragment in context.fragments() { + for (name, text) in &resources.prompts { + let content = FragmentContent { + role: "system".into(), + text: text.clone(), + ..Default::default() + }; + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Insert".into(), + fragment_id: Some(fragment.id()), + fragment: Some(content.clone()), + ..Default::default() + }), + label: format!("Insert after #{} with {name}", fragment.id()), + sink: Some(sink_clip(content)), + }); + } + } + + for index in 0..context.fragments().len().saturating_sub(1) { + let first_id = context.fragments()[index].id(); + let second_id = context.fragments()[index + 1].id(); + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Swap".into(), + fragment_id: Some(first_id), + fragment_id2: Some(second_id), + ..Default::default() + }), + label: format!("Swap #{first_id} ↔ #{second_id}"), + sink: None, + }); + } + + for model_name in &resources.model_order { + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Model".into(), + name: Some(model_name.clone()), + ..Default::default() + }), + label: format!("Model {model_name}"), + sink: None, + }); + } + for tool_name in resources.tool_definitions.keys() { + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Activate".into(), + name: Some(tool_name.clone()), + ..Default::default() + }), + label: format!("Activate {tool_name}"), + sink: None, + }); + } + for tool_name in &resources.active_tools { + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Deactivate".into(), + name: Some(tool_name.clone()), + ..Default::default() + }), + label: format!("Deactivate {tool_name}"), + sink: None, + }); + } + } else { + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Take".into(), + ..Default::default() + }), + label: "Take".into(), + sink: None, + }); + for fragment in context.fragments() { + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Remove".into(), + fragment_id: Some(fragment.id()), + ..Default::default() + }), + label: format!("Remove #{}", fragment.id()), + sink: None, + }); + } + } + + if !state.frame.status.is_done() { + actions.push(ActionItem { + command: Some(ActionCommand { + verb: "Done".into(), + ..Default::default() + }), + label: "Done".into(), + sink: None, + }); + } + + ActionSpace { actions } +} + +fn sink_clip(content: FragmentContent) -> FragmentContent { + if content.text.len() <= 200 { + content + } else { + let mut clipped = content.text.chars().take(200).collect::<String>(); + clipped.push_str("..."); + FragmentContent { + text: clipped, + ..content + } + } +} diff --git a/crates/server/src/decode.rs b/crates/server/src/decode.rs new file mode 100644 index 00000000..2a511436 --- /dev/null +++ b/crates/server/src/decode.rs @@ -0,0 +1,229 @@ +use machine::Action; +use machine::Fragment; +use tonic::Status; + +use crate::rcm::{ActionCommand, FragmentContent, ModelSpec}; + +pub fn build_fragment(content: &FragmentContent) -> Fragment { + let kind = content.kind.as_str(); + let mut fragment = match kind { + "tool_call" => Fragment::tool_call( + content.tag.as_deref().unwrap_or(""), + &content.text, + serde_json::json!({}), + ), + "tool_result" => { + Fragment::tool_result(content.tag.as_deref().unwrap_or(""), &content.text, None) + } + "hitch" => Fragment::hitch(&content.text, None, machine::Role::System, None::<&str>), + _ => match content.role.as_str() { + "user" => Fragment::user(&content.text), + "assistant" => Fragment::assistant(&content.text), + _ => Fragment::system(&content.text), + }, + }; + if let Some(tag) = &content.tag { + fragment.tag = tag.clone(); + } + if let Some(media_source) = &content.media_source + && let Some(source) = &media_source.source + { + let data_source = match source { + crate::rcm::media_source::Source::Url(url) => { + machine::fragment::DataSource::Url(url.clone()) + } + crate::rcm::media_source::Source::Base64(data) => { + machine::fragment::DataSource::Base64(data.clone()) + } + }; + match content.kind.as_str() { + "image" => { + fragment = machine::Fragment::image(data_source, media_source.media_type.clone()) + } + "audio" => { + fragment = machine::Fragment::audio(data_source, media_source.media_type.clone()) + } + "video" => { + fragment = machine::Fragment::video(data_source, media_source.media_type.clone()) + } + "document" => { + fragment = machine::Fragment::document(data_source, media_source.media_type.clone()) + } + _ => {} + } + } + fragment +} + +pub fn decode_command(command: &ActionCommand) -> Result<Action, Status> { + match command.verb.as_str() { + "Halt" => Ok(Action::Halt), + "Done" => Ok(Action::Done), + "Take" => Ok(Action::Take), + "Append" => { + let content = command + .fragment + .as_ref() + .ok_or(Status::invalid_argument("fragment required"))?; + Ok(Action::Append(build_fragment(content))) + } + "Remove" => { + let id = command + .fragment_id + .ok_or(Status::invalid_argument("fragment_id required"))?; + Ok(Action::Remove(id)) + } + "Swap" => { + let id1 = command + .fragment_id + .ok_or(Status::invalid_argument("fragment_id required"))?; + let id2 = command + .fragment_id2 + .ok_or(Status::invalid_argument("fragment_id2 required"))?; + Ok(Action::Swap(id1, id2)) + } + "Insert" => { + let after = command + .fragment_id + .ok_or(Status::invalid_argument("fragment_id required"))?; + let content = command + .fragment + .as_ref() + .ok_or(Status::invalid_argument("fragment required"))?; + Ok(Action::Insert { + after, + fragment: build_fragment(content), + }) + } + "Replace" => { + let id = command + .fragment_id + .ok_or(Status::invalid_argument("fragment_id required"))?; + let content = command + .fragment + .as_ref() + .ok_or(Status::invalid_argument("fragment required"))?; + Ok(Action::Replace { + id, + fragment: build_fragment(content), + }) + } + "Model" => { + let name = command + .name + .as_ref() + .ok_or(Status::invalid_argument("name required"))?; + Ok(Action::Model(name.clone())) + } + "Activate" => { + let name = command + .name + .as_ref() + .ok_or(Status::invalid_argument("name required"))?; + Ok(Action::Activate(name.clone())) + } + "Deactivate" => { + let name = command + .name + .as_ref() + .ok_or(Status::invalid_argument("name required"))?; + Ok(Action::Deactivate(name.clone())) + } + other => Err(Status::invalid_argument(format!("unknown verb: {}", other))), + } +} + +pub fn build_model(spec: &ModelSpec) -> Result<machine::Model, Status> { + use machine::Protocol; + let protocol = match spec.protocol.as_str() { + "openai" => Protocol::OpenAI, + "anthropic" => Protocol::Anthropic, + "gemini" => Protocol::Gemini, + other => { + return Err(Status::invalid_argument(format!( + "unknown protocol: {}", + other + ))); + } + }; + let credentials = spec.credentials.as_ref().and_then(|c| match &c.source { + Some(crate::rcm::credential_spec::Source::Env(var)) => std::env::var(var).ok(), + Some(crate::rcm::credential_spec::Source::Literal(key)) => Some(key.clone()), + None => None, + }); + let limit = spec.limit.as_ref().map(|l| machine::Limit { + context: l.context, + input: l.input, + output: l.output, + }); + let modalities = if !spec.modalities_input.is_empty() || !spec.modalities_output.is_empty() { + let input = spec + .modalities_input + .iter() + .map(|m| parse_modality(m)) + .collect::<Result<Vec<_>, _>>()?; + let output = spec + .modalities_output + .iter() + .map(|m| parse_modality(m)) + .collect::<Result<Vec<_>, _>>()?; + Some(machine::Modalities { input, output }) + } else { + None + }; + Ok(machine::Model { + name: spec.name.clone(), + protocol, + endpoint: spec.endpoint.clone(), + credentials, + limit, + modalities, + timeout: spec.timeout.unwrap_or(machine::DEFAULT_MODEL_TIMEOUT_SECS), + temperature: spec.temperature, + thinking: spec.thinking, + cost: spec.cost.as_ref().map(|c| machine::Cost { + input: c.input, + output: c.output, + cache_read: c.cache_read, + cache_write: c.cache_write, + }), + headers: if spec.headers.is_empty() { + None + } else { + Some(spec.headers.clone()) + }, + ..Default::default() + }) +} + +fn parse_modality(value: &str) -> Result<machine::Modality, Status> { + match value { + "text" => Ok(machine::Modality::Text), + "audio" => Ok(machine::Modality::Audio), + "image" => Ok(machine::Modality::Image), + "video" => Ok(machine::Modality::Video), + "pdf" => Ok(machine::Modality::Pdf), + other => Err(Status::invalid_argument(format!( + "unknown modality: {}", + other + ))), + } +} + +#[cfg(test)] +mod tests { + use super::build_model; + use crate::rcm::ModelSpec; + + #[test] + fn decoded_model_default_allows_thirty_minute_requests() { + let model = build_model(&ModelSpec { + name: "test".to_string(), + protocol: "openai".to_string(), + ..Default::default() + }) + .expect("model should decode"); + + assert_eq!(model.timeout, 1_800); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs new file mode 100644 index 00000000..1b7bded2 --- /dev/null +++ b/crates/server/src/lib.rs @@ -0,0 +1,15 @@ +// tonic::Status (176 B) is used as the error type throughout the server +// layer — it bundles gRPC status code, message, and details. Boxing it at +// every call site would trade ergonomics for a lint. The size is accepted. +#![allow(clippy::result_large_err)] + +pub mod rcm { + tonic::include_proto!("rcm"); +} + +pub mod action_space; +pub mod decode; +pub mod manager; +pub mod mcp; +pub mod service; +pub mod state; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 00000000..a5304e28 --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,26 @@ +use server::manager::MachineManager; +use server::rcm::rcm_server::RcmServer; +use server::service::RcmService; +use std::net::SocketAddr; +use tonic::transport::Server; + +#[tokio::main] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let addr: SocketAddr = "127.0.0.1:50051".parse()?; + let svc = RcmService::new(MachineManager::new()); + + tracing::info!("RCM server listening on {}", addr); + + Server::builder() + .add_service(RcmServer::new(svc)) + .serve(addr) + .await?; + + Ok(()) +} diff --git a/crates/server/src/manager.rs b/crates/server/src/manager.rs new file mode 100644 index 00000000..e9c99748 --- /dev/null +++ b/crates/server/src/manager.rs @@ -0,0 +1,46 @@ +use machine::{Machine, MachineState, ToolRuntime}; +use std::collections::HashMap; +use utils::MachineId; + +pub struct Run { + pub machine: Machine, + pub state: MachineState, + pub tool_runtime: ToolRuntime, + /// Per-machine trajectory store. Always present unless opening the WAL + /// failed — trajectory loss must never prevent a run from existing. + pub store: Option<storage::Store>, +} + +pub struct MachineManager { + machines: HashMap<MachineId, Run>, +} + +impl Default for MachineManager { + fn default() -> Self { + Self::new() + } +} + +impl MachineManager { + pub fn new() -> Self { + Self { + machines: HashMap::new(), + } + } + + pub fn insert(&mut self, id: MachineId, run: Run) { + self.machines.insert(id, run); + } + + pub fn get(&self, id: &MachineId) -> Option<&Run> { + self.machines.get(id) + } + + pub fn get_mut(&mut self, id: &MachineId) -> Option<&mut Run> { + self.machines.get_mut(id) + } + + pub fn destroy(&mut self, id: &MachineId) { + self.machines.remove(id); + } +} diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs new file mode 100644 index 00000000..d0a2e70b --- /dev/null +++ b/crates/server/src/mcp.rs @@ -0,0 +1,61 @@ +use accelerator::mcp::{McpServerConfig, McpTransportConfig}; +use std::collections::HashMap; +use tonic::Status; + +pub fn mcp_config_from_spec(spec: &crate::rcm::McpServerSpec) -> Result<McpServerConfig, Status> { + let transport = spec + .transport + .as_ref() + .ok_or(Status::invalid_argument("mcp requires transport"))?; + use crate::rcm::mcp_transport_spec::Kind; + let transport = match &transport.kind { + Some(Kind::Stdio(stdio)) => McpTransportConfig::Stdio { + command: stdio.command.clone(), + args: stdio.args.clone(), + env: mcp_env_from_spec(&stdio.env)?, + cwd: stdio.cwd.clone().map(std::path::PathBuf::from), + }, + Some(Kind::Http(http)) => McpTransportConfig::Http { + url: http.url.clone(), + headers: mcp_headers_from_spec(&http.headers)?, + }, + Some(Kind::Sse(sse)) => McpTransportConfig::Sse { + url: sse.url.clone(), + headers: mcp_headers_from_spec(&sse.headers)?, + }, + None => return Err(Status::invalid_argument("mcp transport kind required")), + }; + Ok(McpServerConfig { + label: spec.label.clone(), + transport, + }) +} + +fn mcp_env_from_spec( + values: &HashMap<String, crate::rcm::McpValueSpec>, +) -> Result<HashMap<String, String>, Status> { + values + .iter() + .map(|(name, value)| Ok((name.clone(), mcp_value_text(value)?))) + .collect() +} + +fn mcp_headers_from_spec( + values: &HashMap<String, crate::rcm::McpValueSpec>, +) -> Result<Vec<(String, String)>, Status> { + values + .iter() + .map(|(name, value)| Ok((name.clone(), mcp_value_text(value)?))) + .collect() +} + +fn mcp_value_text(value: &crate::rcm::McpValueSpec) -> Result<String, Status> { + use crate::rcm::mcp_value_spec::Source; + match &value.source { + Some(Source::Literal(value)) => Ok(value.clone()), + Some(Source::Env(name)) => std::env::var(name).map_err(|error| { + Status::invalid_argument(format!("env var '{name}' not set: {error}")) + }), + None => Ok(String::new()), + } +} diff --git a/crates/server/src/service.rs b/crates/server/src/service.rs new file mode 100644 index 00000000..9257332a --- /dev/null +++ b/crates/server/src/service.rs @@ -0,0 +1,199 @@ +use accelerator::{Catalog, ResourceSelection}; +use machine::{ExecutionMode, Machine, MachineFrame, MachineState, Purpose, RunState}; +use tonic::{Request, Response, Status}; + +use crate::action_space::build_action_space; +use crate::decode::{build_model, decode_command}; +use crate::manager::{MachineManager, Run}; +use crate::rcm::{ + DestroyRequest, OpenRequest, OpenResponse, StepRequest, StepResponse, rcm_server::Rcm, +}; +use crate::state::build_state; + +pub struct RcmService { + pub manager: std::sync::Arc<tokio::sync::Mutex<MachineManager>>, + catalog: Catalog, +} + +impl RcmService { + pub fn new(manager: MachineManager) -> Self { + Self::with_catalog(manager, Catalog::new()) + } + + pub fn with_catalog(manager: MachineManager, catalog: Catalog) -> Self { + Self { + manager: std::sync::Arc::new(tokio::sync::Mutex::new(manager)), + catalog, + } + } +} + +#[tonic::async_trait] +impl Rcm for RcmService { + async fn open(&self, request: Request<OpenRequest>) -> Result<Response<OpenResponse>, Status> { + let request = request.into_inner(); + let mut catalog = self.catalog.clone(); + + for spec in &request.model_definitions { + catalog + .register_model(build_model(spec)?) + .map_err(Status::invalid_argument)?; + } + + for spec in &request.mcp_definitions { + catalog + .register_mcp_server(crate::mcp::mcp_config_from_spec(spec)?) + .map_err(Status::invalid_argument)?; + } + + let environment_name = if request.environment.is_empty() { + "local" + } else { + request.environment.as_str() + }; + let environment = catalog + .environment(environment_name) + .map_err(Status::invalid_argument)?; + let runtime_resources = catalog + .build_runtime_resources(ResourceSelection { + models: request.models, + tools: request.tools, + mcp_servers: request.mcps, + prompt_texts: request.prompts, + }) + .await + .map_err(Status::invalid_argument)?; + + let machine_id = utils::MachineId::new(); + // Trajectory recording is data-collection-on-by-default for the + // server. An explicit run_dir is honored as the run directory with + // the WAL under run_dir/trajectory/<machine_id> — the same layout + // the CLI writes. Without one, the run directory is unset and the + // WAL lands directly under RCM_SERVER_TRAJECTORY_DIR/<machine_id>. + let (run_dir, trajectory_dir) = match request.run_dir.filter(|dir| !dir.is_empty()) { + Some(explicit) => { + let run_dir = std::path::PathBuf::from(explicit); + let trajectory_dir = run_dir.join("trajectory").join(machine_id.as_str()); + (Some(run_dir), trajectory_dir) + } + None => { + let root = std::env::var("RCM_SERVER_TRAJECTORY_DIR") + .unwrap_or_else(|_| "./rcm-trajectories".to_string()); + (None, std::path::Path::new(&root).join(machine_id.as_str())) + } + }; + let store = match storage::Store::open(&trajectory_dir) { + Ok(store) => Some(store), + Err(error) => { + tracing::warn!( + dir = %trajectory_dir.display(), + ?error, + "trajectory store unavailable; run continues without recording" + ); + None + } + }; + let run = Run { + machine: Machine::new(machine_id.as_str(), "rcm"), + state: MachineState { + run: RunState { + purpose: Purpose::new(request.purpose), + run_dir, + context: machine::Context::new(), + environment, + resources: runtime_resources.resources, + telemetry: machine::Telemetry::default(), + }, + frame: MachineFrame::default(), + }, + tool_runtime: runtime_resources.tool_runtime, + store, + }; + + let action_space = build_action_space(&run); + let state = build_state(&run); + + { + let mut manager = self.manager.lock().await; + manager.insert(machine_id.clone(), run); + } + + Ok(Response::new(OpenResponse { + machine_id: machine_id.to_string(), + state: Some(state), + action_space: Some(action_space), + })) + } + + async fn step(&self, request: Request<StepRequest>) -> Result<Response<StepResponse>, Status> { + let request = request.into_inner(); + let machine_id = utils::MachineId::from_raw(request.machine_id) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + let command = request + .command + .ok_or(Status::invalid_argument("command required"))?; + let action = decode_command(&command)?; + let mut manager = self.manager.lock().await; + let run = manager + .get_mut(&machine_id) + .ok_or(Status::not_found("machine_id not found"))?; + + // Decision-time observation, derived before the action is applied. + // The gRPC Step path has no policy object to declare an overlay; + // external controllers stay on the tape-only protocol (v1), so the + // measured default (no declaration) is already accurate. + let obs = machine::obs::measure(&run.state.run); + let overlay_declared = machine::Overlay::default(); + let result = run + .machine + .apply( + action, + &mut run.state, + ExecutionMode::Live { + tool_runtime: &run.tool_runtime, + overlay: &overlay_declared, + }, + ) + .await; + if let Some(ref mut store) = run.store { + let trajectory = storage::TrajectoryEvent { + step: result.event.step, + obs, + ledger_transitions: machine::ledger_transitions_in(&result.event.effects), + event: result.event.clone(), + }; + if let Err(error) = store.record_trajectory(&trajectory) { + tracing::warn!( + machine_id = machine_id.as_str(), + ?error, + "trajectory write failed; step result is unaffected" + ); + } + } + + let action_space = build_action_space(run); + let state = build_state(run); + Ok(Response::new(StepResponse { + state: Some(state), + action_space: Some(action_space), + })) + } + + async fn destroy(&self, request: Request<DestroyRequest>) -> Result<Response<()>, Status> { + let machine_id = utils::MachineId::from_raw(request.into_inner().machine_id) + .map_err(|error| Status::invalid_argument(error.to_string()))?; + let mut manager = self.manager.lock().await; + if let Some(run) = manager.get_mut(&machine_id) + && let Some(ref mut store) = run.store + && let Err(error) = store.checkpoint(&run.state) + { + tracing::warn!( + machine_id = machine_id.as_str(), + ?error, + "final trajectory checkpoint failed" + ); + } + manager.destroy(&machine_id); + Ok(Response::new(())) + } +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs new file mode 100644 index 00000000..c01631d6 --- /dev/null +++ b/crates/server/src/state.rs @@ -0,0 +1,178 @@ +use machine::event; +use machine::{Content, Fragment, MachineStatus}; + +use crate::manager::Run; +use crate::rcm; + +pub fn build_state(run: &Run) -> rcm::State { + let state = &run.state; + let run_state = &state.run; + let obs = machine::obs::measure(run_state); + let counts = run_state + .telemetry + .action_counts + .iter() + .map(|(action, count)| (action.to_string(), *count)) + .collect(); + + rcm::State { + purpose: run_state.purpose.text.clone(), + machine_id: run.machine.id.to_string(), + fragments: run_state + .context + .fragments() + .iter() + .map(fragment_to_proto) + .collect(), + workdir: run_state.environment.cwd.to_string_lossy().into_owned(), + env_vars: run_state.environment.vars.clone(), + active_model: run_state.resources.active_model.clone(), + active_tools: run_state.resources.active_tools.iter().cloned().collect(), + available_models: run_state.resources.model_order.clone(), + available_tools: run_state + .resources + .tool_definitions + .keys() + .cloned() + .collect(), + done: state.frame.status == MachineStatus::Done, + inbox_pending: state.frame.inbox.peek().is_some(), + inbox_peek: state + .frame + .inbox + .peek() + .map(|item| fragment_to_proto(&item.fragment)), + counts, + usages: run_state + .telemetry + .completions + .iter() + .map(completion_to_proto) + .collect(), + tool_profiles: build_tool_profiles(run), + model_profiles: build_model_profiles(run), + platform: run_state.environment.platform.clone(), + root: run_state + .environment + .root + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + obs: Some(obs_to_proto(&obs)), + } +} + +fn obs_to_proto(obs: &machine::obs::Obs) -> rcm::Obs { + rcm::Obs { + budget: Some(rcm::Budget { + context_limit: obs.budget.context_limit, + estimated_input: obs.budget.estimated_input, + soft_threshold: obs.budget.soft_threshold, + headroom: obs.budget.headroom, + last_actual_input: obs.budget.last_actual_input, + }), + } +} + +fn build_tool_profiles(run: &Run) -> Vec<rcm::ToolProfile> { + let resources = &run.state.run.resources; + resources + .tool_definitions + .iter() + .map(|(name, tool)| rcm::ToolProfile { + name: name.clone(), + description: tool.description.clone(), + active: resources.active_tools.contains(name), + }) + .collect() +} + +fn build_model_profiles(run: &Run) -> Vec<rcm::ModelProfile> { + let resources = &run.state.run.resources; + resources + .models + .iter() + .map(|(name, model)| rcm::ModelProfile { + name: name.clone(), + protocol: format!("{:?}", model.protocol), + limit: model.limit.as_ref().map(|limit| rcm::LimitSpec { + context: limit.context, + input: limit.input, + output: limit.output, + }), + active: resources.active_model == *name, + }) + .collect() +} + +fn completion_to_proto(record: &machine::CompletionRecord) -> rcm::Usage { + rcm::Usage { + input_tokens: record.tokens.input_tokens, + output_tokens: record.tokens.output_tokens, + total_tokens: record.tokens.total_tokens, + cached_input_tokens: record.tokens.cached_input_tokens, + cache_creation_input_tokens: record.tokens.cache_creation_input_tokens, + fragment_ids: record.output_fragment_ids.clone(), + } +} + +fn media_source_from_content(content: &machine::Content) -> Option<rcm::MediaSource> { + use machine::fragment::DataSource; + let source = match content { + Content::Image(image) => &image.source, + Content::Audio(audio) => &audio.source, + Content::Video(video) => &video.source, + Content::Document(document) => &document.source, + _ => return None, + }; + let source = match source { + DataSource::Url(url) => rcm::media_source::Source::Url(url.clone()), + DataSource::Base64(data) => rcm::media_source::Source::Base64(data.clone()), + _ => return None, + }; + Some(rcm::MediaSource { + source: Some(source), + media_type: None, + alt_text: None, + }) +} + +fn fragment_to_proto(fragment: &Fragment) -> rcm::Fragment { + let kind = match &fragment.content { + Content::Text(_) => "text", + Content::ToolCall(_) => "tool_call", + Content::ToolResult(_) => "tool_result", + Content::Hitch { .. } => "hitch", + Content::Image(_) => "image", + Content::Audio(_) => "audio", + Content::Video(_) => "video", + Content::Document(_) => "document", + }; + rcm::Fragment { + id: fragment.id(), + role: event::role_name(fragment.role).into(), + kind: kind.into(), + text_preview: clip(fragment), + tag: Some(fragment.tag.clone()), + content_text: Some(fragment.content_as_text()), + media_source: media_source_from_content(&fragment.content), + } +} + +fn clip(fragment: &Fragment) -> String { + let full = match &fragment.content { + Content::Text(text) => text.text.clone(), + Content::Hitch { message, .. } => message.clone(), + Content::ToolCall(tool_call) => format!("tool_call: {}", tool_call.name), + Content::ToolResult(result) => { + format!("tool_result: {}", result.title.as_deref().unwrap_or("")) + } + _ => String::new(), + }; + if full.len() <= 200 { + full + } else { + let mut clipped = full.chars().take(200).collect::<String>(); + clipped.push_str("..."); + clipped + } +} diff --git a/crates/server/tests/integration.rs b/crates/server/tests/integration.rs new file mode 100644 index 00000000..af80a9c3 --- /dev/null +++ b/crates/server/tests/integration.rs @@ -0,0 +1,226 @@ +use server::manager::MachineManager; +use server::rcm::{ActionCommand, DestroyRequest, OpenRequest, OpenResponse, rcm_server::Rcm}; +use server::service::RcmService; +use tonic::Request; + +fn new_service() -> RcmService { + RcmService::new(MachineManager::new()) +} + +async fn open( + runtime: &RcmService, + purpose: &str, + prompts: &[(&str, &str)], +) -> (String, OpenResponse) { + let mut req = OpenRequest { + purpose: purpose.to_string(), + ..Default::default() + }; + for (k, v) in prompts { + req.prompts.insert(k.to_string(), v.to_string()); + } + let resp = runtime.open(Request::new(req)).await.unwrap().into_inner(); + (resp.machine_id.clone(), resp) +} + +#[tokio::test] +async fn open_creates_run_with_state() { + let runtime = new_service(); + let (_mid, resp) = open(&runtime, "test", &[]).await; + assert!(!resp.machine_id.is_empty()); + assert!(resp.state.is_some()); + assert!(resp.action_space.is_some()); + let state = resp.state.unwrap(); + assert!(!state.done); + assert!(state.fragments.is_empty()); +} + +#[tokio::test] +async fn open_returns_actions_for_prompts() { + let runtime = new_service(); + let (_mid, resp) = open( + &runtime, + "test", + &[ + ("captain", "You are a coding assistant."), + ("hello", "Hello world"), + ], + ) + .await; + let space = resp.action_space.unwrap(); + let append_labels: Vec<_> = space + .actions + .iter() + .filter(|a| a.command.as_ref().unwrap().verb == "Append") + .map(|a| a.label.clone()) + .collect(); + assert!(append_labels.contains(&"Append captain".to_string())); + assert!(append_labels.contains(&"Append hello".to_string())); +} + +#[tokio::test] +async fn append_then_done_ends_episode() { + let runtime = new_service(); + let (mid, resp) = open( + &runtime, + "done-test", + &[("captain", "you are an assistant")], + ) + .await; + let append_cmd = resp + .action_space + .unwrap() + .actions + .iter() + .find(|a| a.label == "Append captain") + .unwrap() + .command + .clone() + .unwrap(); + let step1 = runtime + .step(Request::new(server::rcm::StepRequest { + machine_id: mid.clone(), + command: Some(append_cmd), + })) + .await + .unwrap() + .into_inner(); + let state1 = step1.state.unwrap(); + assert_eq!(state1.fragments.len(), 1); + assert!(!state1.done); + let done_cmd = step1 + .action_space + .unwrap() + .actions + .iter() + .find(|a| a.command.as_ref().unwrap().verb == "Done") + .unwrap() + .command + .clone() + .unwrap(); + let step2 = runtime + .step(Request::new(server::rcm::StepRequest { + machine_id: mid.clone(), + command: Some(done_cmd), + })) + .await + .unwrap() + .into_inner(); + assert!(step2.state.unwrap().done); + runtime + .destroy(Request::new(DestroyRequest { machine_id: mid })) + .await + .unwrap(); +} + +#[tokio::test] +async fn destroy_removes_run() { + let runtime = new_service(); + let (mid, _) = open(&runtime, "ephemeral", &[]).await; + runtime + .destroy(Request::new(DestroyRequest { + machine_id: mid.clone(), + })) + .await + .unwrap(); + let err = runtime + .step(Request::new(server::rcm::StepRequest { + machine_id: mid, + command: Some(ActionCommand { + verb: "Done".into(), + ..Default::default() + }), + })) + .await + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::NotFound); +} + +#[tokio::test] +async fn remove_action_after_append() { + let runtime = new_service(); + let (mid, resp) = open(&runtime, "remove-test", &[("msg", "hello")]).await; + let append_cmd = resp + .action_space + .unwrap() + .actions + .iter() + .find(|a| a.label == "Append msg") + .unwrap() + .command + .clone() + .unwrap(); + let step1 = runtime + .step(Request::new(server::rcm::StepRequest { + machine_id: mid.clone(), + command: Some(append_cmd), + })) + .await + .unwrap() + .into_inner(); + assert_eq!(step1.state.as_ref().unwrap().fragments.len(), 1); + let remove_cmd = step1 + .action_space + .unwrap() + .actions + .iter() + .find(|a| a.command.as_ref().unwrap().verb == "Remove") + .unwrap() + .command + .clone() + .unwrap(); + let step2 = runtime + .step(Request::new(server::rcm::StepRequest { + machine_id: mid.clone(), + command: Some(remove_cmd), + })) + .await + .unwrap() + .into_inner(); + assert!(step2.state.unwrap().fragments.is_empty()); + runtime + .destroy(Request::new(DestroyRequest { machine_id: mid })) + .await + .unwrap(); +} + +#[tokio::test] +async fn take_in_consumption_mode() { + let runtime = new_service(); + let (mid, _) = open(&runtime, "take-test", &[]).await; + { + let mut mgr = runtime.manager.lock().await; + let run = mgr + .get_mut(&utils::MachineId::from_raw(mid.clone()).unwrap()) + .unwrap(); + run.state + .frame + .inbox + .push(machine::Fragment::system("LLM response")); + } + let step = runtime + .step(Request::new(server::rcm::StepRequest { + machine_id: mid.clone(), + command: Some(ActionCommand { + verb: "Take".into(), + ..Default::default() + }), + })) + .await + .unwrap() + .into_inner(); + let space = step.action_space.unwrap(); + let verbs: Vec<_> = space + .actions + .iter() + .map(|a| a.command.as_ref().unwrap().verb.clone()) + .collect(); + assert!( + verbs.contains(&"Halt".to_string()), + "Halt should be available after inbox is drained" + ); + runtime + .destroy(Request::new(DestroyRequest { machine_id: mid })) + .await + .unwrap(); +} diff --git a/crates/server/tests/layer_consistency.rs b/crates/server/tests/layer_consistency.rs new file mode 100644 index 00000000..17c55d16 --- /dev/null +++ b/crates/server/tests/layer_consistency.rs @@ -0,0 +1,478 @@ +use std::collections::HashSet; + +/// Every gRPC verb from `ACTION_VERBS` must be handled in `decode_command`. +/// +/// This test catches the case where a new Action variant is added to +/// `machine::policy` but the corresponding gRPC decode arm is not added. +#[test] +fn all_action_verbs_have_decode_handler() { + use machine::ACTION_VERBS; + use server::decode::decode_command; + use server::rcm::ActionCommand; + + for verb in ACTION_VERBS { + let cmd = ActionCommand { + verb: verb.to_string(), + ..Default::default() + }; + let result = decode_command(&cmd); + // The decode should either return Ok (for verbs that need no args) + // or Err with "invalid_argument" (for verbs that need args), + // but never panic or return an unknown-verb error. + match &result { + Ok(_) => {} // valid verb with no required args (Take, Halt, Done) + Err(s) => { + assert!( + !s.message().contains("unknown verb"), + "verb '{}' is not handled by decode_command: {}", + verb, + s.message() + ); + // Verbs that need args may return Err on empty ActionCommand + assert!( + s.message().contains("required") || s.message().contains("fragment"), + "verb '{}' unexpected error: {}", + verb, + s.message() + ); + } + } + } +} + +/// Every Action variant's verb string has a matching entry in `ACTION_VERBS`. +#[test] +fn all_action_variants_have_verb_in_const_list() { + use machine::ACTION_VERBS; + use machine::Action; + + let verbs: HashSet<&str> = ACTION_VERBS.iter().copied().collect(); + + // Collect all unique verbs from Action variants + let action_verbs: HashSet<&str> = vec![ + Action::Append(machine::Fragment::user("x")).verb(), + Action::Insert { + after: 0, + fragment: machine::Fragment::user("x"), + } + .verb(), + Action::Replace { + id: 0, + fragment: machine::Fragment::user("x"), + } + .verb(), + Action::Remove(0).verb(), + Action::Swap(0, 1).verb(), + Action::Model("x".into()).verb(), + Action::Activate("x".into()).verb(), + Action::Deactivate("x".into()).verb(), + Action::Take.verb(), + Action::Halt.verb(), + Action::Done.verb(), + ] + .into_iter() + .collect(); + + for v in &action_verbs { + assert!( + verbs.contains(v), + "Action verb '{}' is not in ACTION_VERBS const list — add it", + v + ); + } + for v in &verbs { + assert!( + action_verbs.contains(v), + "ACTION_VERBS contains '{}' but no Action variant produces it — remove it or add a variant", + v + ); + } +} + +/// `ACTION_VERBS` has no duplicate entries. +#[test] +fn action_verbs_has_no_duplicates() { + use machine::ACTION_VERBS; + let mut seen = HashSet::new(); + for v in ACTION_VERBS { + assert!(seen.insert(v), "ACTION_VERBS contains duplicate '{}'", v); + } +} + +/// State includes tool_infos and model_infos when tools/models are registered. +#[tokio::test] +async fn state_includes_tool_and_model_info() { + use server::manager::MachineManager; + use server::rcm::rcm_server::Rcm; + use server::rcm::{LimitSpec, ModelSpec, OpenRequest}; + use server::service::RcmService; + use tonic::Request; + + let runtime = RcmService::new(MachineManager::new()); + + let req = OpenRequest { + purpose: "info-test".into(), + model_definitions: vec![ModelSpec { + name: "test-model".into(), + protocol: "openai".into(), + limit: Some(LimitSpec { + context: 4096, + input: Some(2048), + output: 1024, + }), + ..Default::default() + }], + models: vec!["test-model".into()], + ..Default::default() + }; + let resp = runtime.open(Request::new(req)).await.unwrap().into_inner(); + let state = resp.state.unwrap(); + + // model_profiles: the one we requested should appear (but not active until activated) + let model_names: Vec<_> = state + .model_profiles + .iter() + .map(|profile| profile.name.as_str()) + .collect(); + assert!(model_names.contains(&"test-model")); + let model_profile = state + .model_profiles + .iter() + .find(|p| p.name == "test-model") + .unwrap(); + assert_eq!(model_profile.protocol, "OpenAI"); + + // tool_profiles: default catalog tools should be present + if !state.tool_profiles.is_empty() { + for tool_profile in &state.tool_profiles { + assert!(!tool_profile.name.is_empty()); + assert!(!tool_profile.description.is_empty()); + } + } +} + +/// Fragment content_text is populated for text fragments. +#[tokio::test] +async fn fragment_content_text_is_populated() { + use server::manager::MachineManager; + use server::rcm::rcm_server::Rcm; + use server::rcm::{DestroyRequest, OpenRequest, StepRequest}; + use server::service::RcmService; + use tonic::Request; + + let runtime = RcmService::new(MachineManager::new()); + + let resp = runtime + .open(Request::new(OpenRequest { + purpose: "content-test".into(), + prompts: vec![("hello".into(), "Hello world from test".into())] + .into_iter() + .collect(), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + let mid = resp.machine_id.clone(); + + // Find Append hello and send it + let append_cmd = resp + .action_space + .unwrap() + .actions + .iter() + .find(|a| a.label == "Append hello") + .unwrap() + .command + .clone() + .unwrap(); + + let step = runtime + .step(Request::new(StepRequest { + machine_id: mid.clone(), + command: Some(append_cmd), + })) + .await + .unwrap() + .into_inner(); + + let frag = &step.state.as_ref().unwrap().fragments[0]; + assert_eq!(frag.text_preview, "Hello world from test"); + assert_eq!(frag.content_text.as_deref(), Some("Hello world from test")); + + runtime + .destroy(Request::new(DestroyRequest { machine_id: mid })) + .await + .unwrap(); +} + +/// State includes platform and root fields from Environment. +#[tokio::test] +async fn state_includes_env_metadata() { + use server::manager::MachineManager; + use server::rcm::OpenRequest; + use server::rcm::rcm_server::Rcm; + use server::service::RcmService; + use tonic::Request; + + let runtime = RcmService::new(MachineManager::new()); + let resp = runtime + .open(Request::new(OpenRequest { + purpose: "env-test".into(), + ..Default::default() + })) + .await + .unwrap() + .into_inner(); + let state = resp.state.unwrap(); + + assert!(!state.platform.is_empty(), "platform should be set"); + // root is optional; just verify it exists as a field + let _ = state.root; +} + +/// build_fragment with kind="tool_call" creates a ToolCall fragment. +#[test] +fn build_fragment_with_kind_tool_call() { + use machine::Content; + use server::decode::build_fragment; + use server::rcm::FragmentContent; + + let fc = FragmentContent { + role: "assistant".into(), + text: "shell".into(), + tag: Some("call_1".into()), + kind: "tool_call".into(), + media_source: None, + }; + let frag = build_fragment(&fc); + assert!(matches!(frag.content, Content::ToolCall(_))); + if let Content::ToolCall(tc) = &frag.content { + assert_eq!(tc.name, "shell"); + } + // tag is preserved as the call_id and fragment tag + assert_eq!(frag.tag, "call_1"); +} + +/// build_fragment with kind="tool_result" creates a ToolResult fragment. +#[test] +fn build_fragment_with_kind_tool_result() { + use machine::Content; + use server::decode::build_fragment; + use server::rcm::FragmentContent; + + let fc = FragmentContent { + role: "tool".into(), + text: "stdout: hello".into(), + tag: Some("call_1".into()), + kind: "tool_result".into(), + media_source: None, + }; + let frag = build_fragment(&fc); + assert!(matches!(frag.content, Content::ToolResult(_))); + if let Content::ToolResult(tr) = &frag.content { + assert_eq!(tr.content, "stdout: hello"); + } +} + +/// build_fragment with kind="hitch" creates a Hitch fragment. +#[test] +fn build_fragment_with_kind_hitch() { + use machine::Content; + use server::decode::build_fragment; + use server::rcm::FragmentContent; + + let fc = FragmentContent { + role: "system".into(), + text: "something went wrong".into(), + tag: None, + kind: "hitch".into(), + media_source: None, + }; + let frag = build_fragment(&fc); + assert!(matches!(frag.content, Content::Hitch { .. })); +} + +/// build_fragment without kind (empty string) falls back to role-based mapping. +#[test] +fn build_fragment_empty_kind_falls_back_to_role() { + use server::decode::build_fragment; + use server::rcm::FragmentContent; + + // "user" role → User fragment + let fc = FragmentContent { + role: "user".into(), + text: "hello".into(), + tag: None, + kind: String::new(), + media_source: None, + }; + let frag = build_fragment(&fc); + assert_eq!(frag.role, machine::Role::User); + + // "assistant" role → Assistant fragment + let fc2 = FragmentContent { + role: "assistant".into(), + text: "ok".into(), + tag: None, + kind: String::new(), + media_source: None, + }; + let frag2 = build_fragment(&fc2); + assert_eq!(frag2.role, machine::Role::Assistant); +} + +/// build_model with cost/thinking/temperature populates Model correctly. +#[test] +fn build_model_with_cost_thinking_temperature() { + use server::decode::build_model; + use server::rcm::{CostSpec, LimitSpec, ModelSpec}; + + let spec = ModelSpec { + name: "test-model".into(), + protocol: "openai".into(), + cost: Some(CostSpec { + input: 0.15, + output: 0.30, + cache_read: Some(0.005), + cache_write: Some(0.01), + }), + thinking: true, + temperature: Some(0.7), + limit: Some(LimitSpec { + context: 4096, + input: Some(2048), + output: 1024, + }), + ..Default::default() + }; + let model = build_model(&spec).unwrap(); + assert_eq!(model.name, "test-model"); + assert!(model.thinking); + assert_eq!(model.temperature, Some(0.7)); + let cost = model.cost.unwrap(); + assert_eq!(cost.input, 0.15); + assert_eq!(cost.output, 0.30); + assert_eq!(cost.cache_read, Some(0.005)); + assert_eq!(cost.cache_write, Some(0.01)); +} + +// ── Multi-modal proto transport (Issue #68) ── + +/// FragmentContent with kind="image" + media_source creates an Image fragment. +#[test] +fn build_fragment_with_kind_image_and_media_source() { + use machine::Content; + use server::decode::build_fragment; + use server::rcm::FragmentContent; + use server::rcm::{MediaSource, media_source}; + + let fc = FragmentContent { + role: "user".into(), + text: String::new(), + tag: None, + kind: "image".into(), + media_source: Some(MediaSource { + source: Some(media_source::Source::Url( + "https://example.com/img.png".into(), + )), + media_type: Some("image/png".into()), + alt_text: None, + }), + }; + let frag = build_fragment(&fc); + assert!(matches!(frag.content, Content::Image(_))); +} + +/// FragmentContent with kind="audio" + media_source creates an Audio fragment. +#[test] +fn build_fragment_with_kind_audio_and_media_source() { + use machine::Content; + use server::decode::build_fragment; + use server::rcm::FragmentContent; + use server::rcm::{MediaSource, media_source}; + + let fc = FragmentContent { + role: "user".into(), + text: String::new(), + tag: None, + kind: "audio".into(), + media_source: Some(MediaSource { + source: Some(media_source::Source::Base64("AAAA".into())), + media_type: Some("audio/mp3".into()), + alt_text: None, + }), + }; + let frag = build_fragment(&fc); + assert!(matches!(frag.content, Content::Audio(_))); +} + +/// MCP data URL with valid image/* MIME type produces data URL string. +#[test] +fn mcp_data_url_valid_mime() { + use serde_json::json; + + let entry = json!({ + "type": "image", + "mimeType": "image/png", + "data": "iVBORw0KGgo" + }); + let output = format_mcp_content(&entry); + assert!(output.starts_with("data:image/png;base64,")); + assert!(output.contains("iVBORw0KGgo")); +} + +/// MCP data URL with invalid MIME type falls back to placeholder. +#[test] +fn mcp_data_url_invalid_mime() { + use serde_json::json; + + let entry = json!({ + "type": "image", + "mimeType": "application/x-shockwave-flash", + "data": "somebinarydata" + }); + let output = format_mcp_content(&entry); + assert!(output.contains("invalid mime type")); +} + +/// MCP data URL without mimeType defaults to fallback. +#[test] +fn mcp_data_url_no_data_falls_back() { + use serde_json::json; + + let entry = json!({ + "type": "image", + "url": "https://example.com/img.png" + }); + let output = format_mcp_content(&entry); + assert_eq!(output, "https://example.com/img.png"); +} + +// Helper: replicate the MCP append_content logic inline for testing. +fn format_mcp_content(entry: &serde_json::Value) -> String { + let mut output = String::new(); + let mime = entry + .get("mimeType") + .and_then(|v| v.as_str()) + .unwrap_or("application/octet-stream"); + let is_valid_mime = mime.starts_with("image/") || mime.starts_with("audio/"); + if let Some(data) = entry.get("data").and_then(|v| v.as_str()) { + if is_valid_mime { + output.push_str(&format!("data:{mime};base64,{data}")); + } else { + output.push_str(&format!( + "[MCP {} result: invalid mime type]", + entry.get("type").and_then(|v| v.as_str()).unwrap_or("") + )); + } + } else if let Some(url) = entry.get("url").and_then(|v| v.as_str()) { + output.push_str(url); + } else { + output.push_str(&format!( + "[MCP {} result]", + entry.get("type").and_then(|v| v.as_str()).unwrap_or("") + )); + } + output +} diff --git a/crates/server/tests/trajectory.rs b/crates/server/tests/trajectory.rs new file mode 100644 index 00000000..ab5c561b --- /dev/null +++ b/crates/server/tests/trajectory.rs @@ -0,0 +1,112 @@ +use server::manager::MachineManager; +use server::rcm::{DestroyRequest, OpenRequest, StepRequest, rcm_server::Rcm}; +use server::service::RcmService; +use storage::Store; +use tonic::Request; + +fn new_service() -> RcmService { + RcmService::new(MachineManager::new()) +} + +fn open_request(purpose: &str, run_dir: Option<String>) -> OpenRequest { + OpenRequest { + purpose: purpose.to_string(), + run_dir, + ..Default::default() + } +} + +#[tokio::test] +async fn open_step_destroy_produces_restorable_wal() { + let run_dir = tempfile::tempdir().unwrap(); + let runtime = new_service(); + + let response = runtime + .open(Request::new(open_request( + "trajectory integration", + Some(run_dir.path().to_string_lossy().into_owned()), + ))) + .await + .unwrap() + .into_inner(); + let machine_id = response.machine_id.clone(); + + // State carries the observation channel: obs is present, budget exists + // (zero-valued until a model with limits is registered). + let state = response.state.unwrap(); + assert!(state.obs.is_some(), "State.obs must be populated"); + + for _ in 0..3 { + runtime + .step(Request::new(StepRequest { + machine_id: machine_id.clone(), + command: Some(server::rcm::ActionCommand { + verb: "Take".into(), + ..Default::default() + }), + })) + .await + .unwrap(); + } + + runtime + .destroy(Request::new(DestroyRequest { machine_id })) + .await + .unwrap(); + + // The WAL lands under run_dir/trajectory/<machine_id> — the same + // layout the CLI writes — and restores to the server-side state. + let wal_dir = run_dir.path().join("trajectory"); + let machine_wal: Vec<_> = std::fs::read_dir(&wal_dir) + .expect("trajectory subdirectory must exist under the run dir") + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(machine_wal.len(), 1, "exactly one per-machine WAL"); + + let store = Store::open(&machine_wal[0]).unwrap(); + let restored = store.restore().await.unwrap().expect("recorded state"); + assert_eq!(restored.run.purpose.text, "trajectory integration"); + assert_eq!(restored.frame.step, 3, "three Take steps recorded"); + + let trajectories = store.trajectories().await.unwrap(); + assert_eq!(trajectories.len(), 3); + assert!( + trajectories + .iter() + .all(|trajectory| trajectory.event.action == machine::Action::Take) + ); +} + +#[tokio::test] +async fn open_without_run_dir_records_under_server_root() { + let server_root = tempfile::tempdir().unwrap(); + // Scoped env var swap; nextest isolates each test in its own process, + // and the value is restored for plain `cargo test` runs. + let previous = std::env::var("RCM_SERVER_TRAJECTORY_DIR").ok(); + // Environment mutation is process-global and unsafe in edition 2024. + unsafe { + std::env::set_var("RCM_SERVER_TRAJECTORY_DIR", server_root.path().as_os_str()); + } + + let runtime = new_service(); + let response = runtime + .open(Request::new(open_request("default root recording", None))) + .await + .unwrap() + .into_inner(); + + // The machine directory exists under the server root. + let machine_dir = server_root.path().join(&response.machine_id); + assert!( + machine_dir.is_dir(), + "machine trajectory directory must exist under the server root" + ); + + unsafe { + if let Some(previous_value) = previous { + std::env::set_var("RCM_SERVER_TRAJECTORY_DIR", previous_value); + } else { + std::env::remove_var("RCM_SERVER_TRAJECTORY_DIR"); + } + } +} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml new file mode 100644 index 00000000..c5cd0fd6 --- /dev/null +++ b/crates/storage/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "storage" +version = "0.1.0" +edition = "2024" +description = "RCM storage — WAL-backed session persistence" +publish = false + +[dependencies] +machine = { version = "0.1.0", path = "../machine" } +memmap2 = "0.9" +crc32c = "0.6" +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = "2" +tracing = { workspace = true } + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true } diff --git a/crates/storage/src/error.rs b/crates/storage/src/error.rs new file mode 100644 index 00000000..ea1a14cb --- /dev/null +++ b/crates/storage/src/error.rs @@ -0,0 +1,225 @@ +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum CorruptSegment { + #[error("segment file is too small to contain a valid header (got {actual_len} bytes)")] + FileTooSmall { actual_len: u64 }, + #[error("segment header magic mismatch: expected {expected:?}, got {actual:?}")] + BadMagic { expected: [u8; 4], actual: [u8; 4] }, + #[error("invalid item length {len} at offset {offset}: item header is incomplete")] + InvalidItemLen { len: u32, offset: u64 }, + #[error("item at offset {offset} extends past end of segment file")] + ItemTruncated { offset: u64 }, + #[error("checksum mismatch at offset {offset}: expected {expected:#010x}, got {actual:#010x}")] + ChecksumMismatch { + offset: u64, + expected: u32, + actual: u32, + }, + #[error("segment is already closed: cannot open for writing")] + AlreadyClosed, +} + +#[derive(Debug, thiserror::Error)] +pub enum CorruptSnapshot { + #[error("snapshot file header is too short to read: {source}")] + HeaderTooShort { source: std::io::Error }, + #[error("snapshot header magic mismatch: expected {expected:?}, got {actual:?}")] + BadMagic { expected: [u8; 4], actual: [u8; 4] }, + #[error("unsupported snapshot format version {version}")] + UnsupportedVersion { version: u16 }, + #[error("snapshot payload is shorter than declared in header: {source}")] + PayloadTruncated { source: std::io::Error }, + #[error("snapshot checksum mismatch: expected {expected:#010x}, got {actual:#010x}")] + ChecksumMismatch { expected: u32, actual: u32 }, +} + +#[derive(Debug, thiserror::Error)] +pub enum CorruptIndex { + #[error("index file is too small to contain a valid header (got {actual_len} bytes)")] + FileTooSmall { actual_len: u64 }, + #[error("index header magic mismatch: expected {expected:?}, got {actual:?}")] + BadMagic { expected: [u8; 4], actual: [u8; 4] }, + #[error("index item data is truncated: expected {expected_len} bytes, got {actual_len}")] + ItemTruncated { + expected_len: usize, + actual_len: usize, + }, + #[error("index checksum mismatch: expected {expected:#010x}, got {actual:#010x}")] + ChecksumMismatch { expected: u32, actual: u32 }, +} + +#[derive(Debug, thiserror::Error)] +pub enum CorruptManifest { + #[error("manifest field '{key}' has an invalid value that cannot be parsed")] + InvalidField { key: &'static str }, + #[error("manifest is missing the required 'version' field")] + MissingVersion, + #[error("manifest invariant violated: {detail}")] + InvariantViolated { detail: &'static str }, +} + +#[derive(Debug, Clone, Copy)] +pub enum IoOperation { + Create, + Read, + Write, + Sync, + Rename, + Remove, +} + +impl std::fmt::Display for IoOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IoOperation::Create => write!(f, "create"), + IoOperation::Read => write!(f, "read"), + IoOperation::Write => write!(f, "write"), + IoOperation::Sync => write!(f, "sync"), + IoOperation::Rename => write!(f, "rename"), + IoOperation::Remove => write!(f, "remove"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + Operational, + Integrity, + Fatal, +} + +pub mod codes { + pub const IO: u16 = 1000; + pub const INVALID_INPUT: u16 = 1001; + pub const SEGMENT_CORRUPTED: u16 = 2002; + pub const SNAPSHOT_CORRUPTED: u16 = 2003; + pub const INDEX_CORRUPTED: u16 = 2004; + pub const SNAPSHOT_NOT_FOUND: u16 = 3001; + pub const OFFSET_OUT_OF_RANGE: u16 = 3002; + pub const SEGMENT_NOT_FOUND: u16 = 3003; + pub const MANIFEST_ERROR: u16 = 4001; + pub const CODEC: u16 = 5001; + pub const REPLAY_FAILED: u16 = 5002; +} + +#[derive(Debug, thiserror::Error)] +pub enum WalError { + #[error("segment {segment_id} corrupted: {reason}")] + SegmentCorrupted { + segment_id: u64, + reason: CorruptSegment, + }, + #[error("snapshot corrupted at {path}: {reason}")] + SnapshotCorrupted { + path: PathBuf, + reason: CorruptSnapshot, + }, + #[error("index corrupted for segment {segment_id}: {reason}")] + IndexCorrupted { + segment_id: u64, + reason: CorruptIndex, + }, + #[error("no snapshot found: the WAL has never been checkpointed")] + SnapshotNotFound, + #[error("offset {requested} out of range (next available is {next})")] + OffsetOutOfRange { requested: u64, next: u64 }, + #[error("segment {segment_id} not found")] + SegmentNotFound { segment_id: u64 }, + #[error("manifest error: {reason}")] + ManifestError { reason: CorruptManifest }, + #[error("{operation} {path}: {source}")] + Io { + path: PathBuf, + operation: IoOperation, + source: std::io::Error, + }, + #[error("invalid input: {detail}")] + InvalidInput { detail: &'static str }, + #[error("codec error: {detail}")] + Codec { detail: String }, + #[error("replay failed at offset {offset}: {detail}")] + ReplayFailed { offset: u64, detail: String }, +} + +impl WalError { + pub fn at(path: PathBuf, operation: IoOperation) -> impl FnOnce(std::io::Error) -> Self { + move |source| Self::Io { + path, + operation, + source, + } + } + + pub fn code(&self) -> u16 { + match self { + Self::Io { .. } => codes::IO, + Self::InvalidInput { .. } => codes::INVALID_INPUT, + Self::SegmentCorrupted { .. } => codes::SEGMENT_CORRUPTED, + Self::SnapshotCorrupted { .. } => codes::SNAPSHOT_CORRUPTED, + Self::IndexCorrupted { .. } => codes::INDEX_CORRUPTED, + Self::SnapshotNotFound => codes::SNAPSHOT_NOT_FOUND, + Self::OffsetOutOfRange { .. } => codes::OFFSET_OUT_OF_RANGE, + Self::SegmentNotFound { .. } => codes::SEGMENT_NOT_FOUND, + Self::ManifestError { .. } => codes::MANIFEST_ERROR, + Self::Codec { .. } => codes::CODEC, + Self::ReplayFailed { .. } => codes::REPLAY_FAILED, + } + } + + pub fn severity(&self) -> Severity { + match self { + Self::SnapshotNotFound + | Self::OffsetOutOfRange { .. } + | Self::SegmentNotFound { .. } => Severity::Operational, + Self::SegmentCorrupted { .. } + | Self::SnapshotCorrupted { .. } + | Self::IndexCorrupted { .. } => Severity::Integrity, + Self::ManifestError { .. } + | Self::Io { .. } + | Self::InvalidInput { .. } + | Self::Codec { .. } + | Self::ReplayFailed { .. } => Severity::Fatal, + } + } + + pub fn hint(&self) -> &'static str { + match self { + Self::SegmentCorrupted { .. } => { + "Segment file is damaged. Run `axiom recover` to repair or remove the affected segment." + } + Self::SnapshotCorrupted { .. } => { + "Snapshot file is damaged. Delete the corrupted snapshot and recover from an earlier one." + } + Self::IndexCorrupted { .. } => { + "Index file is damaged. Run `axiom recover` to rebuild the sparse index from the segment." + } + Self::SnapshotNotFound => { + "No snapshot exists yet. The engine will replay the full WAL on startup." + } + Self::OffsetOutOfRange { .. } => { + "The requested offset does not exist. Check that the offset is valid." + } + Self::SegmentNotFound { .. } => { + "The requested segment does not exist. The segment ID may be incorrect." + } + Self::ManifestError { .. } => { + "The manifest is corrupted or missing. Check the manifest file or restore from backup." + } + Self::Io { .. } => { + "A file system operation failed. Check disk space, permissions, and hardware health." + } + Self::InvalidInput { .. } => { + "The input is too large or malformed. Reduce the payload size and retry." + } + Self::Codec { .. } => { + "Stored data could not be encoded or decoded. Check the storage format version." + } + Self::ReplayFailed { .. } => { + "A recorded machine event could not be applied to the restored state." + } + } + } +} + +pub type WalResult<T> = std::result::Result<T, WalError>; diff --git a/crates/storage/src/index.rs b/crates/storage/src/index.rs new file mode 100644 index 00000000..6c854f7d --- /dev/null +++ b/crates/storage/src/index.rs @@ -0,0 +1,183 @@ +use std::fs; +use std::io::{Read, Write}; +use std::path::Path; + +use crate::error::{CorruptIndex, IoOperation, WalError, WalResult}; +use crate::io; + +const MAGIC: &[u8; 4] = b"SIDX"; +const HEADER_SIZE: usize = 4096; +const ENTRY_SIZE: usize = 16; + +#[derive(Debug)] +pub struct SparseIndex { + pub items: Vec<(u64, u64)>, + pub seg_id: u64, + pub interval: u32, + pub count: u64, +} + +impl SparseIndex { + pub fn new(seg_id: u64, interval: u32) -> Self { + Self { + items: Vec::new(), + seg_id, + interval, + count: 0, + } + } + + pub fn insert(&mut self, offset: u64, byte_off: u64) { + if self.interval == 0 || self.count.is_multiple_of(self.interval as u64) { + self.items.push((offset, byte_off)); + } + self.count += 1; + } + + pub fn find(&self, target_off: u64) -> Option<(u64, u64)> { + if self.items.is_empty() { + return None; + } + match self.items.binary_search_by_key(&target_off, |(o, _)| *o) { + Ok(i) => Some(self.items[i]), + Err(0) => None, + Err(i) => Some(self.items[i - 1]), + } + } + + pub fn save(&self, path: &Path) -> WalResult<()> { + tracing::debug!( + seg_id = self.seg_id, + entries = self.items.len(), + "saving sparse index" + ); + let mut items_buf = Vec::with_capacity(self.items.len() * ENTRY_SIZE); + for &(offset, byte_off) in &self.items { + items_buf.extend_from_slice(&offset.to_le_bytes()); + items_buf.extend_from_slice(&byte_off.to_le_bytes()); + } + let items_crc = crc32c::crc32c(&items_buf); + + let mut buf = Vec::with_capacity(HEADER_SIZE + items_buf.len()); + buf.extend_from_slice(MAGIC); + buf.extend_from_slice(&self.seg_id.to_le_bytes()); + buf.extend_from_slice(&self.interval.to_le_bytes()); + buf.extend_from_slice(&(self.items.len() as u32).to_le_bytes()); + buf.extend_from_slice(&items_crc.to_le_bytes()); + buf.extend_from_slice(&self.count.to_le_bytes()); + buf.resize(HEADER_SIZE, 0); + buf.extend_from_slice(&items_buf); + + let temp_path = path.with_extension("idx.tmp"); + { + let mut file = fs::File::create(&temp_path) + .map_err(WalError::at(temp_path.clone(), IoOperation::Create))?; + file.write_all(&buf) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + file.sync_all() + .map_err(WalError::at(temp_path.clone(), IoOperation::Sync))?; + } + fs::rename(&temp_path, path) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Rename))?; + + if let Some(parent) = path.parent() { + io::fsync_dir(parent)?; + } + + Ok(()) + } + + pub fn load(path: &Path) -> WalResult<Self> { + let mut data = Vec::new(); + let mut file = + fs::File::open(path).map_err(WalError::at(path.to_path_buf(), IoOperation::Read))?; + file.read_to_end(&mut data) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Read))?; + + if data.len() < HEADER_SIZE { + return Err(WalError::IndexCorrupted { + segment_id: 0, + reason: CorruptIndex::FileTooSmall { + actual_len: data.len() as u64, + }, + }); + } + + let header = &data[..HEADER_SIZE]; + + if &header[0..4] != MAGIC { + return Err(WalError::IndexCorrupted { + segment_id: 0, + reason: CorruptIndex::BadMagic { + expected: *MAGIC, + actual: header[0..4].try_into().unwrap(), + }, + }); + } + + let seg_id = le_u64(header, 4); + let interval = le_u32(header, 12); + let item_count = le_u32(header, 16) as usize; + let stored_crc = le_u32(header, 20); + let count = le_u64(header, 24); + + let expected_len = HEADER_SIZE + item_count * ENTRY_SIZE; + if data.len() < expected_len { + return Err(WalError::IndexCorrupted { + segment_id: seg_id, + reason: CorruptIndex::ItemTruncated { + expected_len: item_count * ENTRY_SIZE, + actual_len: data.len() - HEADER_SIZE, + }, + }); + } + + let items_data = &data[HEADER_SIZE..expected_len]; + let computed_crc = crc32c::crc32c(items_data); + if computed_crc != stored_crc { + return Err(WalError::IndexCorrupted { + segment_id: seg_id, + reason: CorruptIndex::ChecksumMismatch { + expected: stored_crc, + actual: computed_crc, + }, + }); + } + + let mut items = Vec::with_capacity(item_count); + for chunk in items_data.as_chunks::<ENTRY_SIZE>().0 { + let offset = le_u64(chunk, 0); + let byte_off = le_u64(chunk, 8); + items.push((offset, byte_off)); + } + + tracing::debug!(seg_id, entries = items.len(), count, "sparse index loaded"); + + Ok(Self { + items, + seg_id, + interval, + count, + }) + } + + pub fn set_count(&mut self, count: u64) { + self.count = count; + } +} + +pub fn le_u64(buf: &[u8], offset: usize) -> u64 { + u64::from_le_bytes( + buf[offset..offset + 8] + .try_into() + .expect("slice is 8 bytes"), + ) +} + +pub fn le_u32(buf: &[u8], offset: usize) -> u32 { + u32::from_le_bytes( + buf[offset..offset + 4] + .try_into() + .expect("slice is 4 bytes"), + ) +} diff --git a/crates/storage/src/io.rs b/crates/storage/src/io.rs new file mode 100644 index 00000000..11a1d65b --- /dev/null +++ b/crates/storage/src/io.rs @@ -0,0 +1,11 @@ +use std::path::Path; + +use crate::error::{IoOperation, WalError, WalResult}; + +pub(crate) fn fsync_dir(path: &Path) -> WalResult<()> { + let dir = + std::fs::File::open(path).map_err(WalError::at(path.to_path_buf(), IoOperation::Sync))?; + dir.sync_all() + .map_err(WalError::at(path.to_path_buf(), IoOperation::Sync))?; + Ok(()) +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs new file mode 100644 index 00000000..23d15a37 --- /dev/null +++ b/crates/storage/src/lib.rs @@ -0,0 +1,585 @@ +pub use error::{WalError, WalResult}; +pub use store::{Store, TrajectoryEvent}; + +pub mod error; +pub mod index; +mod io; +pub mod manifest; +pub mod segment; +pub mod snapshot; +pub mod store; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use error::{CorruptManifest, CorruptSegment, IoOperation}; +use index::SparseIndex; +use manifest::Manifest; +use segment::{SegmentReader, SegmentWriter}; + +pub struct Wal { + manifest: Manifest, + writer: Option<SegmentWriter>, + readers: BTreeMap<u64, SegmentReader>, + indexes: BTreeMap<u64, SparseIndex>, + dir: PathBuf, +} + +impl Wal { + pub fn open(dir: &Path) -> WalResult<Self> { + tracing::info!(dir = %dir.display(), "opening wal"); + + let seg_base = dir.join("events").join("segments"); + std::fs::create_dir_all(&seg_base).map_err(WalError::at(seg_base, IoOperation::Create))?; + let snap_dir = dir.join("snapshots"); + std::fs::create_dir_all(&snap_dir).map_err(WalError::at(snap_dir, IoOperation::Create))?; + + let mut manifest = Manifest::open(dir)?; + + let mut readers = BTreeMap::new(); + let mut indexes = BTreeMap::new(); + + for seg_id in manifest.first_seg()..manifest.open_seg() { + let path = seg_path(dir, seg_id); + if !path.exists() { + return Err(WalError::SegmentNotFound { segment_id: seg_id }); + } + let reader = SegmentReader::open(&path)?; + readers.insert(seg_id, reader); + + let idx = idx_path(dir, seg_id); + if idx.exists() { + let index = SparseIndex::load(&idx)?; + indexes.insert(seg_id, index); + } + } + + let (writer, active_index) = if manifest.open_seg() == 0 { + let seg_id = 1; + let d = seg_dir(dir, seg_id); + std::fs::create_dir_all(&d).map_err(WalError::at(d.clone(), IoOperation::Create))?; + + let writer = SegmentWriter::new(&d, seg_id, 0, manifest.seg_size())?; + let index = SparseIndex::new(seg_id, manifest.idx_interval()); + manifest.set_open_seg(seg_id); + manifest.set_next_offset(0); + manifest.save()?; + io::fsync_dir(&dir.join("events"))?; + (writer, index) + } else { + let seg_id = manifest.open_seg(); + let path = seg_path(dir, seg_id); + + if path.exists() { + match SegmentWriter::open(&path, manifest.seg_size(), manifest.idx_interval()) { + Ok((writer, index)) => { + if writer.truncated_items() > 0 { + tracing::warn!( + seg_id, + truncated = writer.truncated_items(), + "items truncated during recovery" + ); + } + manifest.set_next_offset(writer.offset() + writer.count() as u64); + (writer, index) + } + Err(WalError::SegmentCorrupted { + reason: CorruptSegment::AlreadyClosed, + .. + }) => { + tracing::info!( + seg_id, + "active segment already closed, demoting to reader and creating new segment" + ); + // Rotate committed manifest but crashed — demote to closed reader. + let reader = SegmentReader::open(&path)?; + readers.insert(seg_id, reader); + + let idx = idx_path(dir, seg_id); + let index = if idx.exists() { + SparseIndex::load(&idx)? + } else { + SparseIndex::new(seg_id, manifest.idx_interval()) + }; + indexes.insert(seg_id, index); + + let next_seg_id = seg_id + 1; + let d = seg_dir(dir, next_seg_id); + std::fs::create_dir_all(&d) + .map_err(WalError::at(d.clone(), IoOperation::Create))?; + let writer = SegmentWriter::new( + &d, + next_seg_id, + manifest.next_offset(), + manifest.seg_size(), + )?; + let next_index = SparseIndex::new(next_seg_id, manifest.idx_interval()); + manifest.set_open_seg(next_seg_id); + manifest.save()?; + io::fsync_dir(&dir.join("events"))?; + (writer, next_index) + } + Err(e) => return Err(e), + } + } else { + return Err(WalError::SegmentNotFound { segment_id: seg_id }); + } + }; + + let active_seg_id = writer.id(); + indexes.insert(active_seg_id, active_index); + + tracing::info!( + active_seg = active_seg_id, + closed_segments = readers.len(), + next_offset = manifest.next_offset(), + snap_offset = ?manifest.snap_offset(), + "wal opened" + ); + + Ok(Self { + manifest, + writer: Some(writer), + readers, + indexes, + dir: dir.to_path_buf(), + }) + } + + /// Append an opaque byte item. Returns `(global_offset, byte_position)`. + /// Data is written and fsynced before returning. + pub fn append(&mut self, bytes: &[u8]) -> WalResult<(u64, u64)> { + let writer = self.writer.as_mut().ok_or(WalError::ManifestError { + reason: CorruptManifest::InvariantViolated { + detail: "no active writer", + }, + })?; + + let (offset, position) = writer.append(bytes)?; + + if let Some(index) = self.indexes.get_mut(&writer.id()) { + index.insert(offset, position); + } + + self.manifest.set_next_offset(offset + 1); + + if writer.is_full() { + tracing::debug!(seg_id = writer.id(), offset, "segment full, rotating"); + self.rotate()?; + } + + Ok((offset, position)) + } + + /// Append multiple items in one write + one fsync. + /// Returns `(global_offset, byte_position)` per item. + pub fn append_batch(&mut self, items: &[&[u8]]) -> WalResult<Vec<(u64, u64)>> { + if items.is_empty() { + return Ok(Vec::new()); + } + + let writer = self.writer.as_mut().ok_or(WalError::ManifestError { + reason: CorruptManifest::InvariantViolated { + detail: "no active writer", + }, + })?; + + let results = writer.append_batch(items)?; + + if let Some(index) = self.indexes.get_mut(&writer.id()) { + for &(offset, position) in &results { + index.insert(offset, position); + } + } + + if let Some(&(last_offset, _)) = results.last() { + self.manifest.set_next_offset(last_offset + 1); + } + + if writer.is_full() { + tracing::debug!( + seg_id = writer.id(), + last_offset = results.last().map(|r| r.0), + "segment full after batch, rotating" + ); + self.rotate()?; + } + + Ok(results) + } + + /// Append a single item without fsync. Caller must call `sync()` later for durability. + pub fn append_unsynced(&mut self, bytes: &[u8]) -> WalResult<(u64, u64)> { + let writer = self.writer.as_mut().ok_or(WalError::ManifestError { + reason: CorruptManifest::InvariantViolated { + detail: "no active writer", + }, + })?; + + let (offset, position) = writer.append_unsynced(bytes)?; + + if let Some(index) = self.indexes.get_mut(&writer.id()) { + index.insert(offset, position); + } + + self.manifest.set_next_offset(offset + 1); + + if writer.is_full() { + tracing::debug!( + seg_id = writer.id(), + offset, + "segment full after unsynced append, rotating" + ); + self.rotate()?; + } + + Ok((offset, position)) + } + + /// Flush pending writes to disk. + pub fn sync(&mut self) -> WalResult<()> { + let writer = self.writer.as_mut().ok_or(WalError::ManifestError { + reason: CorruptManifest::InvariantViolated { + detail: "no active writer", + }, + })?; + writer.sync() + } + + /// Write a snapshot at the given offset. The payload is opaque to the WAL. + pub fn checkpoint(&mut self, offset: u64, state: &[u8]) -> WalResult<()> { + tracing::debug!(offset, size = state.len(), "writing checkpoint"); + if offset > self.manifest.next_offset() { + return Err(WalError::OffsetOutOfRange { + requested: offset, + next: self.manifest.next_offset(), + }); + } + snapshot::save(&self.dir, offset, state)?; + self.manifest.set_snap_offset(offset)?; + self.trim_checkpoint(offset)?; + snapshot::retain_latest(&self.dir, 2)?; + Ok(()) + } + + /// Write a snapshot without trimming the segments it covers. + /// + /// Trajectory stores keep the event history itself as the payload — + /// trimming would destroy the very data the checkpoint complements. + /// Restore semantics are unchanged: the snapshot subsumes the prefix, + /// and replay still starts at the snapshot offset. + pub fn checkpoint_retaining_events(&mut self, offset: u64, state: &[u8]) -> WalResult<()> { + tracing::debug!( + offset, + size = state.len(), + "writing event-retaining checkpoint" + ); + if offset > self.manifest.next_offset() { + return Err(WalError::OffsetOutOfRange { + requested: offset, + next: self.manifest.next_offset(), + }); + } + snapshot::save(&self.dir, offset, state)?; + self.manifest.set_snap_offset(offset)?; + snapshot::retain_latest(&self.dir, 2)?; + Ok(()) + } + + pub fn load(&self) -> WalResult<Option<(u64, Vec<u8>)>> { + snapshot::load(&self.dir, &self.manifest) + } + + /// Replay items starting from `offset`. Yields `(global_offset, payload)`, + /// crossing segment boundaries automatically. + pub fn replay(&self, offset: u64) -> WalResult<ReplayIter<'_>> { + if self.manifest.next_offset() > 0 && offset > self.manifest.next_offset() { + return Err(WalError::OffsetOutOfRange { + requested: offset, + next: self.manifest.next_offset(), + }); + } + if let Some(snapshot_offset) = self.manifest.snap_offset() + && offset < snapshot_offset + { + return Err(WalError::OffsetOutOfRange { + requested: offset, + next: snapshot_offset, + }); + } + self.replay_segments(offset) + } + + /// Replay every retained item from the beginning, ignoring the snapshot + /// lower bound. Trajectory stores retain all events (their checkpoints + /// never trim), so this yields the complete recorded history. + pub fn replay_from_origin(&self) -> WalResult<ReplayIter<'_>> { + self.replay_segments(0) + } + + fn replay_segments(&self, offset: u64) -> WalResult<ReplayIter<'_>> { + // Build sorted (seg_id, first_offset) list for all segments. + let mut segments: Vec<(u64, u64)> = Vec::new(); + for (&seg_id, reader) in &self.readers { + segments.push((seg_id, reader.offset())); + } + if let Some(ref writer) = self.writer { + segments.push((writer.id(), writer.offset())); + } + segments.sort_by_key(|&(_, first)| first); + + if let Some((_, first_available)) = segments.first() + && offset < *first_available + { + return Err(WalError::OffsetOutOfRange { + requested: offset, + next: *first_available, + }); + } + + // First segment that could contain `offset`. + let start_idx = segments + .iter() + .rposition(|&(_, first)| first <= offset) + .unwrap_or(0); + + Ok(ReplayIter { + wal: self, + segments, + seg_idx: start_idx, + current_reader: None, + current_position: segment::HEADER_SIZE, + item_offset: 0, + next_offset: offset, + exhausted: false, + }) + } + + pub fn last_offset(&self) -> Option<u64> { + let next = self.manifest.next_offset(); + if next == 0 { None } else { Some(next - 1) } + } + + pub fn next_offset(&self) -> u64 { + self.manifest.next_offset() + } +} + +/// Cross-segment iterator yielded by [`Wal::replay`]. +pub struct ReplayIter<'a> { + wal: &'a Wal, + segments: Vec<(u64, u64)>, + seg_idx: usize, + current_reader: Option<SegmentReader>, + current_position: u64, + item_offset: u64, + next_offset: u64, + exhausted: bool, +} + +impl<'a> Iterator for ReplayIter<'a> { + type Item = WalResult<(u64, Vec<u8>)>; + + fn next(&mut self) -> Option<Self::Item> { + if self.exhausted { + return None; + } + + loop { + if self.seg_idx >= self.segments.len() { + self.exhausted = true; + return None; + } + + if self.current_reader.is_none() { + let (seg_id, first_offset) = self.segments[self.seg_idx]; + + // SparseIndex seek: find the nearest entry ≤ next_offset. + let (start_pos, start_off) = self + .wal + .indexes + .get(&seg_id) + .and_then(|idx| idx.find(self.next_offset)) + .map(|(off, byte_pos)| (byte_pos, off)) + .unwrap_or((segment::HEADER_SIZE, first_offset)); + + let path = seg_path(&self.wal.dir, seg_id); + let reader = match SegmentReader::open(&path) { + Ok(r) => r, + Err(e) => { + tracing::warn!(seg_id, "failed to open segment during replay"); + self.exhausted = true; + return Some(Err(e)); + } + }; + + self.current_reader = Some(reader); + self.current_position = start_pos; + self.item_offset = start_off; + } + + let reader = self.current_reader.as_ref().unwrap(); + + match reader.read_at(self.current_position) { + Ok(None) => { + self.current_reader = None; + self.seg_idx += 1; + continue; + } + Ok(Some((payload, next_position))) => { + let global_offset = self.item_offset; + self.current_position = next_position; + self.item_offset += 1; + + if global_offset < self.next_offset { + continue; + } + + self.next_offset = global_offset + 1; + return Some(Ok((global_offset, payload.to_vec()))); + } + Err(e) => { + self.exhausted = true; + return Some(Err(e)); + } + } + } + } +} + +// -- private helpers -- + +impl Wal { + fn trim_checkpoint(&mut self, offset: u64) -> WalResult<()> { + let removable = self + .readers + .iter() + .filter_map(|(&seg_id, reader)| { + let end = reader.offset() + reader.count() as u64; + (end <= offset).then_some(seg_id) + }) + .collect::<Vec<_>>(); + + if removable.is_empty() { + return Ok(()); + } + + let new_first_seg = removable.last().copied().unwrap() + 1; + self.manifest.set_first_seg(new_first_seg); + self.manifest.save()?; + + let mut dirs_to_sync = BTreeSet::new(); + for seg_id in removable { + self.readers.remove(&seg_id); + self.indexes.remove(&seg_id); + + let seg_path = seg_path(&self.dir, seg_id); + remove_file_if_exists(&seg_path)?; + if let Some(parent) = seg_path.parent() { + dirs_to_sync.insert(parent.to_path_buf()); + } + + let idx_path = idx_path(&self.dir, seg_id); + remove_file_if_exists(&idx_path)?; + if let Some(parent) = idx_path.parent() { + dirs_to_sync.insert(parent.to_path_buf()); + } + } + + for dir in dirs_to_sync { + io::fsync_dir(&dir)?; + remove_empty_dir(&dir)?; + } + + Ok(()) + } + + fn rotate(&mut self) -> WalResult<()> { + let writer = self.writer.take().ok_or(WalError::ManifestError { + reason: CorruptManifest::InvariantViolated { + detail: "no active writer to roll", + }, + })?; + + let prev_seg_id = writer.id(); + let next_seg_id = prev_seg_id + 1; + + tracing::info!(from = prev_seg_id, to = next_seg_id, "rotating segment"); + + // Phase 1: persist new segment + manifest before closing old one. + let next_dir = seg_dir(&self.dir, next_seg_id); + std::fs::create_dir_all(&next_dir) + .map_err(WalError::at(next_dir.clone(), IoOperation::Create))?; + + let next_writer = SegmentWriter::new( + &next_dir, + next_seg_id, + self.manifest.next_offset(), + self.manifest.seg_size(), + )?; + + self.indexes.insert( + next_seg_id, + SparseIndex::new(next_seg_id, self.manifest.idx_interval()), + ); + + self.manifest.set_open_seg(next_seg_id); + self.manifest.save()?; + io::fsync_dir(&self.dir.join("events"))?; + + // Phase 1 committed. + self.writer = Some(next_writer); + + // Phase 2: close old segment, persist index, open reader. + writer.close()?; + + if let Some(index) = self.indexes.remove(&prev_seg_id) { + let idx_p = idx_path(&self.dir, prev_seg_id); + index.save(&idx_p)?; + io::fsync_dir(idx_p.parent().unwrap_or(Path::new(".")))?; + } + + let seg_p = seg_path(&self.dir, prev_seg_id); + let reader = SegmentReader::open(&seg_p)?; + self.readers.insert(prev_seg_id, reader); + + Ok(()) + } +} + +fn seg_dir(dir: &Path, seg_id: u64) -> PathBuf { + let bucket = (seg_id / 1000) * 1000; + let bucket_dir = format!("{:04}-{:04}", bucket, bucket + 999); + dir.join("events").join("segments").join(bucket_dir) +} + +fn seg_path(dir: &Path, seg_id: u64) -> PathBuf { + seg_dir(dir, seg_id).join(format!("{:06}.seg", seg_id)) +} + +fn idx_path(dir: &Path, seg_id: u64) -> PathBuf { + seg_dir(dir, seg_id).join(format!("{:06}.idx", seg_id)) +} + +fn remove_file_if_exists(path: &Path) -> WalResult<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(WalError::at(path.to_path_buf(), IoOperation::Remove)(e)), + } +} + +fn remove_empty_dir(path: &Path) -> WalResult<()> { + match std::fs::remove_dir(path) { + Ok(()) => Ok(()), + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty + ) => + { + Ok(()) + } + Err(e) => Err(WalError::at(path.to_path_buf(), IoOperation::Remove)(e)), + } +} diff --git a/crates/storage/src/manifest.rs b/crates/storage/src/manifest.rs new file mode 100644 index 00000000..182a987c --- /dev/null +++ b/crates/storage/src/manifest.rs @@ -0,0 +1,246 @@ +use std::fs; +use std::io::{BufRead, Write}; +use std::path::{Path, PathBuf}; + +use crate::error::{CorruptManifest, IoOperation, WalError, WalResult}; +use crate::io; + +pub const DEFAULT_SEG_SIZE: u64 = 128 * 1024 * 1024; +pub const DEFAULT_IDX_INTERVAL: u32 = 256; + +const MANIFEST_VERSION: u32 = 1; + +pub struct Manifest { + pub version: u32, + open_segment: u64, + first_segment: u64, + next_offset: u64, + snapshot_offset: Option<u64>, + segment_size: u64, + index_interval: u32, + dir: PathBuf, +} + +impl Manifest { + pub fn open(dir: &Path) -> WalResult<Self> { + let p = path(dir); + if p.exists() { + let file = fs::File::open(&p).map_err(WalError::at(p.clone(), IoOperation::Read))?; + + let mut version: u32 = 0; + let mut active_segment: u64 = 0; + let mut first_segment: u64 = 1; + let mut next_event_seq: u64 = 0; + let mut latest_snapshot_seq: Option<u64> = None; + let mut segment_size: u64 = DEFAULT_SEG_SIZE; + let mut index_interval: u32 = DEFAULT_IDX_INTERVAL; + + for line in std::io::BufReader::new(file).lines() { + let line = line.map_err(WalError::at(p.clone(), IoOperation::Read))?; + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + match key.trim() { + "version" => { + version = value.trim().parse().map_err(|_| WalError::ManifestError { + reason: CorruptManifest::InvalidField { key: "version" }, + })? + } + "active_segment" => { + active_segment = + value.trim().parse().map_err(|_| WalError::ManifestError { + reason: CorruptManifest::InvalidField { + key: "active_segment", + }, + })? + } + "first_segment" => { + first_segment = + value.trim().parse().map_err(|_| WalError::ManifestError { + reason: CorruptManifest::InvalidField { + key: "first_segment", + }, + })? + } + "next_event_seq" => { + next_event_seq = + value.trim().parse().map_err(|_| WalError::ManifestError { + reason: CorruptManifest::InvalidField { + key: "next_event_seq", + }, + })? + } + "latest_snapshot_seq" => { + let v = value.trim(); + latest_snapshot_seq = if v.is_empty() { + None + } else { + Some(v.parse().map_err(|_| WalError::ManifestError { + reason: CorruptManifest::InvalidField { + key: "latest_snapshot_seq", + }, + })?) + } + } + "segment_size" => { + segment_size = + value.trim().parse().map_err(|_| WalError::ManifestError { + reason: CorruptManifest::InvalidField { + key: "segment_size", + }, + })? + } + "index_interval" => { + index_interval = + value.trim().parse().map_err(|_| WalError::ManifestError { + reason: CorruptManifest::InvalidField { + key: "index_interval", + }, + })? + } + _ => {} + } + } + } + + if version == 0 { + return Err(WalError::ManifestError { + reason: CorruptManifest::MissingVersion, + }); + } + + tracing::debug!( + version, + active_segment, + first_segment, + next_event_seq, + latest_snapshot_seq = ?latest_snapshot_seq, + segment_size, + index_interval, + "manifest loaded" + ); + + Ok(Manifest { + version, + open_segment: active_segment, + first_segment, + next_offset: next_event_seq, + snapshot_offset: latest_snapshot_seq, + segment_size, + index_interval, + dir: dir.to_path_buf(), + }) + } else { + tracing::debug!("no manifest found, creating new"); + let manifest = Manifest { + version: MANIFEST_VERSION, + open_segment: 0, + first_segment: 1, + next_offset: 0, + snapshot_offset: None, + segment_size: DEFAULT_SEG_SIZE, + index_interval: DEFAULT_IDX_INTERVAL, + dir: dir.to_path_buf(), + }; + manifest.save()?; + Ok(manifest) + } + } + + pub fn save(&self) -> WalResult<()> { + let events_dir = self.dir.join("events"); + fs::create_dir_all(&events_dir) + .map_err(WalError::at(events_dir.clone(), IoOperation::Create))?; + + let temp_path = events_dir.join("manifest.dat.tmp"); + let final_path = events_dir.join("manifest.dat"); + + let mut file = fs::File::create(&temp_path) + .map_err(WalError::at(temp_path.clone(), IoOperation::Create))?; + writeln!(file, "# axiom manifest") + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + writeln!(file, "version={}", self.version) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + writeln!(file, "active_segment={}", self.open_segment) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + writeln!(file, "first_segment={}", self.first_segment) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + writeln!(file, "next_event_seq={}", self.next_offset) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + writeln!( + file, + "latest_snapshot_seq={}", + self.snapshot_offset + .map(|s| s.to_string()) + .unwrap_or_default() + ) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + writeln!(file, "segment_size={}", self.segment_size) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + writeln!(file, "index_interval={}", self.index_interval) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + file.sync_all() + .map_err(WalError::at(temp_path.clone(), IoOperation::Sync))?; + + fs::rename(&temp_path, &final_path) + .map_err(WalError::at(final_path.clone(), IoOperation::Rename))?; + io::fsync_dir(&events_dir)?; + Ok(()) + } + + pub fn set_snap_offset(&mut self, offset: u64) -> WalResult<()> { + if let Some(current) = self.snapshot_offset + && offset < current + { + return Err(WalError::ManifestError { + reason: CorruptManifest::InvariantViolated { + detail: "checkpoint offset must not decrease", + }, + }); + } + self.snapshot_offset = Some(offset); + self.save() + } + + pub fn open_seg(&self) -> u64 { + self.open_segment + } + + pub fn first_seg(&self) -> u64 { + self.first_segment + } + + pub fn next_offset(&self) -> u64 { + self.next_offset + } + + pub fn snap_offset(&self) -> Option<u64> { + self.snapshot_offset + } + + pub fn seg_size(&self) -> u64 { + self.segment_size + } + + pub fn idx_interval(&self) -> u32 { + self.index_interval + } + + pub fn set_open_seg(&mut self, seg_id: u64) { + self.open_segment = seg_id; + } + + pub fn set_first_seg(&mut self, seg_id: u64) { + self.first_segment = seg_id; + } + + pub fn set_next_offset(&mut self, offset: u64) { + self.next_offset = offset; + } +} + +pub fn path(dir: &Path) -> PathBuf { + dir.join("events").join("manifest.dat") +} diff --git a/crates/storage/src/segment/mod.rs b/crates/storage/src/segment/mod.rs new file mode 100644 index 00000000..726d6e62 --- /dev/null +++ b/crates/storage/src/segment/mod.rs @@ -0,0 +1,11 @@ +pub const HEADER_SIZE: u64 = 4096; +pub const HEADER_MAGIC: [u8; 4] = [0x45, 0x56, 0x4E, 0x54]; +pub const TAIL_MAGIC: [u8; 4] = [0x45, 0x4E, 0x44, 0x21]; +pub const FORMAT_VERSION: u16 = 1; +pub const ITEM_HEADER_SIZE: u32 = 8; + +mod reader; +mod writer; + +pub use reader::SegmentReader; +pub use writer::SegmentWriter; diff --git a/crates/storage/src/segment/reader.rs b/crates/storage/src/segment/reader.rs new file mode 100644 index 00000000..16637ca7 --- /dev/null +++ b/crates/storage/src/segment/reader.rs @@ -0,0 +1,155 @@ +use std::path::Path; + +use crc32c::crc32c; +use memmap2::Mmap; + +use super::{HEADER_MAGIC, HEADER_SIZE, ITEM_HEADER_SIZE, TAIL_MAGIC}; +use crate::error::{CorruptSegment, IoOperation, WalError, WalResult}; + +#[derive(Debug)] +pub struct SegmentReader { + mmap: Mmap, + id: u64, + offset: u64, + count: u32, +} + +impl SegmentReader { + pub fn open(path: &Path) -> WalResult<Self> { + let file = std::fs::File::open(path) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Read))?; + let file_len = file + .metadata() + .map_err(WalError::at(path.to_path_buf(), IoOperation::Read))? + .len(); + + if file_len < HEADER_SIZE { + return Err(WalError::SegmentCorrupted { + segment_id: 0, + reason: CorruptSegment::FileTooSmall { + actual_len: file_len, + }, + }); + } + + let mmap = unsafe { Mmap::map(&file) } + .map_err(WalError::at(path.to_path_buf(), IoOperation::Read))?; + + if mmap[0..4] != HEADER_MAGIC { + return Err(WalError::SegmentCorrupted { + segment_id: 0, + reason: CorruptSegment::BadMagic { + expected: HEADER_MAGIC, + actual: mmap[0..4].try_into().unwrap(), + }, + }); + } + + let id = u64::from_le_bytes(mmap[8..16].try_into().unwrap()); + let offset = u64::from_le_bytes(mmap[16..24].try_into().unwrap()); + let count = u32::from_le_bytes(mmap[24..28].try_into().unwrap()); + + Ok(Self { + mmap, + id, + offset, + count, + }) + } + + pub(crate) fn read_at(&self, position: u64) -> WalResult<Option<(&[u8], u64)>> { + let pos = position as usize; + let map_len = self.mmap.len(); + + if pos + (ITEM_HEADER_SIZE as usize) > map_len { + return Ok(None); + } + + let item_len = u32::from_le_bytes(self.mmap[pos..pos + 4].try_into().unwrap()); + + if item_len < ITEM_HEADER_SIZE { + if self.mmap[pos..pos + 4] == TAIL_MAGIC { + return Ok(None); + } + return Err(WalError::SegmentCorrupted { + segment_id: self.id, + reason: CorruptSegment::InvalidItemLen { + len: item_len, + offset: position, + }, + }); + } + + let total = item_len as usize; + if pos + total > map_len { + let tail_magic_value: u32 = u32::from_le_bytes(TAIL_MAGIC); + if item_len == tail_magic_value && map_len < tail_magic_value as usize { + return Ok(None); + } + return Err(WalError::SegmentCorrupted { + segment_id: self.id, + reason: CorruptSegment::ItemTruncated { offset: position }, + }); + } + + let stored_crc = u32::from_le_bytes(self.mmap[pos + 4..pos + 8].try_into().unwrap()); + let payload = &self.mmap[pos + 8..pos + total]; + let actual_crc = crc32c(payload); + + if stored_crc != actual_crc { + return Err(WalError::SegmentCorrupted { + segment_id: self.id, + reason: CorruptSegment::ChecksumMismatch { + offset: position, + expected: stored_crc, + actual: actual_crc, + }, + }); + } + + Ok(Some((payload, position + total as u64))) + } + + pub fn iter(&self) -> SegmentIter<'_> { + SegmentIter { + reader: self, + position: HEADER_SIZE, + } + } + + pub fn id(&self) -> u64 { + self.id + } + + pub fn offset(&self) -> u64 { + self.offset + } + + pub fn count(&self) -> u32 { + self.count + } +} + +pub struct SegmentIter<'a> { + reader: &'a SegmentReader, + position: u64, +} + +impl<'a> Iterator for SegmentIter<'a> { + type Item = WalResult<(u64, &'a [u8])>; + + fn next(&mut self) -> Option<Self::Item> { + match self.reader.read_at(self.position) { + Ok(None) => None, + Ok(Some((payload, next_position))) => { + let item_offset = self.position; + self.position = next_position; + Some(Ok((item_offset, payload))) + } + Err(e) => { + self.position = u64::MAX; + Some(Err(e)) + } + } + } +} diff --git a/crates/storage/src/segment/writer.rs b/crates/storage/src/segment/writer.rs new file mode 100644 index 00000000..425a4e83 --- /dev/null +++ b/crates/storage/src/segment/writer.rs @@ -0,0 +1,494 @@ +use std::fs::{File, OpenOptions}; +use std::io::{Read as _, Seek as _, Write as _}; +use std::path::{Path, PathBuf}; + +use crc32c::crc32c; +use memmap2::Mmap; + +use super::{FORMAT_VERSION, HEADER_MAGIC, HEADER_SIZE, ITEM_HEADER_SIZE, TAIL_MAGIC}; +use crate::error::{CorruptSegment, IoOperation, WalError, WalResult}; +use crate::index::SparseIndex; +use crate::io; + +#[derive(Copy, Clone)] +struct ItemMeta { + offset: u64, + position: u64, + item_crc: u32, + payload_len: usize, +} + +pub struct SegmentWriter { + file: File, + path: PathBuf, + id: u64, + offset: u64, + count: u32, + position: u64, + max_size: u64, + checksum: u32, + truncated_items: u32, +} + +impl SegmentWriter { + pub fn new(dir: &Path, seg_id: u64, offset: u64, max_size: u64) -> WalResult<Self> { + tracing::debug!(seg_id, offset, max_size, "creating new segment"); + let path = dir.join(format!("{:06}.seg", seg_id)); + let mut file = + File::create(&path).map_err(WalError::at(path.clone(), IoOperation::Create))?; + + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let mut header = [0u8; HEADER_SIZE as usize]; + header[0..4].copy_from_slice(&HEADER_MAGIC); + header[4..6].copy_from_slice(&FORMAT_VERSION.to_le_bytes()); + // bytes 6..8: padding for u64 alignment + header[8..16].copy_from_slice(&seg_id.to_le_bytes()); + header[16..24].copy_from_slice(&offset.to_le_bytes()); + // bytes 24..28: count — written on close + header[28..36].copy_from_slice(&created_at.to_le_bytes()); + + file.write_all(&header) + .map_err(WalError::at(path.clone(), IoOperation::Write))?; + file.sync_all() + .map_err(WalError::at(path.clone(), IoOperation::Sync))?; + + io::fsync_dir(dir)?; + + Ok(Self { + file, + path, + id: seg_id, + offset, + count: 0, + position: HEADER_SIZE, + max_size, + checksum: 0, + truncated_items: 0, + }) + } + + pub fn open(path: &Path, max_size: u64, idx_interval: u32) -> WalResult<(Self, SparseIndex)> { + let mut file = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Read))?; + + let file_len = file + .metadata() + .map_err(WalError::at(path.to_path_buf(), IoOperation::Read))? + .len(); + + if file_len < HEADER_SIZE { + return Err(WalError::SegmentCorrupted { + segment_id: 0, + reason: CorruptSegment::FileTooSmall { + actual_len: file_len, + }, + }); + } + + let mut header = [0u8; HEADER_SIZE as usize]; + file.read_exact(&mut header) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Read))?; + + if header[0..4] != HEADER_MAGIC { + return Err(WalError::SegmentCorrupted { + segment_id: 0, + reason: CorruptSegment::BadMagic { + expected: HEADER_MAGIC, + actual: header[0..4].try_into().unwrap(), + }, + }); + } + + let seg_id = u64::from_le_bytes(header[8..16].try_into().unwrap()); + let first_offset = u64::from_le_bytes(header[16..24].try_into().unwrap()); + let header_count = u32::from_le_bytes(header[24..28].try_into().unwrap()); + + // Fast recovery via mmap: zero-syscall, zero-alloc item scanning. + // For closed segments with valid footer CRC, skip per-item CRC checks. + if let Some(result) = Self::mmap_recover( + path, + file_len, + seg_id, + first_offset, + header_count, + idx_interval, + ) { + let (position, count, checksum, truncated_items, index) = result; + + file.set_len(position) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Write))?; + file.seek(std::io::SeekFrom::Start(position)) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Write))?; + file.sync_all() + .map_err(WalError::at(path.to_path_buf(), IoOperation::Sync))?; + + if truncated_items > 0 { + tracing::warn!( + seg_id, + recovered = count, + truncated = truncated_items, + "segment recovery completed with truncation" + ); + } else { + tracing::debug!(seg_id, items = count, "segment recovered cleanly via mmap"); + } + + return Ok(( + Self { + file, + path: path.to_path_buf(), + id: seg_id, + offset: first_offset, + count, + position, + max_size, + checksum, + truncated_items, + }, + index, + )); + } + + // Fallback: slow path via read syscalls. + let mut position = HEADER_SIZE; + let mut count: u32 = 0; + let mut checksum: u32 = 0; + let mut index = SparseIndex::new(seg_id, idx_interval); + let mut truncated = false; + + file.seek(std::io::SeekFrom::Start(HEADER_SIZE)) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Read))?; + + loop { + if position + ITEM_HEADER_SIZE as u64 > file_len { + break; + } + + let mut len_buf = [0u8; 4]; + if file.read_exact(&mut len_buf).is_err() { + truncated = true; + break; + } + + let item_len = u32::from_le_bytes(len_buf); + + if len_buf == TAIL_MAGIC && position + 8 == file_len { + return Err(WalError::SegmentCorrupted { + segment_id: seg_id, + reason: CorruptSegment::AlreadyClosed, + }); + } + + if item_len < ITEM_HEADER_SIZE || position + item_len as u64 > file_len { + truncated = true; + break; + } + + let mut crc_buf = [0u8; 4]; + if file.read_exact(&mut crc_buf).is_err() { + truncated = true; + break; + } + let stored_crc = u32::from_le_bytes(crc_buf); + + let payload_len = (item_len - ITEM_HEADER_SIZE) as usize; + let mut payload = vec![0u8; payload_len]; + if file.read_exact(&mut payload).is_err() { + truncated = true; + break; + } + + let item_crc = crc32c(&payload); + if item_crc != stored_crc { + tracing::warn!( + seg_id, + item = count, + position, + "CRC mismatch at item during recovery, truncating" + ); + truncated = true; + break; + } + + let global_offset = first_offset + count as u64; + index.insert(global_offset, position); + + checksum = crc32c::crc32c_combine(checksum, item_crc, payload_len); + position += item_len as u64; + count += 1; + } + + file.set_len(position) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Write))?; + file.seek(std::io::SeekFrom::Start(position)) + .map_err(WalError::at(path.to_path_buf(), IoOperation::Write))?; + file.sync_all() + .map_err(WalError::at(path.to_path_buf(), IoOperation::Sync))?; + + let truncated_items = if truncated { + header_count.saturating_sub(count) + } else { + 0 + }; + + if truncated_items > 0 { + tracing::warn!( + seg_id, + recovered = count, + truncated = truncated_items, + "segment recovery completed with truncation" + ); + } else { + tracing::debug!( + seg_id, + items = count, + "segment recovered cleanly via read path" + ); + } + + Ok(( + Self { + file, + path: path.to_path_buf(), + id: seg_id, + offset: first_offset, + count, + position, + max_size, + checksum, + truncated_items, + }, + index, + )) + } + + /// Read segment-level CRC from footer if TAIL_MAGIC is present. + /// mmap-based recovery: scan items with zero syscalls and zero heap allocs. + /// Returns None for closed segments (caller should return AlreadyClosed) + /// or if mmap fails (caller falls back to read-syscall path). + fn mmap_recover( + path: &Path, + file_len: u64, + seg_id: u64, + first_offset: u64, + header_count: u32, + idx_interval: u32, + ) -> Option<(u64, u32, u32, u32, SparseIndex)> { + let file = std::fs::File::open(path).ok()?; + let mmap = unsafe { Mmap::map(&file) }.ok()?; + + // Closed segment — open() should not recover these. + if file_len >= HEADER_SIZE + 8 { + let tail_start = file_len as usize - 8; + if mmap[tail_start..tail_start + 4] == TAIL_MAGIC { + return None; + } + } + + let mut pos = HEADER_SIZE as usize; + let mut count: u32 = 0; + let mut checksum: u32 = 0; + let mut index = SparseIndex::new(seg_id, idx_interval); + let mut truncated = false; + + while pos + ITEM_HEADER_SIZE as usize <= file_len as usize { + let item_len_bytes: [u8; 4] = mmap[pos..pos + 4].try_into().ok()?; + let item_len = u32::from_le_bytes(item_len_bytes); + + if item_len < ITEM_HEADER_SIZE || pos + item_len as usize > file_len as usize { + truncated = true; + break; + } + + let stored_crc = u32::from_le_bytes(mmap[pos + 4..pos + 8].try_into().ok()?); + let payload = &mmap[pos + 8..pos + item_len as usize]; + let item_crc = crc32c(payload); + + if item_crc != stored_crc { + tracing::warn!( + seg_id, + item = count, + position = pos, + "CRC mismatch at item during mmap recovery, truncating" + ); + truncated = true; + break; + } + + let global_offset = first_offset + count as u64; + index.insert(global_offset, pos as u64); + + checksum = crc32c::crc32c_combine(checksum, item_crc, payload.len()); + pos += item_len as usize; + count += 1; + } + + let truncated_items = if truncated { + header_count.saturating_sub(count) + } else { + 0 + }; + + Some((pos as u64, count, checksum, truncated_items, index)) + } + + pub fn append(&mut self, bytes: &[u8]) -> WalResult<(u64, u64)> { + let mut buf = Vec::new(); + let meta = self.prepare_item(bytes, &mut buf)?; + self.write_buffer(&buf)?; + self.sync()?; + self.commit_item(meta); + Ok((meta.offset, meta.position)) + } + + /// Append multiple items in one write + one fsync. + pub(crate) fn append_batch(&mut self, items: &[&[u8]]) -> WalResult<Vec<(u64, u64)>> { + if items.is_empty() { + return Ok(Vec::new()); + } + + let saved_position = self.position; + let saved_count = self.count; + + let mut buf = Vec::new(); + let mut metas = Vec::with_capacity(items.len()); + + for &bytes in items { + let meta = self.prepare_item(bytes, &mut buf)?; + metas.push(meta); + } + + if let Err(e) = self.write_buffer(&buf).and_then(|_| self.sync()) { + self.position = saved_position; + self.count = saved_count; + return Err(e); + } + + for meta in &metas { + self.commit_item(*meta); + } + + Ok(metas.iter().map(|m| (m.offset, m.position)).collect()) + } + + /// Append a single item without fsync. Caller must call `sync()` later. + pub(crate) fn append_unsynced(&mut self, bytes: &[u8]) -> WalResult<(u64, u64)> { + let saved_position = self.position; + let saved_count = self.count; + + let mut buf = Vec::new(); + let meta = self.prepare_item(bytes, &mut buf)?; + + if let Err(e) = self.write_buffer(&buf) { + self.position = saved_position; + self.count = saved_count; + return Err(e); + } + + self.commit_item(meta); + Ok((meta.offset, meta.position)) + } + + pub(crate) fn sync(&mut self) -> WalResult<()> { + self.file + .sync_all() + .map_err(WalError::at(self.path.clone(), IoOperation::Sync)) + } + + /// Prepare an item: validate, compute CRC, encode into buffer. + /// Advances position and count so consecutive calls produce correct offsets. + fn prepare_item(&mut self, bytes: &[u8], buf: &mut Vec<u8>) -> WalResult<ItemMeta> { + let payload_len: u32 = bytes.len().try_into().map_err(|_| WalError::InvalidInput { + detail: "payload too large", + })?; + let item_len = ITEM_HEADER_SIZE + .checked_add(payload_len) + .ok_or(WalError::InvalidInput { + detail: "item too large", + })?; + + let item_crc = crc32c(bytes); + + buf.extend_from_slice(&item_len.to_le_bytes()); + buf.extend_from_slice(&item_crc.to_le_bytes()); + buf.extend_from_slice(bytes); + + let byte_pos = self.position; + let global_offset = + self.offset + .checked_add(self.count as u64) + .ok_or(WalError::InvalidInput { + detail: "offset overflow", + })?; + + self.position += item_len as u64; + self.count += 1; + + Ok(ItemMeta { + offset: global_offset, + position: byte_pos, + item_crc, + payload_len: bytes.len(), + }) + } + + fn write_buffer(&mut self, buf: &[u8]) -> WalResult<()> { + self.file + .write_all(buf) + .map_err(WalError::at(self.path.clone(), IoOperation::Write)) + } + + /// Update checksum after an item has been written to the file. + fn commit_item(&mut self, meta: ItemMeta) { + self.checksum = crc32c::crc32c_combine(self.checksum, meta.item_crc, meta.payload_len); + } + + pub fn is_full(&self) -> bool { + self.position >= self.max_size + } + + pub fn close(mut self) -> WalResult<()> { + tracing::debug!(seg_id = self.id, items = self.count, "closing segment"); + self.file + .write_all(&TAIL_MAGIC) + .map_err(WalError::at(self.path.clone(), IoOperation::Write))?; + self.file + .write_all(&self.checksum.to_le_bytes()) + .map_err(WalError::at(self.path.clone(), IoOperation::Write))?; + + self.file + .seek(std::io::SeekFrom::Start(24)) + .map_err(WalError::at(self.path.clone(), IoOperation::Write))?; + self.file + .write_all(&self.count.to_le_bytes()) + .map_err(WalError::at(self.path.clone(), IoOperation::Write))?; + self.file + .sync_all() + .map_err(WalError::at(self.path.clone(), IoOperation::Sync))?; + + Ok(()) + } + + pub fn count(&self) -> u32 { + self.count + } + + pub fn id(&self) -> u64 { + self.id + } + + pub fn offset(&self) -> u64 { + self.offset + } + + pub fn truncated_items(&self) -> u32 { + self.truncated_items + } +} diff --git a/crates/storage/src/snapshot.rs b/crates/storage/src/snapshot.rs new file mode 100644 index 00000000..70a5d279 --- /dev/null +++ b/crates/storage/src/snapshot.rs @@ -0,0 +1,200 @@ +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use crate::error::{CorruptSnapshot, IoOperation, WalError, WalResult}; +use crate::io; +use crate::manifest::Manifest; + +const MAGIC: &[u8; 4] = b"SNAP"; +const VERSION: u16 = 1; +const HEADER_SIZE: usize = 4096; + +pub fn save(dir: &Path, offset: u64, state: &[u8]) -> WalResult<()> { + tracing::debug!(offset, size = state.len(), "saving snapshot"); + let final_path = snap_path(dir, offset); + + if let Some(parent) = final_path.parent() { + fs::create_dir_all(parent) + .map_err(WalError::at(final_path.clone(), IoOperation::Create))?; + } + + let temp_path = final_path.with_extension("dat.tmp"); + + let mut file = fs::File::create(&temp_path) + .map_err(WalError::at(temp_path.clone(), IoOperation::Create))?; + + let checksum = crc32c::crc32c(state); + let mut header = [0u8; HEADER_SIZE]; + header[0..4].copy_from_slice(MAGIC); + header[4..6].copy_from_slice(&VERSION.to_le_bytes()); + header[6..14].copy_from_slice(&offset.to_le_bytes()); + header[14..22].copy_from_slice(&(state.len() as u64).to_le_bytes()); + header[22..26].copy_from_slice(&checksum.to_le_bytes()); + + file.write_all(&header) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + file.write_all(state) + .map_err(WalError::at(temp_path.clone(), IoOperation::Write))?; + file.sync_all() + .map_err(WalError::at(temp_path.clone(), IoOperation::Sync))?; + + fs::rename(&temp_path, &final_path) + .map_err(WalError::at(final_path.clone(), IoOperation::Rename))?; + + if let Some(parent) = final_path.parent() { + io::fsync_dir(parent)?; + } + Ok(()) +} + +pub fn retain_latest(dir: &Path, keep: usize) -> WalResult<()> { + let mut snapshots = list_snapshots(dir)?; + if snapshots.len() <= keep { + return Ok(()); + } + + snapshots.sort_by_key(|(offset, _)| *offset); + let remove_count = snapshots.len() - keep; + for (_, path) in snapshots.into_iter().take(remove_count) { + fs::remove_file(&path).map_err(WalError::at(path.clone(), IoOperation::Remove))?; + if let Some(parent) = path.parent() { + io::fsync_dir(parent)?; + remove_empty_dir(parent)?; + } + } + Ok(()) +} + +pub fn load(dir: &Path, manifest: &Manifest) -> WalResult<Option<(u64, Vec<u8>)>> { + match manifest.snap_offset() { + Some(offset) => { + tracing::debug!(offset, "loading snapshot"); + let path = snap_path(dir, offset); + if !path.exists() { + return Err(WalError::SnapshotNotFound); + } + let (event_offset, payload) = read(&path)?; + Ok(Some((event_offset, payload))) + } + None => { + tracing::debug!("no snapshot to load"); + Ok(None) + } + } +} + +pub fn read(path: &Path) -> WalResult<(u64, Vec<u8>)> { + let mut file = + fs::File::open(path).map_err(WalError::at(path.to_path_buf(), IoOperation::Read))?; + + let mut header = [0u8; HEADER_SIZE]; + file.read_exact(&mut header) + .map_err(|e| WalError::SnapshotCorrupted { + path: path.to_path_buf(), + reason: CorruptSnapshot::HeaderTooShort { source: e }, + })?; + + if &header[0..4] != MAGIC { + return Err(WalError::SnapshotCorrupted { + path: path.to_path_buf(), + reason: CorruptSnapshot::BadMagic { + expected: *MAGIC, + actual: header[0..4].try_into().unwrap(), + }, + }); + } + + let version = u16::from_le_bytes([header[4], header[5]]); + if version != VERSION { + return Err(WalError::SnapshotCorrupted { + path: path.to_path_buf(), + reason: CorruptSnapshot::UnsupportedVersion { version }, + }); + } + + let offset = u64::from_le_bytes(header[6..14].try_into().unwrap()); + let payload_len = u64::from_le_bytes(header[14..22].try_into().unwrap()); + let expected_crc = u32::from_le_bytes(header[22..26].try_into().unwrap()); + + let mut payload = vec![0u8; payload_len as usize]; + file.read_exact(&mut payload) + .map_err(|e| WalError::SnapshotCorrupted { + path: path.to_path_buf(), + reason: CorruptSnapshot::PayloadTruncated { source: e }, + })?; + + let actual_crc = crc32c::crc32c(&payload); + if actual_crc != expected_crc { + return Err(WalError::SnapshotCorrupted { + path: path.to_path_buf(), + reason: CorruptSnapshot::ChecksumMismatch { + expected: expected_crc, + actual: actual_crc, + }, + }); + } + + Ok((offset, payload)) +} + +pub fn snap_path(dir: &Path, offset: u64) -> PathBuf { + let bucket = (offset / 1000) * 1000; + let bucket_dir = format!("{:04}-{:04}", bucket, bucket + 999); + dir.join("snapshots") + .join(bucket_dir) + .join(format!("ckpt-{:06}.dat", offset)) +} + +pub fn list_snapshots(dir: &Path) -> WalResult<Vec<(u64, PathBuf)>> { + let snapshots_dir = dir.join("snapshots"); + if !snapshots_dir.exists() { + return Ok(Vec::new()); + } + + let mut snapshots = Vec::new(); + let buckets = fs::read_dir(&snapshots_dir) + .map_err(WalError::at(snapshots_dir.clone(), IoOperation::Read))?; + for bucket in buckets { + let bucket = bucket.map_err(WalError::at(snapshots_dir.clone(), IoOperation::Read))?; + let bucket_path = bucket.path(); + if !bucket_path.is_dir() { + continue; + } + + let files = fs::read_dir(&bucket_path) + .map_err(WalError::at(bucket_path.clone(), IoOperation::Read))?; + for file in files { + let file = file.map_err(WalError::at(bucket_path.clone(), IoOperation::Read))?; + let path = file.path(); + if !path.is_file() { + continue; + } + if let Some(offset) = parse_snapshot_offset(&path) { + snapshots.push((offset, path)); + } + } + } + Ok(snapshots) +} + +fn parse_snapshot_offset(path: &Path) -> Option<u64> { + let filename = path.file_name()?.to_str()?; + let offset = filename.strip_prefix("ckpt-")?.strip_suffix(".dat")?; + offset.parse().ok() +} + +pub fn remove_empty_dir(path: &Path) -> WalResult<()> { + match fs::remove_dir(path) { + Ok(()) => Ok(()), + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty + ) => + { + Ok(()) + } + Err(e) => Err(WalError::at(path.to_path_buf(), IoOperation::Remove)(e)), + } +} diff --git a/crates/storage/src/store.rs b/crates/storage/src/store.rs new file mode 100644 index 00000000..dab86f04 --- /dev/null +++ b/crates/storage/src/store.rs @@ -0,0 +1,103 @@ +use std::path::Path; + +use machine::{Machine, MachineState, StoredEvent}; +use serde::{Deserialize, Serialize}; + +use crate::{Wal, WalError, WalResult}; + +/// One recorded decision point: the observation the policy saw, the action +/// it chose, and the effects the machine applied. This is the training-data +/// unit for offline policy learning and the audit trail for replay. +/// +/// Overlay declarations are persisted as counts inside `obs.overlay_status` +/// only — projected content is ephemeral and re-derived by the policy on +/// every turn, so it never enters the audit trail. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TrajectoryEvent { + pub step: u64, + pub obs: machine::Obs, + /// Ledger state migrations caused by this step's tool calls, lifted + /// from ledger tool results by `machine::ledger_transitions_in`. + pub ledger_transitions: Vec<machine::LedgerTransition>, + pub event: StoredEvent, +} + +pub struct Store { + wal: Wal, +} + +impl Store { + pub fn open(dir: impl AsRef<Path>) -> WalResult<Self> { + Ok(Self { + wal: Wal::open(dir.as_ref())?, + }) + } + + pub fn record_trajectory(&mut self, trajectory: &TrajectoryEvent) -> WalResult<u64> { + let payload = encode(trajectory)?; + let (offset, _) = self.wal.append(&payload)?; + Ok(offset) + } + + pub fn checkpoint(&mut self, state: &MachineState) -> WalResult<()> { + let payload = encode(state)?; + // Trajectory stores retain their events: the WAL is the payload, + // not a disposable prefix of it. + self.wal + .checkpoint_retaining_events(self.wal.next_offset(), &payload) + } + + pub async fn restore(&self) -> WalResult<Option<MachineState>> { + let (start_offset, mut state, had_checkpoint) = match self.wal.load()? { + Some((offset, payload)) => (offset, decode::<MachineState>(&payload)?, true), + None => (0, MachineState::default(), false), + }; + + let machine = Machine::new("recovered", "recovered"); + let mut applied_event = false; + for item in self.wal.replay(start_offset)? { + let (_, payload) = item?; + let trajectory = decode::<TrajectoryEvent>(&payload)?; + state.frame.step = trajectory.event.step; + machine.replay_effects(&mut state, &trajectory.event.effects); + applied_event = true; + } + + if had_checkpoint || applied_event { + Ok(Some(state)) + } else { + Ok(None) + } + } + + /// Read back the full recorded trajectory (envelopes, not bare events). + /// Used by tests and offline training pipelines to consume + /// `(obs, action, effects)` tuples exactly as the policy experienced them. + pub async fn trajectories(&self) -> WalResult<Vec<TrajectoryEvent>> { + let mut trajectories = Vec::new(); + // From origin, not from the last checkpoint: trajectory stores + // retain all events, and the training pipeline wants every + // (obs, action, effects) pair ever recorded. + for item in self.wal.replay_from_origin()? { + let (_, payload) = item?; + trajectories.push(decode::<TrajectoryEvent>(&payload)?); + } + Ok(trajectories) + } + + pub fn next_offset(&self) -> u64 { + self.wal.next_offset() + } +} + +fn encode(value: &impl Serialize) -> WalResult<Vec<u8>> { + serde_json::to_vec(value).map_err(|error| WalError::Codec { + detail: error.to_string(), + }) +} + +fn decode<Value: for<'de> Deserialize<'de>>(payload: &[u8]) -> WalResult<Value> { + serde_json::from_slice(payload).map_err(|error| WalError::Codec { + detail: error.to_string(), + }) +} diff --git a/crates/storage/tests/index.rs b/crates/storage/tests/index.rs new file mode 100644 index 00000000..64cbb95e --- /dev/null +++ b/crates/storage/tests/index.rs @@ -0,0 +1,330 @@ +use std::fs; +use std::io::Write; + +use storage::error::{CorruptIndex, WalError}; +use storage::index::SparseIndex; +use tempfile::TempDir; + +#[test] +fn new_creates_empty_index() { + let idx = SparseIndex::new(42, 256); + assert_eq!(idx.seg_id, 42); + assert_eq!(idx.interval, 256); + assert_eq!(idx.items.len(), 0); + assert!(idx.items.is_empty()); + assert_eq!(idx.count, 0); +} + +#[test] +fn insert_adds_on_interval_boundary() { + let mut idx = SparseIndex::new(1, 256); + idx.insert(100, 0); + assert_eq!(idx.items.len(), 1); + for i in 1..256 { + idx.insert(100 + i, 1024 + i * 4); + } + idx.insert(356, 2048); + assert_eq!(idx.items.len(), 2); + for i in 1..256 { + idx.insert(356 + i, 3000 + i * 4); + } + idx.insert(612, 4096); + assert_eq!(idx.items.len(), 3); + assert_eq!(idx.count, 513); +} + +#[test] +fn insert_skips_non_boundary() { + let mut idx = SparseIndex::new(1, 256); + idx.insert(1, 100); + idx.insert(255, 200); + idx.insert(257, 300); + idx.insert(500, 400); + assert_eq!(idx.items.len(), 1); + assert_eq!(idx.count, 4); +} + +#[test] +fn insert_interval_of_one_always_records() { + let mut idx = SparseIndex::new(1, 1); + idx.insert(0, 0); + idx.insert(1, 10); + idx.insert(2, 20); + assert_eq!(idx.items.len(), 3); + assert_eq!(idx.count, 3); +} + +#[test] +fn insert_zero_interval_always_records() { + let mut idx = SparseIndex::new(1, 0); + idx.insert(7, 100); + idx.insert(99, 200); + assert_eq!(idx.items.len(), 2); + assert_eq!(idx.count, 2); +} + +#[test] +fn insert_mixed_sequence() { + let mut idx = SparseIndex::new(1, 4); + idx.insert(10, 0); + idx.insert(11, 10); + idx.insert(12, 20); + idx.insert(13, 30); + idx.insert(14, 40); + idx.insert(15, 50); + idx.insert(16, 60); + assert_eq!(idx.items.len(), 2); + assert_eq!(idx.count, 7); +} + +#[test] +fn set_count_resumes_counting() { + let mut idx = SparseIndex::new(1, 4); + idx.set_count(3); + idx.insert(50, 500); + assert_eq!(idx.items.len(), 0); + idx.insert(51, 510); + assert_eq!(idx.count, 5); + assert_eq!(idx.items.len(), 1); +} + +#[test] +fn find_returns_none_for_empty_index() { + let idx = SparseIndex::new(1, 256); + assert!(idx.find(0).is_none()); + assert!(idx.find(1000).is_none()); +} + +#[test] +fn find_returns_exact_match() { + let mut idx = SparseIndex::new(1, 1); + idx.insert(0, 0); + idx.insert(4, 40); + idx.insert(8, 80); + + assert_eq!(idx.find(0), Some((0, 0))); + assert_eq!(idx.find(4), Some((4, 40))); + assert_eq!(idx.find(8), Some((8, 80))); +} + +#[test] +fn find_returns_closest_entry_before_target() { + let mut idx = SparseIndex::new(1, 1); + idx.insert(0, 0); + idx.insert(256, 1024); + idx.insert(512, 2048); + + assert_eq!(idx.find(300), Some((256, 1024))); + assert_eq!(idx.find(511), Some((256, 1024))); + assert_eq!(idx.find(1000), Some((512, 2048))); +} + +#[test] +fn find_returns_none_when_target_before_first_entry() { + let mut idx = SparseIndex::new(1, 1); + idx.insert(256, 1024); + assert!(idx.find(100).is_none()); + assert!(idx.find(255).is_none()); +} + +#[test] +fn find_with_single_entry() { + let mut idx = SparseIndex::new(1, 1); + idx.insert(256, 1024); + assert_eq!(idx.find(256), Some((256, 1024))); + assert_eq!(idx.find(500), Some((256, 1024))); + assert!(idx.find(100).is_none()); +} + +#[test] +fn save_load_round_trip() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("000001.idx"); + + let mut idx = SparseIndex::new(7, 256); + idx.insert(0, 0); + for i in 1..256 { + idx.insert(i, i * 16); + } + idx.insert(256, 4096); + for i in 1..256 { + idx.insert(256 + i, 4096 + i * 16); + } + idx.insert(512, 8192); + assert_eq!(idx.count, 513); + + idx.save(&path).unwrap(); + let loaded = SparseIndex::load(&path).unwrap(); + + assert_eq!(loaded.seg_id, 7); + assert_eq!(loaded.interval, 256); + assert_eq!(loaded.items.len(), 3); + assert_eq!(loaded.items[0], (0, 0)); + assert_eq!(loaded.items[1], (256, 4096)); + assert_eq!(loaded.items[2], (512, 8192)); + assert_eq!(loaded.count, 513); +} + +#[test] +fn save_load_empty_index() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("000002.idx"); + + let idx = SparseIndex::new(99, 128); + idx.save(&path).unwrap(); + let loaded = SparseIndex::load(&path).unwrap(); + + assert_eq!(loaded.seg_id, 99); + assert_eq!(loaded.interval, 128); + assert_eq!(loaded.items.len(), 0); + assert!(loaded.items.is_empty()); + assert_eq!(loaded.count, 0); +} + +#[test] +fn save_load_large_entries() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("000003.idx"); + + let mut idx = SparseIndex::new(1, 10); + for i in 0..10_000u64 { + idx.insert(i, (i / 10) * 64); + } + + assert_eq!(idx.items.len(), 1000); + idx.save(&path).unwrap(); + let loaded = SparseIndex::load(&path).unwrap(); + + assert_eq!(loaded.items.len(), 1000); + assert_eq!(loaded.items[0], (0, 0)); + assert_eq!(loaded.items[999], (9990, 999 * 64)); + + assert_eq!(loaded.find(5000), Some((5000, 500 * 64))); + assert_eq!(loaded.find(5005), Some((5000, 500 * 64))); +} + +#[test] +fn load_rejects_bad_magic() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("bad.idx"); + + let mut file = fs::File::create(&path).unwrap(); + let mut header = vec![0u8; 4096]; + header[0..4].copy_from_slice(b"XXXX"); + file.write_all(&header).unwrap(); + + let result = SparseIndex::load(&path); + assert!(result.is_err()); + assert!( + matches!( + result.unwrap_err(), + WalError::IndexCorrupted { + reason: CorruptIndex::BadMagic { .. }, + .. + } + ), + "expected BadMagic error" + ); +} + +#[test] +fn load_rejects_truncated_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("short.idx"); + + fs::write(&path, b"SIDX").unwrap(); + + let result = SparseIndex::load(&path); + assert!(result.is_err()); + assert!( + matches!( + result.unwrap_err(), + WalError::IndexCorrupted { + reason: CorruptIndex::FileTooSmall { .. }, + .. + } + ), + "expected FileTooSmall error" + ); +} + +#[test] +fn load_rejects_corrupted_entries() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("corrupt.idx"); + + let mut idx = SparseIndex::new(1, 1); + idx.insert(0, 0); + idx.insert(256, 1024); + idx.save(&path).unwrap(); + + let mut data = fs::read(&path).unwrap(); + let entry_offset = 4096 + 4; + data[entry_offset] ^= 0xFF; + fs::write(&path, &data).unwrap(); + + let result = SparseIndex::load(&path); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("checksum") || err.contains("mismatch"), + "unexpected error: {err}" + ); +} + +#[test] +fn load_rejects_truncated_entries() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("trunc.idx"); + + let mut idx = SparseIndex::new(1, 1); + idx.insert(0, 0); + idx.insert(256, 1024); + idx.save(&path).unwrap(); + + let mut data = fs::read(&path).unwrap(); + data.truncate(4096 + 16); + fs::write(&path, &data).unwrap(); + + let result = SparseIndex::load(&path); + assert!(result.is_err()); + assert!( + matches!( + result.unwrap_err(), + WalError::IndexCorrupted { + reason: CorruptIndex::ItemTruncated { .. }, + .. + } + ), + "expected ItemTruncated error" + ); +} + +#[test] +fn simulate_segment_indexing() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("000010.idx"); + + let mut idx = SparseIndex::new(10, 256); + let mut byte_off: u64 = 0; + + for call in 0u64..1024 { + let offset = call + 500; + idx.insert(offset, byte_off); + byte_off += 48; + } + + assert_eq!(idx.items.len(), 4); + assert_eq!(idx.count, 1024); + + idx.save(&path).unwrap(); + let loaded = SparseIndex::load(&path).unwrap(); + assert_eq!(loaded.items.len(), 4); + assert_eq!(loaded.count, 4 * 256); + + assert_eq!(loaded.find(500), Some((500, 0))); + assert_eq!(loaded.find(800), Some((756, 256 * 48))); + assert_eq!(loaded.find(1100), Some((1012, 512 * 48))); + assert_eq!(loaded.find(1400), Some((1268, 768 * 48))); + assert_eq!(loaded.find(1523), Some((1268, 768 * 48))); +} diff --git a/crates/storage/tests/manifest.rs b/crates/storage/tests/manifest.rs new file mode 100644 index 00000000..f6ea659a --- /dev/null +++ b/crates/storage/tests/manifest.rs @@ -0,0 +1,49 @@ +use std::fs; + +use storage::manifest::Manifest; +use tempfile::TempDir; + +#[test] +fn create_and_load() { + let dir = TempDir::new().unwrap(); + let manifest = Manifest::open(dir.path()).unwrap(); + assert_eq!(manifest.version, 1); + assert_eq!(manifest.open_seg(), 0); + assert_eq!(manifest.first_seg(), 1); + assert_eq!(manifest.next_offset(), 0); + assert!(manifest.snap_offset().is_none()); + + let loaded = Manifest::open(dir.path()).unwrap(); + assert_eq!(loaded.version, manifest.version); + assert_eq!(loaded.open_seg(), manifest.open_seg()); + assert_eq!(loaded.next_offset(), manifest.next_offset()); +} + +#[test] +fn update_persists() { + let dir = TempDir::new().unwrap(); + let mut manifest = Manifest::open(dir.path()).unwrap(); + + manifest.set_open_seg(3); + manifest.set_first_seg(2); + manifest.set_next_offset(500001); + manifest.set_snap_offset(450000).unwrap(); + + let loaded = Manifest::open(dir.path()).unwrap(); + assert_eq!(loaded.open_seg(), 3); + assert_eq!(loaded.first_seg(), 2); + assert_eq!(loaded.next_offset(), 500001); + assert_eq!(loaded.snap_offset(), Some(450000)); +} + +#[test] +fn human_readable() { + let dir = TempDir::new().unwrap(); + let mut manifest = Manifest::open(dir.path()).unwrap(); + manifest.set_next_offset(42); + manifest.save().unwrap(); + + let content = fs::read_to_string(dir.path().join("events/manifest.dat")).unwrap(); + assert!(content.contains("version=1")); + assert!(content.contains("next_event_seq=42")); +} diff --git a/crates/storage/tests/segment.rs b/crates/storage/tests/segment.rs new file mode 100644 index 00000000..e6da8880 --- /dev/null +++ b/crates/storage/tests/segment.rs @@ -0,0 +1,401 @@ +use storage::error::{CorruptSegment, WalError, WalResult}; +use storage::segment::{HEADER_MAGIC, HEADER_SIZE, SegmentReader, SegmentWriter, TAIL_MAGIC}; +use tempfile::TempDir; + +fn seg_path(dir: &std::path::Path, id: u64) -> std::path::PathBuf { + dir.join(format!("{:06}.seg", id)) +} + +fn make_writer(dir: &std::path::Path, seg_id: u64, offset: u64) -> SegmentWriter { + SegmentWriter::new(dir, seg_id, offset, 64 * 1024 * 1024) + .expect("failed to create SegmentWriter") +} + +#[test] +fn header_magic_and_version() { + let dir = TempDir::new().unwrap(); + let writer = make_writer(dir.path(), 1, 100); + let path = seg_path(dir.path(), 1); + + let data = std::fs::read(&path).unwrap(); + assert_eq!(&data[0..4], &HEADER_MAGIC); + assert_eq!(u16::from_le_bytes([data[4], data[5]]), 1); + assert_eq!(u64::from_le_bytes(data[8..16].try_into().unwrap()), 1); + assert_eq!(u64::from_le_bytes(data[16..24].try_into().unwrap()), 100); + assert_eq!(u32::from_le_bytes(data[24..28].try_into().unwrap()), 0); + drop(writer); +} + +#[test] +fn header_is_4096_bytes() { + let dir = TempDir::new().unwrap(); + let writer = make_writer(dir.path(), 1, 0); + let path = seg_path(dir.path(), 1); + let data = std::fs::read(&path).unwrap(); + assert_eq!(data.len(), HEADER_SIZE as usize); + drop(writer); +} + +#[test] +fn append_returns_correct_offset_and_position() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 5, 1000); + + let (off0, pos0) = writer.append(b"hello").unwrap(); + assert_eq!(off0, 1000); + assert_eq!(pos0, HEADER_SIZE); + + let (off1, pos1) = writer.append(b"world").unwrap(); + assert_eq!(off1, 1001); + assert_eq!(pos1, HEADER_SIZE + 8 + 5); +} + +#[test] +fn iter_after_close() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + + let (_, pos0) = writer.append(b"alpha").unwrap(); + let (_, pos1) = writer.append(b"beta").unwrap(); + writer.close().unwrap(); + + let reader = SegmentReader::open(&seg_path(dir.path(), 1)).unwrap(); + let items: Vec<_> = reader.iter().collect::<WalResult<Vec<_>>>().unwrap(); + + assert_eq!(items.len(), 2); + assert_eq!(items[0].0, pos0); + assert_eq!(items[0].1, b"alpha"); + assert_eq!(items[1].0, pos1); + assert_eq!(items[1].1, b"beta"); +} + +#[test] +fn iter_without_close() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + + let (_, pos0) = writer.append(b"gamma").unwrap(); + drop(writer); + + let reader = SegmentReader::open(&seg_path(dir.path(), 1)).unwrap(); + let items: Vec<_> = reader.iter().collect::<WalResult<Vec<_>>>().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].0, pos0); + assert_eq!(items[0].1, b"gamma"); +} + +#[test] +fn empty_payload() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + + let (_, pos) = writer.append(b"").unwrap(); + writer.close().unwrap(); + + let reader = SegmentReader::open(&seg_path(dir.path(), 1)).unwrap(); + let items: Vec<_> = reader.iter().collect::<WalResult<Vec<_>>>().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].0, pos); + assert_eq!(items[0].1, b""); +} + +#[test] +fn large_payload() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + + let payload: Vec<u8> = (0..100_000).map(|i| (i % 256) as u8).collect(); + let (_, pos) = writer.append(&payload).unwrap(); + writer.close().unwrap(); + + let reader = SegmentReader::open(&seg_path(dir.path(), 1)).unwrap(); + let items: Vec<_> = reader.iter().collect::<WalResult<Vec<_>>>().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].0, pos); + assert_eq!(items[0].1, &payload[..]); +} + +#[test] +fn detect_corruption() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + let (_, pos) = writer.append(b"clean data").unwrap(); + writer.close().unwrap(); + + let path = seg_path(dir.path(), 1); + let mut data = std::fs::read(&path).unwrap(); + + let payload_start = pos as usize + 8; + data[payload_start] ^= 0xFF; + std::fs::write(&path, &data).unwrap(); + + let reader = SegmentReader::open(&path).unwrap(); + let collected: Result<Vec<_>, _> = reader.iter().collect(); + let err = collected.unwrap_err(); + match err { + WalError::SegmentCorrupted { + reason: CorruptSegment::ChecksumMismatch { .. }, + .. + } => {} + other => panic!("expected SegmentCorrupted with ChecksumMismatch, got {other}"), + } +} + +#[test] +fn total_crc_in_tail() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + + let payload_a = b"hello"; + let payload_b = b"world"; + writer.append(payload_a).unwrap(); + writer.append(payload_b).unwrap(); + writer.close().unwrap(); + + let path = seg_path(dir.path(), 1); + let data = std::fs::read(&path).unwrap(); + + let tail_start = data.len() - 8; + assert_eq!(&data[tail_start..tail_start + 4], &TAIL_MAGIC); + + let stored_total_crc = + u32::from_le_bytes(data[tail_start + 4..tail_start + 8].try_into().unwrap()); + let mut concatenated = Vec::new(); + concatenated.extend_from_slice(payload_a); + concatenated.extend_from_slice(payload_b); + assert_eq!(stored_total_crc, crc32c::crc32c(&concatenated)); +} + +#[test] +fn close_updates_count() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + writer.append(b"a").unwrap(); + writer.append(b"b").unwrap(); + writer.append(b"c").unwrap(); + writer.close().unwrap(); + + let reader = SegmentReader::open(&seg_path(dir.path(), 1)).unwrap(); + assert_eq!(reader.count(), 3); +} + +#[test] +fn iter_all_items() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 10); + + writer.append(b"one").unwrap(); + writer.append(b"two").unwrap(); + writer.append(b"three").unwrap(); + writer.close().unwrap(); + + let reader = SegmentReader::open(&seg_path(dir.path(), 1)).unwrap(); + let items: Vec<_> = reader.iter().collect::<WalResult<Vec<_>>>().unwrap(); + + assert_eq!(items.len(), 3); + assert_eq!(items[0].1, b"one"); + assert_eq!(items[1].1, b"two"); + assert_eq!(items[2].1, b"three"); + + assert_eq!(items[0].0, HEADER_SIZE); + assert_eq!(items[1].0, HEADER_SIZE + 8 + 3); + assert_eq!(items[2].0, HEADER_SIZE + 8 + 3 + 8 + 3); +} + +#[test] +fn iter_without_tail() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + writer.append(b"x").unwrap(); + writer.append(b"y").unwrap(); + drop(writer); + + let reader = SegmentReader::open(&seg_path(dir.path(), 1)).unwrap(); + let items: Vec<_> = reader.iter().collect::<WalResult<Vec<_>>>().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].1, b"x"); + assert_eq!(items[1].1, b"y"); +} + +#[test] +fn iter_empty_segment() { + let dir = TempDir::new().unwrap(); + let writer = make_writer(dir.path(), 1, 0); + writer.close().unwrap(); + + let reader = SegmentReader::open(&seg_path(dir.path(), 1)).unwrap(); + let items: Vec<_> = reader.iter().collect::<WalResult<Vec<_>>>().unwrap(); + assert!(items.is_empty()); +} + +#[test] +fn is_full_when_full() { + let dir = TempDir::new().unwrap(); + let max_size = HEADER_SIZE + 8 + 5; + let mut writer = SegmentWriter::new(dir.path(), 1, 0, max_size).unwrap(); + + assert!(!writer.is_full()); + writer.append(b"hello").unwrap(); + assert!(writer.is_full()); +} + +#[test] +fn open_rejects_bad_magic() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("bad.seg"); + let mut data = vec![0u8; 4096]; + data[0..4].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); + std::fs::write(&path, &data).unwrap(); + + let result = SegmentReader::open(&path); + assert!(result.is_err()); + match result.unwrap_err() { + WalError::SegmentCorrupted { + reason: CorruptSegment::BadMagic { .. }, + .. + } => {} + other => panic!("expected SegmentCorrupted with BadMagic, got {other}"), + } +} + +#[test] +fn open_rejects_too_small() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("tiny.seg"); + std::fs::write(&path, b"EVNT").unwrap(); + + let result = SegmentReader::open(&path); + assert!(result.is_err()); +} + +#[test] +fn multiple_segments_independent() { + let dir = TempDir::new().unwrap(); + + let mut w1 = make_writer(dir.path(), 1, 0); + let mut w2 = make_writer(dir.path(), 2, 100); + + w1.append(b"seg1").unwrap(); + w2.append(b"seg2").unwrap(); + + w1.close().unwrap(); + w2.close().unwrap(); + + let r1 = SegmentReader::open(&seg_path(dir.path(), 1)).unwrap(); + let r2 = SegmentReader::open(&seg_path(dir.path(), 2)).unwrap(); + + assert_eq!(r1.id(), 1); + assert_eq!(r2.id(), 2); + + let items1: Vec<_> = r1.iter().collect::<WalResult<Vec<_>>>().unwrap(); + let items2: Vec<_> = r2.iter().collect::<WalResult<Vec<_>>>().unwrap(); + assert_eq!(items1[0].1, b"seg1"); + assert_eq!(items2[0].1, b"seg2"); +} + +#[test] +fn writer_properties() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 7, 200); + + assert_eq!(writer.id(), 7); + assert_eq!(writer.offset(), 200); + assert_eq!(writer.count(), 0); + + writer.append(b"test").unwrap(); + assert_eq!(writer.count(), 1); +} + +#[test] +fn open_empty_segment() { + let dir = TempDir::new().unwrap(); + let writer = make_writer(dir.path(), 1, 100); + drop(writer); + + let path = seg_path(dir.path(), 1); + let (resumed, index) = SegmentWriter::open(&path, 64 * 1024 * 1024, 256).unwrap(); + assert_eq!(resumed.id(), 1); + assert_eq!(resumed.offset(), 100); + assert_eq!(resumed.count(), 0); + assert!(index.items.is_empty()); + drop(resumed); +} + +#[test] +fn open_with_records() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + writer.append(b"one").unwrap(); + writer.append(b"two").unwrap(); + writer.append(b"three").unwrap(); + drop(writer); + + let path = seg_path(dir.path(), 1); + let (resumed, _index) = SegmentWriter::open(&path, 64 * 1024 * 1024, 256).unwrap(); + assert_eq!(resumed.count(), 3); + assert_eq!(resumed.offset(), 0); +} + +#[test] +fn open_and_continue_writing() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 10); + writer.append(b"before").unwrap(); + drop(writer); + + let path = seg_path(dir.path(), 1); + let (mut resumed, _) = SegmentWriter::open(&path, 64 * 1024 * 1024, 256).unwrap(); + let (off, _pos) = resumed.append(b"after").unwrap(); + assert_eq!(off, 11); + assert_eq!(resumed.count(), 2); + resumed.close().unwrap(); + + let reader = SegmentReader::open(&path).unwrap(); + assert_eq!(reader.count(), 2); + let items: Vec<_> = reader.iter().collect::<WalResult<Vec<_>>>().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].1, b"before"); + assert_eq!(items[1].1, b"after"); +} + +#[test] +fn open_closed_segment_returns_error() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + writer.append(b"data").unwrap(); + writer.close().unwrap(); + + let path = seg_path(dir.path(), 1); + let result = SegmentWriter::open(&path, 64 * 1024 * 1024, 256); + assert!(result.is_err(), "expected error for closed segment"); + let err = result.err().unwrap(); + assert!( + matches!( + err, + WalError::SegmentCorrupted { + reason: CorruptSegment::AlreadyClosed, + .. + } + ), + "expected AlreadyClosed, got: {err}" + ); +} + +#[test] +fn open_with_partial_write() { + let dir = TempDir::new().unwrap(); + let mut writer = make_writer(dir.path(), 1, 0); + writer.append(b"good").unwrap(); + drop(writer); + + let path = seg_path(dir.path(), 1); + let mut data = std::fs::read(&path).unwrap(); + data.extend_from_slice(&[0xFF; 5]); + std::fs::write(&path, &data).unwrap(); + + let (resumed, _) = SegmentWriter::open(&path, 64 * 1024 * 1024, 256).unwrap(); + assert_eq!(resumed.count(), 1); + + let file_len = std::fs::metadata(&path).unwrap().len(); + assert_eq!(file_len, HEADER_SIZE + 8 + 4); +} diff --git a/crates/storage/tests/snapshot.rs b/crates/storage/tests/snapshot.rs new file mode 100644 index 00000000..71f208c4 --- /dev/null +++ b/crates/storage/tests/snapshot.rs @@ -0,0 +1,261 @@ +use std::fs; +use std::path::Path; + +use storage::error::{CorruptSnapshot, WalError}; +use storage::manifest::Manifest; +use storage::snapshot; +use tempfile::TempDir; + +fn make_manifest(dir: &Path, snap_offset: Option<u64>) -> Manifest { + let mut m = Manifest::open(dir).unwrap(); + if let Some(offset) = snap_offset { + m.set_snap_offset(offset).unwrap(); + } + m +} + +#[test] +fn save_and_read_round_trip() { + let dir = TempDir::new().unwrap(); + let state = b"hello snapshot world"; + let offset = 450u64; + + snapshot::save(dir.path(), offset, state).unwrap(); + + let path = snapshot::snap_path(dir.path(), offset); + assert!(path.exists()); + + let (read_offset, read_state) = snapshot::read(&path).unwrap(); + assert_eq!(read_offset, offset); + assert_eq!(read_state, state); +} + +#[test] +fn path_generation_bucket_0() { + let dir = TempDir::new().unwrap(); + let path = snapshot::snap_path(dir.path(), 0); + assert_eq!(path, dir.path().join("snapshots/0000-0999/ckpt-000000.dat")); +} + +#[test] +fn path_generation_bucket_middle() { + let dir = TempDir::new().unwrap(); + let path = snapshot::snap_path(dir.path(), 450); + assert_eq!(path, dir.path().join("snapshots/0000-0999/ckpt-000450.dat")); +} + +#[test] +fn path_generation_bucket_boundary() { + let dir = TempDir::new().unwrap(); + let path = snapshot::snap_path(dir.path(), 1000); + assert_eq!(path, dir.path().join("snapshots/1000-1999/ckpt-001000.dat")); +} + +#[test] +fn path_generation_bucket_large() { + let dir = TempDir::new().unwrap(); + let path = snapshot::snap_path(dir.path(), 500001); + assert_eq!( + path, + dir.path().join("snapshots/500000-500999/ckpt-500001.dat") + ); +} + +#[test] +fn load_with_manifest() { + let dir = TempDir::new().unwrap(); + let manifest = make_manifest(dir.path(), None); + + let result = snapshot::load(dir.path(), &manifest).unwrap(); + assert!(result.is_none()); + + snapshot::save(dir.path(), 200, b"state-v1").unwrap(); + let manifest = make_manifest(dir.path(), Some(200)); + + let (offset, state) = snapshot::load(dir.path(), &manifest).unwrap().unwrap(); + assert_eq!(offset, 200); + assert_eq!(state, b"state-v1"); +} + +#[test] +fn load_manifest_points_to_missing_file() { + let dir = TempDir::new().unwrap(); + let manifest = make_manifest(dir.path(), Some(9999)); + + let result = snapshot::load(dir.path(), &manifest); + assert!(result.is_err()); + assert!( + matches!(result.unwrap_err(), WalError::SnapshotNotFound), + "expected SnapshotNotFound" + ); +} + +#[test] +fn atomic_save_no_temp_file_left() { + let dir = TempDir::new().unwrap(); + snapshot::save(dir.path(), 100, b"data").unwrap(); + + let path = snapshot::snap_path(dir.path(), 100); + assert!(path.exists()); + + let temp_path = path.with_extension("dat.tmp"); + assert!(!temp_path.exists()); +} + +#[test] +fn save_creates_parent_directories() { + let dir = TempDir::new().unwrap(); + let offset = 5000u64; + + snapshot::save(dir.path(), offset, b"deep write").unwrap(); + + let path = snapshot::snap_path(dir.path(), offset); + assert!(path.exists()); + assert!(dir.path().join("snapshots/5000-5999").exists()); +} + +#[test] +fn empty_payload_round_trip() { + let dir = TempDir::new().unwrap(); + snapshot::save(dir.path(), 0, b"").unwrap(); + + let path = snapshot::snap_path(dir.path(), 0); + let (offset, state) = snapshot::read(&path).unwrap(); + assert_eq!(offset, 0); + assert!(state.is_empty()); +} + +#[test] +fn large_payload_round_trip() { + let dir = TempDir::new().unwrap(); + let state: Vec<u8> = (0..100_000).map(|i| (i % 256) as u8).collect(); + + snapshot::save(dir.path(), 9999, &state).unwrap(); + + let path = snapshot::snap_path(dir.path(), 9999); + let (offset, read_state) = snapshot::read(&path).unwrap(); + assert_eq!(offset, 9999); + assert_eq!(read_state, state); +} + +#[test] +fn bad_magic_rejected() { + let dir = TempDir::new().unwrap(); + snapshot::save(dir.path(), 42, b"good data").unwrap(); + + let path = snapshot::snap_path(dir.path(), 42); + let mut data = fs::read(&path).unwrap(); + data[0] = b'X'; + fs::write(&path, &data).unwrap(); + + let err = snapshot::read(&path).unwrap_err(); + match err { + WalError::SnapshotCorrupted { reason, .. } => { + assert!(matches!(reason, CorruptSnapshot::BadMagic { .. })); + } + other => panic!("expected SnapshotCorrupted, got {other}"), + } +} + +#[test] +fn bad_checksum_rejected() { + let dir = TempDir::new().unwrap(); + snapshot::save(dir.path(), 42, b"good data").unwrap(); + + let path = snapshot::snap_path(dir.path(), 42); + let mut data = fs::read(&path).unwrap(); + let payload_start = 4096; + data[payload_start] ^= 0xFF; + fs::write(&path, &data).unwrap(); + + let err = snapshot::read(&path).unwrap_err(); + match err { + WalError::SnapshotCorrupted { reason, .. } => { + assert!(matches!(reason, CorruptSnapshot::ChecksumMismatch { .. })); + } + other => panic!("expected SnapshotCorrupted with ChecksumMismatch, got {other}"), + } +} + +#[test] +fn truncated_header_rejected() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("snapshots/bad.dat"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"SNAP").unwrap(); + + let err = snapshot::read(&path).unwrap_err(); + match err { + WalError::SnapshotCorrupted { reason, .. } => { + assert!(matches!(reason, CorruptSnapshot::HeaderTooShort { .. })); + } + other => panic!("expected SnapshotCorrupted, got {other}"), + } +} + +#[test] +fn unsupported_version_rejected() { + let dir = TempDir::new().unwrap(); + snapshot::save(dir.path(), 10, b"data").unwrap(); + + let path = snapshot::snap_path(dir.path(), 10); + let mut data = fs::read(&path).unwrap(); + data[4..6].copy_from_slice(&99u16.to_le_bytes()); + fs::write(&path, &data).unwrap(); + + let err = snapshot::read(&path).unwrap_err(); + match err { + WalError::SnapshotCorrupted { reason, .. } => { + assert!(matches!(reason, CorruptSnapshot::UnsupportedVersion { .. })); + } + other => panic!("expected SnapshotCorrupted, got {other}"), + } +} + +#[test] +fn truncated_payload_rejected() { + let dir = TempDir::new().unwrap(); + snapshot::save(dir.path(), 10, b"some payload").unwrap(); + + let path = snapshot::snap_path(dir.path(), 10); + let data = fs::read(&path).unwrap(); + fs::write(&path, &data[..data.len() - 3]).unwrap(); + + let err = snapshot::read(&path).unwrap_err(); + match err { + WalError::SnapshotCorrupted { reason, .. } => { + assert!(matches!(reason, CorruptSnapshot::PayloadTruncated { .. })); + } + other => panic!("expected SnapshotCorrupted, got {other}"), + } +} + +#[test] +fn multiple_snapshots_coexist() { + let dir = TempDir::new().unwrap(); + snapshot::save(dir.path(), 100, b"first").unwrap(); + snapshot::save(dir.path(), 200, b"second").unwrap(); + snapshot::save(dir.path(), 1000, b"third").unwrap(); + + let (off1, p1) = snapshot::read(&snapshot::snap_path(dir.path(), 100)).unwrap(); + let (off2, p2) = snapshot::read(&snapshot::snap_path(dir.path(), 200)).unwrap(); + let (off3, p3) = snapshot::read(&snapshot::snap_path(dir.path(), 1000)).unwrap(); + + assert_eq!(off1, 100); + assert_eq!(p1, b"first"); + assert_eq!(off2, 200); + assert_eq!(p2, b"second"); + assert_eq!(off3, 1000); + assert_eq!(p3, b"third"); +} + +#[test] +fn overwrite_existing_snapshot() { + let dir = TempDir::new().unwrap(); + snapshot::save(dir.path(), 50, b"old").unwrap(); + snapshot::save(dir.path(), 50, b"new").unwrap(); + + let (offset, state) = snapshot::read(&snapshot::snap_path(dir.path(), 50)).unwrap(); + assert_eq!(offset, 50); + assert_eq!(state, b"new"); +} diff --git a/crates/storage/tests/store.rs b/crates/storage/tests/store.rs new file mode 100644 index 00000000..fe21a1c4 --- /dev/null +++ b/crates/storage/tests/store.rs @@ -0,0 +1,348 @@ +use machine::{ + Action, Effect, Fragment, InboxItem, MachineState, Model, Obs, Resources, StoredEvent, + ToolDefinition, +}; +use serde_json::json; +use storage::{Store, TrajectoryEvent}; +use tempfile::TempDir; + +fn model(name: &str) -> Model { + Model { + name: name.to_string(), + ..Default::default() + } +} + +fn tool_definition(name: &str) -> ToolDefinition { + ToolDefinition { + name: name.to_string(), + description: format!("{name} tool"), + parameters: json!({"type": "object"}), + } +} + +fn state_with_resources() -> MachineState { + let resources = Resources::new() + .with_model(model("fast")) + .with_tool_definition(tool_definition("search")); + let mut state = MachineState::default(); + state.run.environment = machine::Environment::empty("/tmp"); + state.run.resources = resources; + state +} + +fn trajectory(step: u64, action: Action, effects: Vec<Effect>) -> TrajectoryEvent { + TrajectoryEvent { + step, + obs: Obs::default(), + ledger_transitions: Vec::new(), + event: StoredEvent::new(step, action, effects), + } +} + +#[tokio::test] +async fn restore_returns_none_for_empty_store() { + let dir = TempDir::new().unwrap(); + let store = Store::open(dir.path()).unwrap(); + assert!(store.restore().await.unwrap().is_none()); +} + +#[tokio::test] +async fn record_and_restore_context_actions() { + let dir = TempDir::new().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + + store + .record_trajectory(&trajectory( + 1, + Action::Append(Fragment::user("hello")), + vec![ + Effect::ActionCounted { + action: "append".into(), + }, + Effect::ContextAppended { + id: 1, + fragment: Fragment::user("hello"), + }, + ], + )) + .unwrap(); + store + .record_trajectory(&trajectory( + 2, + Action::Append(Fragment::assistant("world")), + vec![ + Effect::ActionCounted { + action: "append".into(), + }, + Effect::ContextAppended { + id: 2, + fragment: Fragment::assistant("world"), + }, + ], + )) + .unwrap(); + + let restored = store.restore().await.unwrap().unwrap(); + assert_eq!(restored.run.context.fragments().len(), 2); + assert_eq!(restored.run.context.fragments()[0].as_text(), Some("hello")); + assert_eq!(restored.run.context.fragments()[1].as_text(), Some("world")); + assert_eq!(restored.frame.step, 2); +} + +#[tokio::test] +async fn restore_replays_runtime_resource_actions() { + let dir = TempDir::new().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + let mut state = state_with_resources(); + store.checkpoint(&state).unwrap(); + + store + .record_trajectory(&trajectory( + 1, + Action::Model("fast".into()), + vec![ + Effect::ActionCounted { + action: "model".into(), + }, + Effect::ModelSelected { + name: "fast".into(), + }, + ], + )) + .unwrap(); + store + .record_trajectory(&trajectory( + 2, + Action::Activate("search".into()), + vec![ + Effect::ActionCounted { + action: "activate".into(), + }, + Effect::ToolActivated { + name: "search".into(), + }, + ], + )) + .unwrap(); + store + .record_trajectory(&trajectory( + 3, + Action::Deactivate("search".into()), + vec![ + Effect::ActionCounted { + action: "deactivate".into(), + }, + Effect::ToolDeactivated { + name: "search".into(), + }, + ], + )) + .unwrap(); + + let restored = store.restore().await.unwrap().unwrap(); + assert_eq!(restored.run.resources.active_model, "fast"); + assert!(!restored.run.resources.active_tools.contains("search")); + assert_eq!(restored.frame.step, 3); + + state.run.resources.use_model("fast").unwrap(); + state.run.resources.enable("search").unwrap(); + state.run.resources.disable("search"); + assert_eq!( + restored.run.resources.active_model, + state.run.resources.active_model + ); + assert_eq!( + restored.run.resources.active_tools, + state.run.resources.active_tools + ); +} + +#[tokio::test] +async fn failed_action_replays_recorded_inbox_hitch() { + let dir = TempDir::new().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + let hitch = Fragment::hitch( + "fragment id 999 not found", + None, + machine::Role::System, + None::<&str>, + ); + + store + .record_trajectory(&trajectory( + 1, + Action::Remove(999), + vec![ + Effect::ActionCounted { + action: "remove".into(), + }, + Effect::InboxPushed { + item: InboxItem::new(hitch, None), + }, + ], + )) + .unwrap(); + + let restored = store.restore().await.unwrap().unwrap(); + assert_eq!(restored.run.context.fragments().len(), 0); + assert_eq!(restored.frame.inbox.len(), 1); + assert!( + restored + .frame + .inbox + .peek() + .unwrap() + .fragment + .content_as_text() + .contains("fragment id 999 not found") + ); +} + +#[tokio::test] +async fn completion_output_replays_through_inbox_and_take() { + let dir = TempDir::new().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + let completion_id = machine::CompletionId(1); + + store + .record_trajectory(&trajectory( + 1, + Action::Halt, + vec![ + Effect::ActionCounted { + action: "halt".into(), + }, + Effect::CompletionRecorded { + record: machine::CompletionRecord { + id: completion_id, + step: 1, + model: Some("fast".into()), + tokens: machine::TokenUsage { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + cached_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + output_fragment_ids: Vec::new(), + }, + inbox_items: vec![InboxItem::new( + Fragment::assistant("answer"), + Some(completion_id), + )], + }, + ], + )) + .unwrap(); + store + .record_trajectory(&trajectory( + 2, + Action::Take, + vec![ + Effect::ActionCounted { + action: "take".into(), + }, + Effect::InboxTaken { + source_completion: Some(completion_id), + fragment_id: 1, + }, + ], + )) + .unwrap(); + + let restored = store.restore().await.unwrap().unwrap(); + assert_eq!(restored.run.context.fragments().len(), 1); + assert_eq!( + restored.run.context.fragments()[0].as_text(), + Some("answer") + ); + assert!(restored.frame.inbox.is_empty()); + assert_eq!(restored.run.telemetry.completions.len(), 1); + assert_eq!(restored.run.telemetry.completions[0].tokens.total_tokens, 2); + assert_eq!( + restored.run.telemetry.completions[0].output_fragment_ids, + [1] + ); +} + +#[tokio::test] +async fn checkpoint_skips_replaying_old_events() { + let dir = TempDir::new().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + + store + .record_trajectory(&trajectory( + 1, + Action::Append(Fragment::user("before")), + vec![ + Effect::ActionCounted { + action: "append".into(), + }, + Effect::ContextAppended { + id: 1, + fragment: Fragment::user("before"), + }, + ], + )) + .unwrap(); + let mut checkpoint = MachineState::default(); + checkpoint.run.context.append(Fragment::user("checkpoint")); + checkpoint.frame.step = 1; + store.checkpoint(&checkpoint).unwrap(); + store + .record_trajectory(&trajectory( + 2, + Action::Append(Fragment::assistant("after")), + vec![ + Effect::ActionCounted { + action: "append".into(), + }, + Effect::ContextAppended { + id: 2, + fragment: Fragment::assistant("after"), + }, + ], + )) + .unwrap(); + + let restored = store.restore().await.unwrap().unwrap(); + assert_eq!(restored.run.context.fragments().len(), 2); + assert_eq!( + restored.run.context.fragments()[0].as_text(), + Some("checkpoint") + ); + assert_eq!(restored.run.context.fragments()[1].as_text(), Some("after")); +} + +#[tokio::test] +async fn trajectories_round_trip_observation_snapshots() { + let dir = TempDir::new().unwrap(); + let mut store = Store::open(dir.path()).unwrap(); + + let mut observed = Obs::default(); + observed.budget.context_limit = 128_000; + observed.budget.estimated_input = 4_096; + observed.budget.last_actual_input = Some(3_850); + let recorded = TrajectoryEvent { + step: 7, + obs: observed, + ledger_transitions: Vec::new(), + event: StoredEvent::new( + 7, + Action::Take, + vec![Effect::ActionCounted { + action: "take".into(), + }], + ), + }; + store.record_trajectory(&recorded).unwrap(); + + let replayed = store.trajectories().await.unwrap(); + assert_eq!(replayed.len(), 1); + assert_eq!(replayed[0].step, 7); + assert_eq!(replayed[0].obs.budget.context_limit, 128_000); + assert_eq!(replayed[0].obs.budget.estimated_input, 4_096); + assert_eq!(replayed[0].obs.budget.last_actual_input, Some(3_850)); + assert_eq!(replayed[0].event.action, Action::Take); +} diff --git a/crates/storage/tests/wal.rs b/crates/storage/tests/wal.rs new file mode 100644 index 00000000..97710673 --- /dev/null +++ b/crates/storage/tests/wal.rs @@ -0,0 +1,617 @@ +use std::path::Path; + +use storage::{Wal, WalError}; +use tempfile::TempDir; + +fn patch_segment_size(dir: &Path, size: u64) { + let manifest_path = dir.join("events/manifest.dat"); + let content = std::fs::read_to_string(&manifest_path).unwrap(); + let patched = content.replace("segment_size=134217728", &format!("segment_size={size}")); + std::fs::write(&manifest_path, patched).unwrap(); +} + +fn count_files_with_ext(dir: &Path, ext: &str) -> usize { + let mut count = 0; + if !dir.exists() { + return 0; + } + for entry in std::fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + count += count_files_with_ext(&path, ext); + } else if path.extension().and_then(|s| s.to_str()) == Some(ext) { + count += 1; + } + } + count +} + +#[test] +fn open_creates_new_workspace() { + let dir = TempDir::new().unwrap(); + let wal = Wal::open(dir.path()).unwrap(); + assert_eq!(wal.next_offset(), 0); +} + +#[test] +fn append_returns_correct_offset() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + let (off0, _) = wal.append(b"hello").unwrap(); + let (off1, _) = wal.append(b"world").unwrap(); + assert_eq!(off0, 0); + assert_eq!(off1, 1); + assert_eq!(wal.next_offset(), 2); +} + +#[test] +fn checkpoint_and_load() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + wal.append(b"e1").unwrap(); + wal.append(b"e2").unwrap(); + wal.checkpoint(2, b"state-at-2").unwrap(); + + let (offset, state) = wal.load().unwrap().unwrap(); + assert_eq!(offset, 2); + assert_eq!(state, b"state-at-2"); +} + +#[test] +fn checkpoint_trims_segments_covered_by_snapshot() { + let dir = TempDir::new().unwrap(); + Wal::open(dir.path()).unwrap(); + patch_segment_size(dir.path(), 4196); + + let mut wal = Wal::open(dir.path()).unwrap(); + let payload = vec![0u8; 60]; + for _ in 0..5 { + wal.append(&payload).unwrap(); + } + + let seg_dir = dir.path().join("events/segments/0000-0999"); + assert!(count_files_with_ext(&seg_dir, "seg") > 1); + + wal.checkpoint(5, b"state-at-5").unwrap(); + + assert!(!seg_dir.join("000001.seg").exists()); + assert!(!seg_dir.join("000001.idx").exists()); + let err = match wal.replay(0) { + Ok(_) => panic!("expected compacted replay offset to fail"), + Err(err) => err, + }; + match err { + WalError::OffsetOutOfRange { requested, next } => { + assert_eq!(requested, 0); + assert_eq!(next, 5); + } + other => panic!("expected OffsetOutOfRange, got: {other}"), + } + + let items: Vec<_> = wal + .replay(5) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + assert!(items.is_empty()); + + drop(wal); + let wal = Wal::open(dir.path()).unwrap(); + let (offset, state) = wal.load().unwrap().unwrap(); + assert_eq!(offset, 5); + assert_eq!(state, b"state-at-5"); +} + +#[test] +fn checkpoint_keeps_only_two_latest_snapshots() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + for i in 0..4 { + wal.append(format!("e{i}").as_bytes()).unwrap(); + wal.checkpoint(i + 1, format!("state-{i}").as_bytes()) + .unwrap(); + } + + let snapshot_dir = dir.path().join("snapshots"); + assert_eq!(count_files_with_ext(&snapshot_dir, "dat"), 2); + assert!(!snapshot_dir.join("0000-0999/ckpt-000001.dat").exists()); + assert!(!snapshot_dir.join("0000-0999/ckpt-000002.dat").exists()); + assert!(snapshot_dir.join("0000-0999/ckpt-000003.dat").exists()); + assert!(snapshot_dir.join("0000-0999/ckpt-000004.dat").exists()); + + let (offset, state) = wal.load().unwrap().unwrap(); + assert_eq!(offset, 4); + assert_eq!(state, b"state-3"); +} + +#[test] +fn no_checkpoint_returns_none() { + let dir = TempDir::new().unwrap(); + let wal = Wal::open(dir.path()).unwrap(); + assert!(wal.load().unwrap().is_none()); +} + +#[test] +fn reopen_preserves_data() { + let dir = TempDir::new().unwrap(); + + { + let mut wal = Wal::open(dir.path()).unwrap(); + wal.append(b"before close").unwrap(); + } + + let wal = Wal::open(dir.path()).unwrap(); + assert_eq!(wal.next_offset(), 1); +} + +#[test] +fn segment_roll() { + let dir = TempDir::new().unwrap(); + + // Create workspace with default segment size, then patch manifest. + { + let wal = Wal::open(dir.path()).unwrap(); + drop(wal); + } + + // Patch manifest on disk to use a tiny segment size. + let manifest_path = dir.path().join("events/manifest.dat"); + let content = std::fs::read_to_string(&manifest_path).unwrap(); + let patched = content.replace("segment_size=134217728", "segment_size=4196"); + std::fs::write(&manifest_path, patched).unwrap(); + + let mut wal = Wal::open(dir.path()).unwrap(); + let payload = vec![0u8; 60]; + for _ in 0..5 { + wal.append(&payload).unwrap(); + } + + // After 5 appends with tiny segments, at least one roll should have happened. + // next_offset should be 5. + assert_eq!(wal.next_offset(), 5); +} + +#[test] +fn recovery_with_snapshot() { + let dir = TempDir::new().unwrap(); + + { + let mut wal = Wal::open(dir.path()).unwrap(); + wal.append(b"e0").unwrap(); + wal.append(b"e1").unwrap(); + wal.checkpoint(2, b"state-at-2").unwrap(); + wal.append(b"e2").unwrap(); + wal.append(b"e3").unwrap(); + } + + let wal = Wal::open(dir.path()).unwrap(); + + let (snap_offset, snap_state) = wal.load().unwrap().unwrap(); + assert_eq!(snap_offset, 2); + assert_eq!(snap_state, b"state-at-2"); +} + +#[test] +fn last_offset() { + let dir = TempDir::new().unwrap(); + let wal = Wal::open(dir.path()).unwrap(); + assert_eq!(wal.last_offset(), None); + + let mut wal = Wal::open(dir.path()).unwrap(); + wal.append(b"x").unwrap(); + assert_eq!(wal.last_offset(), Some(0)); + assert_eq!(wal.next_offset(), 1); +} + +#[test] +fn next_offset() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + assert_eq!(wal.next_offset(), 0); + wal.append(b"a").unwrap(); + assert_eq!(wal.next_offset(), 1); + wal.append(b"b").unwrap(); + assert_eq!(wal.next_offset(), 2); +} + +// -- replay tests -- + +#[test] +fn replay_from_zero() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + wal.append(b"a").unwrap(); + wal.append(b"b").unwrap(); + wal.append(b"c").unwrap(); + + let items: Vec<_> = wal + .replay(0) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + + assert_eq!(items.len(), 3); + assert_eq!(items[0], (0, b"a".to_vec())); + assert_eq!(items[1], (1, b"b".to_vec())); + assert_eq!(items[2], (2, b"c".to_vec())); +} + +#[test] +fn replay_from_middle() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + wal.append(b"a").unwrap(); + wal.append(b"b").unwrap(); + wal.append(b"c").unwrap(); + + let items: Vec<_> = wal + .replay(1) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + + assert_eq!(items.len(), 2); + assert_eq!(items[0], (1, b"b".to_vec())); + assert_eq!(items[1], (2, b"c".to_vec())); +} + +#[test] +fn replay_across_segments() { + let dir = TempDir::new().unwrap(); + + // Create workspace with tiny segments to force rolls. + { + let wal = Wal::open(dir.path()).unwrap(); + drop(wal); + } + + let manifest_path = dir.path().join("events/manifest.dat"); + let content = std::fs::read_to_string(&manifest_path).unwrap(); + let patched = content.replace("segment_size=134217728", "segment_size=4196"); + std::fs::write(&manifest_path, patched).unwrap(); + + let mut wal = Wal::open(dir.path()).unwrap(); + let payload = vec![0u8; 60]; + for _ in 0..5 { + wal.append(&payload).unwrap(); + } + + let items: Vec<_> = wal + .replay(0) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + assert_eq!(items.len(), 5); + for (i, (offset, data)) in items.iter().enumerate() { + assert_eq!(*offset, i as u64); + assert_eq!(data, &payload); + } +} + +#[test] +fn replay_with_snapshot_recovery() { + let dir = TempDir::new().unwrap(); + + { + let mut wal = Wal::open(dir.path()).unwrap(); + wal.append(b"e0").unwrap(); + wal.append(b"e1").unwrap(); + wal.checkpoint(2, b"state-at-2").unwrap(); + wal.append(b"e2").unwrap(); + wal.append(b"e3").unwrap(); + } + + let wal = Wal::open(dir.path()).unwrap(); + + let (snap_offset, snap_state) = wal.load().unwrap().unwrap(); + assert_eq!(snap_offset, 2); + assert_eq!(snap_state, b"state-at-2"); + + // Replay from the snapshot offset to recover post-snapshot items. + let items: Vec<_> = wal + .replay(snap_offset) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0], (2, b"e2".to_vec())); + assert_eq!(items[1], (3, b"e3".to_vec())); +} + +#[test] +fn replay_empty_wal() { + let dir = TempDir::new().unwrap(); + let wal = Wal::open(dir.path()).unwrap(); + + let items: Vec<_> = wal + .replay(0) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + assert!(items.is_empty()); +} + +// -- checkpoint monotonicity -- + +#[test] +fn checkpoint_offset_must_not_decrease() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + wal.append(b"e0").unwrap(); + wal.append(b"e1").unwrap(); + wal.checkpoint(2, b"state-v1").unwrap(); + + // Checkpoint at a lower offset should fail. + let err = wal.checkpoint(1, b"state-v0").unwrap_err(); + assert!( + err.to_string().contains("must not decrease"), + "expected monotonicity error, got: {err}" + ); +} + +#[test] +fn checkpoint_same_offset_is_ok() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + wal.append(b"e0").unwrap(); + wal.checkpoint(1, b"state-v1").unwrap(); + + // Re-checkpoint at the same offset is allowed (overwrite). + wal.checkpoint(1, b"state-v1-updated").unwrap(); + + let (_, state) = wal.load().unwrap().unwrap(); + assert_eq!(state, b"state-v1-updated"); +} + +// -- crash safety / recovery -- + +#[test] +fn missing_segment_returns_error() { + let dir = TempDir::new().unwrap(); + + // Create a workspace with data. + { + let mut wal = Wal::open(dir.path()).unwrap(); + wal.append(b"data").unwrap(); + } + + // Delete the segment file to simulate a crash that lost it. + let seg_path = dir + .path() + .join("events") + .join("segments") + .join("0000-0999") + .join("000001.seg"); + assert!(seg_path.exists()); + std::fs::remove_file(&seg_path).unwrap(); + + let result = Wal::open(dir.path()); + let err = result.err().expect("expected SegmentNotFound error"); + match err { + WalError::SegmentNotFound { segment_id } => assert_eq!(segment_id, 1), + other => panic!("expected SegmentNotFound, got: {other}"), + } +} + +#[test] +fn recovery_from_already_closed_segment() { + let dir = TempDir::new().unwrap(); + + // Simulate the crash scenario: manifest points at segment N, + // but segment N is already closed (has TAIL_MAGIC). + // This happens when rotate() committed the manifest but + // crashed before fully completing. + { + let mut wal = Wal::open(dir.path()).unwrap(); + wal.append(b"item-0").unwrap(); + wal.append(b"item-1").unwrap(); + + // Force a close of the segment by dropping, then manually + // add TAIL_MAGIC + update count to simulate a closed segment. + // Actually, let's use the real rotate path with tiny segments. + } + + // Use tiny segments to force rotation. + let manifest_path = dir.path().join("events/manifest.dat"); + let content = std::fs::read_to_string(&manifest_path).unwrap(); + let patched = content.replace("segment_size=134217728", "segment_size=4196"); + std::fs::write(&manifest_path, patched).unwrap(); + + { + let mut wal = Wal::open(dir.path()).unwrap(); + let payload = vec![0u8; 60]; + // Force at least one rotation. + for _ in 0..5 { + wal.append(&payload).unwrap(); + } + // Now segment 1 is closed and segment 2 is active. + // Simulate: crash after manifest committed to seg 2 but + // before the old segment's index was saved. + // (This is already the normal state after rotate.) + } + + // Reopening should work — segment 1 is a closed segment, + // segment 2 is the active one. + let wal = Wal::open(dir.path()).unwrap(); + assert_eq!(wal.next_offset(), 7); // 2 from seg 1 + 5 from seg 2 +} + +#[test] +fn checkpoint_future_offset_rejected() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + wal.append(b"e0").unwrap(); + // next_offset is 1; checkpoint at 5 should fail. + let err = wal.checkpoint(5, b"future-state").unwrap_err(); + match err { + WalError::OffsetOutOfRange { requested, next } => { + assert_eq!(requested, 5); + assert_eq!(next, 1); + } + other => panic!("expected OffsetOutOfRange, got: {other}"), + } +} + +#[test] +fn load_missing_snapshot_returns_error() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + wal.append(b"e0").unwrap(); + wal.checkpoint(1, b"state").unwrap(); + + // Delete the snapshot file to simulate data loss. + let snap_path = dir + .path() + .join("snapshots") + .join("0000-0999") + .join("ckpt-000001.dat"); + assert!(snap_path.exists()); + std::fs::remove_file(&snap_path).unwrap(); + + // Re-open: manifest says snapshot at offset 1 but file is missing. + let wal = Wal::open(dir.path()).unwrap(); + let result = wal.load(); + assert!(result.is_err()); + match result.unwrap_err() { + WalError::SnapshotNotFound => {} + other => panic!("expected SnapshotNotFound, got: {other}"), + } +} + +#[test] +fn open_creates_dir_fsync() { + // Smoke test: open creates workspace and all dir fsyncs succeed. + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + wal.append(b"data").unwrap(); + wal.checkpoint(1, b"state").unwrap(); + drop(wal); + + // Reopen to verify durability. + let wal = Wal::open(dir.path()).unwrap(); + assert_eq!(wal.next_offset(), 1); + assert_eq!(wal.last_offset(), Some(0)); +} + +// -- append_batch tests -- + +#[test] +fn append_batch_basic() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + let results = wal.append_batch(&[b"one", b"two", b"three"]).unwrap(); + assert_eq!(results.len(), 3); + assert_eq!(results[0].0, 0); + assert_eq!(results[1].0, 1); + assert_eq!(results[2].0, 2); + assert_eq!(wal.next_offset(), 3); + + let items: Vec<_> = wal + .replay(0) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + assert_eq!(items.len(), 3); + assert_eq!(items[0], (0, b"one".to_vec())); + assert_eq!(items[1], (1, b"two".to_vec())); + assert_eq!(items[2], (2, b"three".to_vec())); +} + +#[test] +fn append_batch_empty() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + let results = wal.append_batch(&[]).unwrap(); + assert!(results.is_empty()); + assert_eq!(wal.next_offset(), 0); +} + +#[test] +fn append_batch_persists_across_reopen() { + let dir = TempDir::new().unwrap(); + + { + let mut wal = Wal::open(dir.path()).unwrap(); + wal.append_batch(&[b"a", b"b", b"c", b"d"]).unwrap(); + } + + let wal = Wal::open(dir.path()).unwrap(); + assert_eq!(wal.next_offset(), 4); + + let items: Vec<_> = wal + .replay(0) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + assert_eq!(items.len(), 4); +} + +#[test] +fn append_batch_interleaved_with_append() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + wal.append(b"solo1").unwrap(); + wal.append_batch(&[b"batch1", b"batch2"]).unwrap(); + wal.append(b"solo2").unwrap(); + + let items: Vec<_> = wal + .replay(0) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + assert_eq!(items.len(), 4); + assert_eq!(items[0].1, b"solo1"); + assert_eq!(items[1].1, b"batch1"); + assert_eq!(items[2].1, b"batch2"); + assert_eq!(items[3].1, b"solo2"); +} + +// -- append_unsynced + sync tests -- + +#[test] +fn append_unsynced_then_sync() { + let dir = TempDir::new().unwrap(); + let mut wal = Wal::open(dir.path()).unwrap(); + + wal.append_unsynced(b"unsynced1").unwrap(); + wal.append_unsynced(b"unsynced2").unwrap(); + wal.sync().unwrap(); + + let items: Vec<_> = wal + .replay(0) + .unwrap() + .collect::<Result<Vec<_>, _>>() + .unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[0].1, b"unsynced1"); + assert_eq!(items[1].1, b"unsynced2"); +} + +#[test] +fn append_unsynced_persists_after_sync_and_reopen() { + let dir = TempDir::new().unwrap(); + + { + let mut wal = Wal::open(dir.path()).unwrap(); + wal.append_unsynced(b"x").unwrap(); + wal.append_unsynced(b"y").unwrap(); + wal.sync().unwrap(); + } + + let wal = Wal::open(dir.path()).unwrap(); + assert_eq!(wal.next_offset(), 2); +} diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 1283dfeb..f90ee8fe 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -2,6 +2,7 @@ name = "utils" version = "0.1.0" edition = "2024" +publish = false [dependencies] serde.workspace = true diff --git a/crates/utils/src/id.rs b/crates/utils/src/id.rs index 274023d5..1caa5d6e 100644 --- a/crates/utils/src/id.rs +++ b/crates/utils/src/id.rs @@ -95,9 +95,8 @@ impl std::error::Error for IdError {} define_id!(GraphId, "graph_"); define_id!(AcceleratorId, "accel_"); +define_id!(MachineId, "rcm_"); define_id!(FluxId, "flux_"); define_id!(ConditionId, "cond_"); -define_id!(AssemblyId, "asm_"); -define_id!(SlotId, "slot_"); define_id!(EnvironmentId, "env_"); define_id!(ResourcesId, "res_"); diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 54b20066..0d9514c0 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -1,7 +1,5 @@ mod id; mod name; -pub use id::{ - AcceleratorId, AssemblyId, ConditionId, EnvironmentId, FluxId, GraphId, ResourcesId, SlotId, -}; +pub use id::{AcceleratorId, ConditionId, EnvironmentId, FluxId, GraphId, MachineId, ResourcesId}; pub use name::{Name, NameError}; diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..1a846d0e --- /dev/null +++ b/deny.toml @@ -0,0 +1,56 @@ +[graph] +all-features = true + +[advisories] +yanked = "deny" +unmaintained = "workspace" +unsound = "workspace" +unused-ignored-advisory = "deny" +ignore = [] + +[licenses] +confidence-threshold = 0.8 +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "Unicode-3.0", + "Unlicense", +] +exceptions = [ + { allow = ["MPL-2.0"], crate = "cssparser" }, + { allow = ["MPL-2.0"], crate = "cssparser-macros" }, + { allow = ["MPL-2.0"], crate = "dtoa-short" }, + { allow = ["MPL-2.0"], crate = "selectors" }, +] + +[licenses.private] +ignore = true + +[bans] +multiple-versions = "warn" +wildcards = "deny" +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" +allow-workspace = true +allow = [] +deny = [ + { crate = "openssl", reason = "RCM standardizes on rustls/aws-lc for TLS unless explicitly approved." }, + { crate = "openssl-sys", reason = "RCM standardizes on rustls/aws-lc for TLS unless explicitly approved." }, + { crate = "native-tls", reason = "RCM standardizes on rustls/aws-lc for TLS unless explicitly approved." }, +] +skip = [] +skip-tree = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/doc/mcp.md b/doc/mcp.md deleted file mode 100644 index 3f8dbf66..00000000 --- a/doc/mcp.md +++ /dev/null @@ -1,208 +0,0 @@ -# MCP Server 配置 - -MCP (Model Context Protocol) 让 agent 能通过标准化的对外接口调用外部工具——搜索、查数据库、操作文件系统等。RCM 通过 `--mcp-server` 参数配置,支持 stdio 子进程和 Streamable HTTP 两种传输方式。 - -## 快速开始 - -```bash -# Stdio 子进程(MCPWorld 上最常见的类型) -accelerate run "抓取这个网页内容" \ - --mcp-server "fetch=uvx mcp-server-fetch" - -# HTTP 远程服务器(如 AnySearch 搜索) -accelerate run "搜索量子计算最新进展" \ - --mcp-server "search=https://api.anysearch.com/mcp|Authorization:Bearer <api-key>" -``` - ---- - -## 从标准配置到 RCM CLI - -大多数 MCP 服务器在文档中展示的都是 Claude Desktop 的 JSON 配置格式。映射到 RCM 很简单:`command` + `args` 用空格拼起来就行。 - -### Basic — uvx - -```json -{ - "mcpServers": { - "fetch": { - "command": "uvx", - "args": ["mcp-server-fetch"] - } - } -} -``` - -```bash ---mcp-server "fetch=uvx mcp-server-fetch" -``` - -### npx - -```json -{ - "mcpServers": { - "github": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"] - } - } -} -``` - -```bash ---mcp-server "github=npx -y @modelcontextprotocol/server-github" -``` - -### Python - -```json -{ - "mcpServers": { - "fetch": { - "command": "python", - "args": ["-m", "mcp_server_fetch"] - } - } -} -``` - -```bash ---mcp-server "fetch=python -m mcp_server_fetch" -``` - -### Docker - -```json -{ - "mcpServers": { - "fetch": { - "command": "docker", - "args": ["run", "-i", "--rm", "mcp/fetch"] - } - } -} -``` - -```bash ---mcp-server "fetch=docker run -i --rm mcp/fetch" -``` - -### HTTP 远程(带自定义头部) - -```json -{ - "mcpServers": { - "anysearch": { - "type": "streamable-http", - "url": "https://api.anysearch.com/mcp", - "headers": { - "Authorization": "Bearer <api-key>" - } - } - } -} -``` - -```bash ---mcp-server "anysearch=https://api.anysearch.com/mcp|Authorization:Bearer <api-key>" -``` - ---- - -## 语法 - -``` ---mcp-server "label=command arg1 arg2" ---mcp-server "label=https://url|HeaderName:Value" -``` - -| 部分 | 说明 | -|------|------| -| `label=` | 服务标识名,用于日志和调试 | -| `command` | 子进程可执行文件(`uvx`、`npx`、`python`、`docker` 等) | -| `arg1 arg2` | 传给子进程的参数,**空格分隔** | -| `https://url` | HTTP MCP 端点(以 `http://` 或 `https://` 开头) | -| `HeaderName:Value` | HTTP 请求头,多个用 `|` 分隔 | - -> **为什么用 `|` 而不是 `,`?** URL 和 HTTP header value 中可能含有逗号和冒号,用管道符可以避免歧义。 - -## 传输方式 - -`--mcp-server` 的值以 `http://` 或 `https://` 开头 → **HTTP**,否则 → **Stdio**。 - -| 传输方式 | 自动判断条件 | 适用场景 | -|----------|-------------|---------| -| **Stdio** | 值以 `http://` 或 `https://` 开头 | 本地 MCP 服务器(uvx / npx / python / docker) | -| **HTTP** | 值以 `http://` 或 `https://` 开头 | 远程 API 服务(AnySearch 等) | - ---- - -## 示例 - -### Stdio 子进程 - -```bash -# uvx ---mcp-server "fetch=uvx mcp-server-fetch" - -# npx ---mcp-server "github=npx -y @modelcontextprotocol/server-github" - -# python ---mcp-server "fetch=python -m mcp_server_fetch --ignore-robots-txt" - -# docker ---mcp-server "fetch=docker run -i --rm mcp/fetch" -``` - -### 多个服务器混用 - -```bash -accelerate run "对比 GPU 价格并搜索相关讨论" \ - --mcp-server "fetch=uvx mcp-server-fetch" \ - --mcp-server "github=npx -y @modelcontextprotocol/server-github" -``` - -### HTTP 远程(带认证) - -```bash -accelerate run "搜索量子计算" \ - --mcp-server "search=https://api.anysearch.com/mcp|Authorization:Bearer as_sk_xxxxxxxxxxxx" -``` - -### HTTP 多头部 - -```bash -accelerate run "带自定义追踪ID的查询" \ - --mcp-server "api=https://api.example.com/mcp|Authorization:Bearer <key>|X-Trace-Id:abc-123" -``` - ---- - -## 工作原理 - -启动时,RCM 依次: - -1. **连接** — 根据格式创建 HTTP 客户端或启动子进程 -2. **握手** — 发送 `initialize` 请求,协商协议版本 -3. **发现工具** — 调用 `tools/list` 获取服务端提供的所有工具列表 -4. **注册** — 将每个工具包装为 `machine::Tool`,注入 agent 的可用工具池 -5. **执行** — agent 在对话中调用工具时,通过原有连接转发 `tools/call` 请求并返回结果 - -启动日志中会显示发现的工具: - -``` -INFO mcp: "search" server="search" tool_count=4 "MCP server ready" -DEBUG mcp: discovered tool server="search" tool="search" -DEBUG mcp: discovered tool server="search" tool="batch_search" -``` - ---- - -## 注意事项 - -- Stdio 子进程使用 `kill_on_drop(true)` 管理生命周期,进程随 agent 退出而终止 -- Authorization header 等敏感信息会随每个请求发送,确保使用 HTTPS -- Header value 中如果包含 `|` 字符,当前不支持转义——可用环境变量注入 -- 多个同 label 的服务器不会报错,但工具名会合并,后注册的工具覆盖同名的已有工具 diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 00000000..9e61d3fa --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md — Documentation standard + +This file defines document structure, Markdown tiers, writing rules, and the hygiene checklist for this repository. Use the `repo-review` skill for semantic review and the `repo-decisions` skill for decision records. + +## Document structure + +Every human-facing document belongs to a tier. A document's subject and tree position fix its scope: describe its own subject at appropriate detail, direct children only by purpose and responsibility, and link to the owning descendant for lower-level detail. Testing mechanisms belong at the lowest owning level. + +### The tier taxonomy: one home per fact + +Each fact has one home; everywhere else, link there. + +| Tier | Job | Does NOT belong there | +|---|---|---| +| Root `AGENTS.md` | Management identity, skill router, and standing orders needed in every session | Stories, worked examples, restated procedures | +| `docs/AGENTS.md` | The documentation standard | Repo-wide rules the root file carries | +| `docs/architecture.md` | Ordered map: composition, modules, seams | Per-module detail, decision rationale | +| `docs/development.md` | Contributor setup, daily workflow, commands | Runtime rationale | +| `docs/testing.md` | Test mission, risk-to-layer selection, topology, evidence, maintenance budget | Per-case walkthroughs or drifting command inventories | +| `docs/specs/` | Risk-triggered Intent, Contract, Plan, Verification, and Evidence for a change | Durable alternative rationale or routine-change ceremony | +| `docs/decisions/` | Durable choices with genuine alternatives (MADR + Class extension) | Change logs, implementation plans, product contracts | +| `docs/postmortems/` | Incident write-ups linked to permanent guardrails (the only war-story tier) | — | +| In-repo skills | Reusable workflows and decision procedures | Product contracts | + +Placement: change contract → specs; durable alternative rationale → decisions; procedures → skills or development; incident stories → postmortems; standing orders → root `AGENTS.md` with a link. + +## Tutorial or reference + +Classify every in-scope document as a tutorial or a reference. A tutorial follows an ordered path to an outcome. A reference defines a lookup scope and current behavior without a teaching sequence. Separate substantial mixed forms; label a small secondary form clearly. + +## Writing rules + +- Document current state, not change history. Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. +- One physical line per paragraph. +- State complete contracts, not reasoning transcripts. +- Cross-reference with machine-checkable relative Markdown links. Links must resolve. +- No fill-in tokens may remain in any doc that ships. +- Comments and JSDoc state contracts or provenance; do not restate code. + +## Hygiene checklist + +Hunt these in any doc: + +- The same rule stated in more than one home. Keep one home and link the rest. +- Narrated history or war stories. +- Implementation-status annotations ("implemented!", "future: …"). Status rots. +- Hand-restated catalogs or inventories when source or a generator is authoritative. +- Reasoning transcripts: step-by-step narration, test walkthroughs, rejected alternatives. +- Rationale repeated beside sibling methods instead of once at the owning place. +- Paragraph walls. Split or demote. +- Emphasis inflation. Reserve emphasis for the clause that changes behavior. +- Spec-speak in shipped decisions ("should", migration plans, acceptance checklists). + +## Budget + +Root `AGENTS.md` has a soft budget of 100 lines. When it grows past that, relocate content to its tier home and leave a one-line link. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..63f79895 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,22 @@ +# Architecture + +This document is the ordered map of the repository: composition, modules, seams, and extension points. Read it before changing structure. Per-module detail lives in the owning module's own documentation; durable decision rationale lives in [docs/decisions/](../docs/decisions/). + +The Rust workspace is composed of six crates: machine owns context, model, policy, reactor, tools, and execution; accelerator composes graphs, policies, provider/model resolution, tools, LSP, MCP, and trajectory handling; storage persists WAL-backed sessions; cli exposes the accelerate binary and RCM parser; server exposes the gRPC API; utils provides shared identifiers and naming. The Python SDK under sdks/python mirrors the gRPC contract. + +Cross-crate seams are the machine API, accelerator graph and tool registries, storage snapshots, the CLI parser/compiler boundary, the protobuf service in proto/rcm.proto, and the SDK generated bindings. + +## Conventions + +- New behavior goes on documented extension points; a decision record is required when a core-flow change chooses among meaningful alternatives. +- Explicit > implicit at boundaries: defaulting is an explicit step in the owning implementation, never a hidden fallback. +- Registrations are effects: every contribution goes through the owning registry and returns a disposer. +- Opaque cross-boundary identifiers are branded types, never bare strings. + +## Change procedure + +1. Trace the current owners of the flow you change. +2. For a risk-boundary change, obtain an Approved spec in [docs/specs/](../docs/specs/). +3. Record durable design choices in [docs/decisions/](../docs/decisions/) when genuine alternatives exist. +4. Update this document and the owning module docs in the same change. +5. Run the gates; add the smallest sufficient evidence for the behavior and risk, not tests for line count. diff --git a/docs/decisions/0000-use-markdown-architectural-decision-records.md b/docs/decisions/0000-use-markdown-architectural-decision-records.md new file mode 100644 index 00000000..ba353255 --- /dev/null +++ b/docs/decisions/0000-use-markdown-architectural-decision-records.md @@ -0,0 +1,48 @@ +# Use Markdown Architectural Decision Records + +## Status +Accepted +Class: process + +## Context and Problem Statement +A repository's decisions — why a design or a process rule exists, what it beat, and what it gave up — are lost if they live only in commit messages or conversation. Without a decision log, the same decision gets re-litigated, and future maintainers cannot tell whether current structure is intentional or accidental. + +## Decision Drivers +- A single, industry-standard home for durable architectural and process choices with meaningful alternatives. +- Tooling compatibility: records should work with existing ADR tooling. +- Machine-checkable structure so drift can be caught by a verifier. + +## Considered Options +- MADR (Markdown Any Decision Records) in `docs/decisions/`. +- Nygard's original ADR format. +- A bespoke "notes" format with a custom lifecycle vocabulary. +- No decision log at all. + +## Decision Outcome +Adopt **MADR** as the format for durable choices with meaningful alternatives, stored flat in `docs/decisions/NNNN-title.md`, with a documented `Class:` extension line (architecture/process/testing/feature/bug-fix/simplification). Status values are the MADR-native set: Proposed, Accepted, Rejected, Deprecated, Superseded by NNNN. The lifecycle and linking discipline — a superseded record is never rewritten into its opposite, a new record supersedes it — follow the MADR convention. Specs own risk-triggered change contracts; commits own change history. `scripts/verify-decisions.mjs` enforces naming, numbering, sections, status, class, and supersede links. + +### Consequences +- Good: one place for durable rationale; existing MADR tooling can parse the files; drift is caught mechanically. +- Good: the `Class:` extension is ignored by tools that do not know it, so compatibility is preserved. +- Trade-off: a small format overhead per decision compared to free-form notes; accepted because the verifier makes the format cheap. + +## Pros and Cons of the Options +### MADR +- Good: standard, documented, tooling-friendly. +- Good: `## Status` at the top makes lifecycle explicit. +- Neutral: requires a few required sections; the verifier keeps them honest. + +### Nygard format +- Good: the original, widely known. +- Bad: looser section conventions; less uniform to verify. + +### Bespoke notes format +- Good: fully tailored vocabulary. +- Bad: no ecosystem compatibility; reinvents a wheel the industry already standardized. + +### No decision log +- Bad: decisions live only in memory and commit history; re-litigation is guaranteed. + +## Links +- [ADR 0001](0001-repo-seed-is-a-skill-not-a-template.md) — why this repository exists as a skill. +- [ADR 0002](0002-self-governing-repository-design.md) — the five-layer governance design this log protects. diff --git a/docs/decisions/0001-repo-seed-is-a-skill-not-a-template.md b/docs/decisions/0001-repo-seed-is-a-skill-not-a-template.md new file mode 100644 index 00000000..397dc3cd --- /dev/null +++ b/docs/decisions/0001-repo-seed-is-a-skill-not-a-template.md @@ -0,0 +1,49 @@ +# repo-seed is a skill that seeds a self-governing repository, not a static template + +## Status +Accepted +Class: architecture + +## Context and Problem Statement +The goal is to let any repository — empty or existing, any technology stack — become agent-native: documented, decision-logged, and governed by mechanical checks, ready for vibe coding. Two shapes were on the table: a static template repository that users fork or clone, or a skill that generates and maintains the governance layer in place. + +Static templates have a known fatal flaw: a fork is a single-commit snapshot with no upstream history, so it can never receive template improvements, and it cannot adapt to the target repository's actual state. Pure runtime skills have a different flaw: skill triggering is unreliable (Vercel's focused Next.js evals found that an available skill was never invoked in 56% of cases), and generated-but-not-committed output is invisible and unversioned. + +## Decision Drivers +- The generated repository must stay upgradeable: upstream governance improvements must reach existing seeded repositories. +- The generated repository must be deterministic where structure matters, and adaptive where content matters. +- The user must see and own the result: generated files are committed baselines, not ephemeral context. +- The industry-standard shapes (AGENTS.md, SKILL.md, MADR) must be reused, not reinvented. + +## Considered Options +- A static template repository (fork-to-start). +- A pure runtime skill whose output never lands in the repository. +- A skill that scaffolds deterministic structure and delegates content to the model, with an ownership manifest for updates — the chosen route. + +## Decision Outcome +repo-seed is **one generator skill** (SKILL.md + zero-dependency scripts + templates under `references/`). It writes an approved governance baseline into the target repository (resident instructions, docs, Specs, decision/postmortem memory, three in-repo skills, gates, and `.repo-seed/manifest.json`), which the user reviews and commits. Hooks are a separately authorized capability. Re-running the skill is the upgrade channel: the manifest records ownership, capabilities, governance paths, and external sources; files the user changed are preserved and template evolution applies only to untouched files. + +### Consequences +- Good: seeded repositories remain upgradeable — the "dead template" failure mode is structurally removed. +- Good: the user owns the result because it is committed baseline, not ephemeral context. +- Good: the generator is a single explicit-invocation skill, the shape where skills are most reliable. +- Trade-off: the generator needs a state file (`.repo-seed/manifest.json`) and update semantics; the complexity is contained in one scripted path. + +## Pros and Cons of the Options +### Static template repository +- Good: instant, deterministic, zero runtime. +- Bad: fork cannot receive upstream updates; cannot adapt to repository state; no question-based customization. + +### Pure runtime skill, no persisted output +- Good: cheap to trigger; no repository pollution until invoked. +- Bad: unreliable triggering; invisible, unversioned output; cannot be reviewed as a diff. + +### Skill generator with persisted baseline and ownership manifest +- Good: deterministic structure, adaptive content, upgradeable baseline, user-owned result. +- Bad: requires manifest maintenance and update-mode logic. + +## Links +- [ADR 0000](0000-use-markdown-architectural-decision-records.md) — the decision log this record lives in. +- [ADR 0002](0002-self-governing-repository-design.md) — what the generated baseline contains. +- [.repo-seed/update-strategy.md](../../.repo-seed/update-strategy.md) — the ownership/update semantics. +- [Vercel: “AGENTS.md outperforms skills in our agent evals”](https://vercel.com/blog/agents-md-outperforms-skills-in-our-agent-evals) — source for the 56% non-invocation result. diff --git a/docs/decisions/0002-self-governing-repository-design.md b/docs/decisions/0002-self-governing-repository-design.md new file mode 100644 index 00000000..a58b882a --- /dev/null +++ b/docs/decisions/0002-self-governing-repository-design.md @@ -0,0 +1,59 @@ +# Self-governing repository design + +## Status +Accepted +Class: architecture + +## Context and Problem Statement +A generated repository that is "documented" but ungoverned will drift: links rot, placeholder text survives, decisions get re-litigated, seeded files get edited silently, and process steps get skipped. Documentation alone does not prevent drift; layered instruction, distinct durable artifacts, deterministic gates, and a feedback loop form one governance system. + +The open question: how much of that system should a seed repository get on day one, and in what shape, without coupling the seed to any one technology stack or any one vendor's tooling? + +## Decision Drivers +- Objective anti-drift rules must be mechanical through verifiers and a shared runner. +- The seed must be technology-stack agnostic: no generated build config, no generated CI. +- The seed must be cross-tool for agents: AGENTS.md and SKILL.md are the industry standards; no vendor-private extensions. +- Day-one scope must be a governance skeleton, not a full harness machine; the upgrade channel grows it. +- Read-only assessment may be autonomous; changing governance or local/external state requires user authority. + +## Considered Options +- Single-layer governance: one large AGENTS.md carrying everything, no gates. +- Harness-scale day one: port all 35+ gates, i18n pairing, coverage gates, vendoring policy. +- Five-layer governance skeleton plus an upgrade channel — the chosen route. + +## Decision Outcome +The generated repository is governed by **five layers plus an upgrade channel**: + +- **L0 — resident instructions**: root `AGENTS.md` (soft budget of 100 lines) plus `CLAUDE.md` that imports it via `@AGENTS.md`. Carries the governance loop hard rules and the security rules (never commit/push without explicit request). +- **L1 — deterministic gates**: the manifest-selected `scripts/run-gates.mjs` executes Spec, decision, postmortem, link, placeholder, and ownership verifiers. CI and an optional authorized hook call the same runner. +- **L2 — change memory**: risk-boundary work uses an Approved Spec; MADR records preserve durable alternatives; postmortems link escaped failures to permanent guardrails. +- **L3 — in-repo skills**: `repo-review`, `repo-decisions`, and `repo-governance` live in `.agents/skills/`; review policy is instantiated per project. +- **L4 — process evidence**: testing policy, Spec Evidence, postmortem guardrails, and PR review findings outlive an agent session. +- **Upgrade channel**: `.repo-seed/manifest.json` records ownership, capability state, governance paths, external sources, and progressive artifact policy; re-running refreshes untouched files, preserves user edits, and never deletes user files. + +### Consequences +- Good: drift and path-level hallucination are caught mechanically through one installed gate set. +- Good: the seed is stack-agnostic and cross-tool; it adds no build configuration. +- Good: day-one scope is a skeleton; optional capabilities are assessed and added later when facts justify their ongoing cost. +- Good: local hooks, CI, policy changes, and external connections retain an explicit human authority boundary. +- Trade-off: semantic correctness (does the prose say something true?) cannot be proven by gates; `repo-review` and the decision log's "what we gave up" discipline mitigate but cannot eliminate it. This is an industry-wide limit, not a repo-seed gap. + +## Pros and Cons of the Options +### Single-layer governance +- Good: simplest possible seed. +- Bad: resident instructions have bounded capacity (context limits); no mechanical verification; process steps decay. + +### Harness-scale day one +- Good: maximal rigor from the start. +- Bad: disproportionate for a new repository; couples the seed to a specific toolchain; violates the stack-agnostic constraint. + +### Five-layer skeleton plus upgrade channel +- Good: mechanical anti-drift where it matters most; stack-agnostic; grows with the repository. +- Good: honest about the semantic-correctness limit. +- Bad: requires manifest/update machinery from day one. + +## Links +- [ADR 0000](0000-use-markdown-architectural-decision-records.md) — the decision log standard. +- [ADR 0001](0001-repo-seed-is-a-skill-not-a-template.md) — why the generator shape is a skill. +- [.repo-seed/update-strategy.md](../../.repo-seed/update-strategy.md) — ownership and update semantics. +- [docs/AGENTS.md](../AGENTS.md) — the documentation standard (L0/L2 discipline). diff --git a/docs/decisions/0003-repo-review-instantiated-per-project.md b/docs/decisions/0003-repo-review-instantiated-per-project.md new file mode 100644 index 00000000..26b27bef --- /dev/null +++ b/docs/decisions/0003-repo-review-instantiated-per-project.md @@ -0,0 +1,48 @@ +# repo-review is instantiated per project, not shipped statically + +## Status +Accepted +Class: architecture + +## Context and Problem Statement +The seed ships resident procedure skills plus repo-review, a project policy. A static review policy generalizes poorly across stacks, carries no project red lines, and breaks the generator principle ADR 0001 established — structure deterministic, content instantiated. Every other content-bearing seeded doc (architecture.md, testing.md) is instantiated with tokens; repo-review needs the same treatment. + +## Decision Drivers +- Review policy is project-specific; its value is specificity, not uniformity. +- The seed must stay stack-agnostic: no checks that presume one stack family. +- Existing machinery (verify-placeholders scope, manifest refusal logic) already enforces instantiation once a template carries tokens. +- Procedure and policy skills differ: a procedure (how to write an ADR) is bound to the seed's own standard and can ship static; a policy (what must be checked here) cannot. + +## Considered Options +- Static repo-review for every project (status quo). +- Fully generic repo-review with no project content. +- Universal core plus per-project instantiation with a derivation standard — the chosen route. + +## Decision Outcome +The repo-review template carries a universal core (semantic prose review, docs match code, correct Spec/decision use, tests, and provenance) plus two instantiation tokens — REVIEW_PROJECT_BLOCKING and REVIEW_PROJECT_CHECKS — that the model composes at seed time from AGENTS.md hard rules, architecture seams, stack detection, and interview Q8. The repo-seed skill carries a derivation standard (`references/review-standard.md`) with a per-stack risk catalog; it is skill-side guidance and is not seeded into target repositories. The record step marks the instantiated file's manifest entry `userModified` via `--user-owned`, and a re-run never refreshes a user-owned entry. Machine-provable gate results stay out of semantic review; blocking requirements block while manual checks rank remaining risk. + +### Consequences +- Good: seeded repositories get a review policy that names their actual failure modes and red lines. +- Good: the placeholder gate mechanically prevents an un-instantiated repo-review from shipping. +- Good: upstream evolution still applies to the universal core; project content is protected. +- Trade-off: instantiation quality depends on the executing model, mitigated by the derivation standard and the universal-core floor. +- Trade-off: project-specific content no longer refreshes from upstream, by design. + +## Pros and Cons of the Options +### Static repo-review (status quo) +- Good: zero instantiation cost; upstream-editable. +- Bad: stack-biased checks; no project red lines; breaks the generator principle. + +### Fully generic repo-review +- Good: never wrong for any project. +- Bad: near-zero value; a reviewer needs project-specific gates to be useful. + +### Universal core plus per-project instantiation +- Good: specific where it matters, standard where it does not; existing gates enforce it. +- Bad: depends on model composition quality at seed time. + +## Links +- [ADR 0001](0001-repo-seed-is-a-skill-not-a-template.md) — the generator principle this restores for the review skill. +- [ADR 0002](0002-self-governing-repository-design.md) — the L3 layer this changes. +- [repo-review SKILL](../../.agents/skills/repo-review/SKILL.md) — the instantiated result. +- [.repo-seed/update-strategy.md](../../.repo-seed/update-strategy.md) — user-ownership and refresh semantics. diff --git a/docs/decisions/0004-release-from-primary-repository.md b/docs/decisions/0004-release-from-primary-repository.md new file mode 100644 index 00000000..ec6892a1 --- /dev/null +++ b/docs/decisions/0004-release-from-primary-repository.md @@ -0,0 +1,59 @@ +# Release artifacts live in the primary repository + +## Status +Accepted +Class: process + +## Context and Problem Statement + +RCM's source repository is public and already owns the version tags and release +workflow. A second `rcm-dist` repository duplicates release state, requires a +cross-repository token, and makes the latest binary differ from the source tag. +The repository name is now `recursive-context-machine`, so public installation and +release URLs can share one source of truth. + +## Decision Drivers + +- Release assets must be traceable to the exact source tag that built them. +- Main must remain protected and updated only after release checks succeed. +- Homebrew metadata needs the same release URLs and checksums as other installers. +- The workflow should not require a second repository or token. + +## Considered Options + +- Keep source releases in a separate `rcm-dist` repository. +- Publish assets in the primary repository while keeping Formula updates external. +- Publish assets and Formula from the primary repository, then promote the tagged + snapshot to `main` through an automated PR. + +## Decision Outcome + +The primary repository publishes the platform archives and `checksums.txt`, +generates `Formula/rcm.rb` from those hashes, and creates a `release/vX.Y.Z` PR to +`main`. Required CI and a release-only promotion check must pass before GitHub +auto-merges the PR. `rcm-dist` is archived as historical context and is not read by +the release workflow. + +## Pros and Cons of the Options + +### Separate distribution repository + +- Good: a tap-oriented repository can contain only distribution files. +- Bad: source, tags, assets, and Formula drift; a cross-repository token is needed. + +### Primary assets with external Formula + +- Good: source tags own the binary assets. +- Bad: installation metadata still has a second authority and update path. + +### Primary assets and Formula with promotion PR + +- Good: one repository owns source, release assets, checksums, Formula, and history. +- Good: protected main still receives only verified release snapshots. +- Trade-off: release automation owns a promotion branch and a small PR lifecycle. + +## Links + +- [Release migration spec](../specs/0000-primary-repository-release-migration.md) +- [Release workflow](../../.github/workflows/release-accelerate.yml) +- [Main promotion gate](../../.github/workflows/release-promotion.yml) diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 00000000..64744488 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,70 @@ +# Decision log + +Every durable choice with meaningful alternatives — architecture or process — lives in one place: this directory. Architecture and process decisions share the same format, lifecycle, and verifier. Specs own change contracts; commits own change history. + +## Format + +The format is **MADR (Markdown Any Decision Records)** with a documented `Class:` extension. Names and structure follow the industry standard so existing MADR tooling keeps working; the extension is ignored by tools that do not know it. + +A decision record is a file named `NNNN-title.md` (4-digit zero-padded number, `-`, kebab-case title) directly inside this directory. Numbers are assigned sequentially; the number is the record's identity and never changes. + +### Required sections + +Every record contains exactly these `##` sections, in this order: + +1. `## Status` +2. `## Context and Problem Statement` +3. `## Decision Drivers` +4. `## Considered Options` +5. `## Decision Outcome` +6. `## Pros and Cons of the Options` +7. `## Links` + +`## Links` may be empty ("None.") but must exist. Additional `##` sections may follow `## Links` when a record needs them. + +### Status line + +The first non-empty line under `## Status` is the status value. Valid values: + +- `Proposed` — the decision is being considered; not yet shipped. +- `Accepted` — the decision shipped; the record describes what is. +- `Rejected` — considered and declined; kept while its rationale prevents a tempting, meaningful mistake. +- `Deprecated` — no longer recommended; superseded by a linked record. +- `Superseded by [NNNN](NNNN-title.md)` — replaced by a newer record; the target must exist. + +A record whose status is `Accepted`, `Deprecated`, or `Superseded by …` describes current or frozen reality. It is never rewritten into a different decision: to change a decision, add a new record and mark the old one `Superseded by NNNN`. Both records stay, cross-linked. + +### Class line (extension) + +The line immediately after the status value may be `Class: <value>`. It classifies the decision: + +| Class | Covers | +|---|---| +| `architecture` | Structure of the shipped source: modules, boundaries, runtime vocabulary | +| `process` | Tooling, policy, workflow around the code: gates, package manager, conventions | +| `testing` | Test infrastructure and strategy | +| `feature` | A new user- or model-facing capability | +| `bug-fix` | Corrects a defect or closes a gap a postmortem surfaced | +| `simplification` | Removes code, behavior, or surface area without adding capability | + +A missing `Class:` line is valid (it is an extension); an invalid value is a violation. + +## Lifecycle + +A decision starts `Proposed`. Once implemented, its status becomes `Accepted` and the record is kept current with what actually shipped (facts only — names, paths, structure — not the decision itself). A declined proposal is `Rejected`; delete a rejected record only when its rationale no longer prevents a plausible mistake. An obsolete accepted decision becomes `Superseded by NNNN` or `Deprecated`; never edit it into its opposite. + +## Writing rules + +- Create or update a record when a change chooses among meaningful alternatives and future maintainers may reasonably revisit the rationale. Do not create a decision record for routine implementation, mechanical refactors, obvious fixes already defined by regression tests, or work that merely follows an accepted decision; risk-boundary feature behavior belongs in a spec and change history belongs in commits. +- State the decision, what it beats, and what it gives up. `## Considered Options` lists genuine alternatives; `## Pros and Cons of the Options` records why the losers lost. A decision without its alternatives invites re-litigation. +- When a record relies on external evidence or an implementation source, cite it descriptively in `## Links` with a stable URL, DOI, or versioned permalink. Do not leave research or quantitative claims unlinked. +- Cross-reference records with relative Markdown links (`[0001](0001-title.md)`), never bare numbers. +- Document current reality, not change history. Put change stories in commits; the decision record states the live contract. + +## Verifier + +`scripts/verify-decisions.mjs` enforces: file naming, unique sequential numbering, required sections, valid status values, valid `Class:` values when present, and `Superseded by NNNN` targets that exist. It exits non-zero on any violation. `scripts/run-gates.mjs` selects it from the manifest; an explicitly authorized managed hook calls the runner. + +## Index + +None yet. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..cf9c3a27 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,47 @@ +# Development + +English is the working language of this repository. This document covers contributor setup, daily workflow, and the commands that matter. Commands live in the root [AGENTS.md](../AGENTS.md); durable decision rationale lives in [docs/decisions](../docs/decisions/). + +## Prerequisites + +- Git +- Rust stable toolchain, Protocol Buffers compiler (protoc), and cargo-nextest. + +## Daily workflow + +1. Pull, branch, or stack per the repository's branching convention (see [CONTRIBUTING.md](../CONTRIBUTING.md)). +2. Make the change; update the applicable spec, durable decisions, docs, and the smallest sufficient behavior evidence in the same change. +3. Run the relevant gates: + ```sh + node scripts/run-gates.mjs + ``` + An authorized pre-commit capability enforces the same runner; run it early even when no hook is installed. +4. Run the test command: `cargo nextest run --workspace --locked` +5. Run the lint command: `cargo fmt --all -- --check && cargo clippy --workspace --all-targets --tests --locked -- -D warnings` +6. Commit with a message that states the why; include a decision record reference when one changed. + +## Source attribution + +When an implementation is materially derived from a paper, article, community post, benchmark, research report, or copied/adapted code, preserve that provenance at the closest stable repository location: + +- For a local algorithm, formula, constant, workaround, or behavior, add a nearby `Source:` comment with a descriptive title, a stable URL or DOI, and what the implementation derived from it. +- For a cross-cutting design, link the implementation entry point to the owning decision record and list the external sources in that record's `## Links` section. +- For generated, vendored, copied, or adapted material, retain the source header or metadata and satisfy the applicable copyright, license, and NOTICE requirements; a citation does not replace license compliance. +- For mutable community pages, include the relevant version or section and an access date when it helps future readers recover the cited evidence. + +Routine language idioms and standard-library usage do not need citations. A pull request, issue, prompt, or chat transcript may supplement repository provenance but is never its only home because it can become detached from the implementation. + +Example: + +```text +// Source: "Exponential Backoff and Jitter" — https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ +// Derived: full jitter prevents synchronized retries. +``` + +## Working tree + +Seeded governance files (AGENTS.md, CLAUDE.md, docs/, scripts/, .agents/skills/, .github/, CONTRIBUTING.md, LICENSE, .editorconfig, .gitattributes, .repo-seed/) are owned by the repo-seed skill's manifest. Hand-edit them only with intent; the manifest records their hash and the update mode preserves your edits. + +## Editing docs + +Follow [docs/AGENTS.md](AGENTS.md): one home per fact, tutorials vs references, hygiene checklist. diff --git a/docs/postmortems/README.md b/docs/postmortems/README.md new file mode 100644 index 00000000..642ba016 --- /dev/null +++ b/docs/postmortems/README.md @@ -0,0 +1,25 @@ +# Postmortems + +Incident write-ups: a bug reached a place it shouldn't have (a real user, a merged change, a release), and the interesting part is *why our process let it through*, not just the one-line fix. + +A postmortem is not a decision record ([docs/decisions/](../../docs/decisions/)), which preserves durable rationale. It is a backward-looking record of a failure: what broke, the mechanism, why every safety net missed it, and the concrete guardrails added so the same class of bug fails loudly next time. + +## When to write one + +Write one when a bug is **subtle** (the mechanism is non-obvious and a careful engineer would re-derive it the hard way), **systemic** (the reason it escaped is a gap in tests/tooling/conventions, not a one-off typo), and **costly to rediscover** (it cost real debugging time, and would cost it again). + +## Format + +Every postmortem opens with an **Executive summary**: one short paragraph a busy reader can absorb in thirty seconds — what broke, the root cause in plain terms, why it escaped, and the durable lesson — before the detailed sections that follow: + +1. `## Executive summary` +2. `## Summary` +3. `## Timeline` +4. `## Root cause` +5. `## Guardrails` + +Name files `NNNN-short-title.md` (sequential number) and put `Artifact-Version: 1` below the title. `## Guardrails` must link at least one permanent repository test, gate, skill, AGENTS.md rule, or decision record. A verbal follow-up without a durable guardrail does not close the incident loop. + +## Index + +None yet. diff --git a/docs/release-policy.md b/docs/release-policy.md new file mode 100644 index 00000000..e61a2c9d --- /dev/null +++ b/docs/release-policy.md @@ -0,0 +1,43 @@ +# Release policy + +This repository follows **Conventional Commits** for commit messages and a +decision-log / CHANGELOG / RFC division for durable writing. The commit-msg hook +enforces the message format mechanically. + +## Commit messages + +The subject line follows Conventional Commits: + +``` +type(scope)!: subject +``` + +Allowed types are `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, +`build`, `ci`, `chore`, and `revert`. A `!` marks a breaking change; a +`BREAKING CHANGE:` trailer in the body explains it. Merge commits are exempt. + +## Writing durable changes + +- **Decision log** ([docs/decisions/](../docs/decisions/)) records why one + meaningful alternative won. It is append-only and is not a changelog. +- **CHANGELOG** records what changed between releases for users. +- **Specs** ([docs/specs/](../docs/specs/)) define intent, observable contract, + implementation seams, verification, and evidence for risk-boundary work. + +## RCM release automation + +Release tags are created from `dev`. The release workflow builds the five +platform archives, publishes them in this repository, renders `Formula/rcm.rb`, +and opens a `release/vX.Y.Z` promotion pull request to `main`. The promotion PR +is the only path that changes `main`; required checks must pass before its +configured auto-merge can complete. + +- Commit messages are the release contract — keep them honest. +- Publish steps run in CI, never locally. +- The archived distribution repository is not a release dependency. + +## Versioning + +Use semantic versioning: `MAJOR.MINOR.PATCH`. Breaking changes bump MAJOR; +features bump MINOR; fixes bump PATCH. Pre-release tags (`-alpha`, `-rc.1`) are +allowed for staged releases. diff --git a/docs/specs/0000-primary-repository-release-migration.md b/docs/specs/0000-primary-repository-release-migration.md new file mode 100644 index 00000000..446a5c5d --- /dev/null +++ b/docs/specs/0000-primary-repository-release-migration.md @@ -0,0 +1,48 @@ +# Primary-repository release migration + +Artifact-Version: 1 +Status: Implemented + +## Intent + +Publish RCM binaries, installers, checksums, and the Homebrew Formula from +`recursive-context-machine` itself. Development remains on `dev`; `main` changes +only through an automated release promotion pull request. The old `rcm-dist` +repository is no longer a release dependency. + +## Contract + +- A `v*` tag must point to a commit reachable from `dev` and match the CLI package + version. +- The release workflow builds the five supported platform archives and a + `checksums.txt` file, then publishes them to the current repository Release. +- The workflow creates a `release/vX.Y.Z` branch, writes Formula hashes, opens a + promotion PR to `main`, and enables squash auto-merge only after required checks + pass. +- Installers resolve releases from the current repository and never require + `rcm-dist` credentials or checkout. +- The maintainer workflow downloads and extracts the Linux archive format emitted + by the release workflow. + +## Plan + +Keep the existing build matrix and compatibility checks. Remove cross-repository +release and Formula updates, generate the Formula from the release artifacts, add +a release-only promotion check, and enable repository auto-merge while retaining +main branch protection. + +## Verification + +- `actionlint` validates the workflow definitions. +- `shellcheck install.sh` and `ruby -c Formula/rcm.rb` validate distribution files. +- Rust formatting, clippy, build, nextest, doctests, dependency hygiene, and + repo-seed gates pass on the release branch. +- A `v0.2.20` release rehearsal confirms archive names, checksums, Formula URLs, + and promotion-PR behavior. + +## Evidence + +- [Release workflow](../../.github/workflows/release-accelerate.yml) +- [Main promotion gate](../../.github/workflows/release-promotion.yml) +- [Formula renderer](../../scripts/render-formula.sh) +- [Repository governance manifest](../../.repo-seed/manifest.json) diff --git a/docs/specs/README.md b/docs/specs/README.md new file mode 100644 index 00000000..e3361ba8 --- /dev/null +++ b/docs/specs/README.md @@ -0,0 +1,46 @@ +# Specs + +A spec is the stable contract between intent and implementation. It says what observable result must hold and how completion is proven. It is not an implementation diary: a plan says how to edit the code, while a decision record preserves why one meaningful option beat another. + +## When a spec is required + +Write and obtain human approval for a spec before implementation when a change affects public or model-visible behavior, a cross-module contract, data or security boundaries, a migration, or work that must survive a person/agent/session handoff. Routine documentation fixes, mechanical refactors, and local bugs already defined by a failing regression test do not require one. + +## Lifecycle + +- **Draft** — proposed; under discussion. File name: `draft-<title>.md`. +- **Approved** — reviewed and accepted; implementation may start. Rename to `<NNNN>-<title>.md` (sequential). +- **Implemented** — shipped; the spec describes what exists. +- **Superseded** — replaced by a newer numbered spec; keep both and link the new one from `Status:`. + +## Template + +```markdown +# <Title> + +Artifact-Version: 1 +Status: Draft + +## Intent +Problem, desired outcome, scope, non-goals, and constraints. + +## Contract +Observable behavior, inputs, outputs, errors, side effects, and compatibility. + +## Plan +Implementation seams and ordering. Keep this brief; volatile execution detail can stay in the working plan. + +## Verification +Commands and acceptance scenarios that prove the contract. + +## Evidence +Pending until implementation. Implemented specs link permanent repository tests, gates, screenshots, or other durable proof. +``` + +## Rules + +- An agent may draft a spec, but a human changes it to Approved. +- Draft files use `draft-<title>.md`; approved records use sequential `NNNN-<title>.md` names. +- Implemented specs contain at least one relative repository link in Evidence. +- Existing external requirements may remain the source of truth; set the Spec capability state to `external`, register the stable source link, and do not create a parallel contract. +- Keep one spec under roughly 200 lines; split independent contracts rather than growing a design essay. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 00000000..7d0ab51e --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,40 @@ +# Testing policy + +Tests are executable evidence that a meaningful regression becomes visible before delivery. Optimize for risk-adjusted signal over the test's lifetime, not test count, line coverage, or assertion volume. Repository-wide entry commands live in the root [AGENTS.md](../AGENTS.md); this document owns test selection and topology. + +## Test selection + +Name the behavior or invariant at risk before writing a test. Choose the lowest boundary that can observe it with production-like composition: pure decisions belong in unit tests, module contracts and stateful boundaries in integration tests, and public entry points or cross-system flows in end-to-end tests. One behavior has one primary evidence home; add defense in depth only when a public contract, security boundary, migration, escaped incident, or similarly costly risk justifies the duplication. + +| Risk shape | Primary evidence | +|---|---| +| Pure logic, decisions, and state transitions | Focused unit tests at the owning code area | +| Module contracts, persistence, files, queues, or framework composition | Integration tests with real owned modules and only unstable external boundaries replaced | +| Public CLI/API/UI entry points and cross-system flows | End-to-end tests through the shipped entry point with externally inspected results | +| Security boundaries, migrations, and escaped incidents | The lowest sufficient primary test plus narrowly justified defense in depth | + +## Test topology + +Rust unit tests are colocated with owning modules, while crate integration tests live under crates/*/tests and exercise public crate APIs. CLI tests cover parsing, compilation, execution, dispatch, templates, and example projects; server tests cover integration and layer consistency; storage and accelerator tests cover persistence and tool/graph behavior. Python SDK tests live under sdks/python/tests. CI runs workspace build, cargo nextest, and Rust doctests; deterministic suites do not require external services. + +Follow the ecosystem's discoverable convention rather than forcing a universal tree. Colocated unit tests are valid where idiomatic; cross-module and system tests belong in clearly named repository-level roots. Keep a fixture beside the test that owns it and promote it to shared infrastructure only when multiple contracts genuinely share it. + +## Evidence rules + +- Assert observable outcomes: return values, errors, persisted state, files, process exit status, rendered output, or externally visible side effects. Internal call counts and private ordering are evidence only when they are themselves a contract. +- Test the real entry path where packaging, process setup, configuration, or wiring can fail. A hand-wired harness cannot prove the shipped entry point works. +- Mock only expensive or non-deterministic boundaries such as the network, clock, or an external API; keep owned composition downstream of that boundary real. +- Select representative equivalence classes, boundaries, invalid inputs, state transitions, and recovery points instead of enumerating incidental examples. +- A regression test fails for the escaped behavior before the fix and passes afterward. It proves the cause, not merely that some nearby output changed. +- When a change should preserve a file or state, assert it remains byte-identical or semantically identical as the contract requires. + +## Maintenance budget + +- Coverage reports locate unobserved code; they are not a target and do not establish test quality. +- Remove or merge tests when their behavior disappears, another test becomes the primary evidence, or they constrain implementation without protecting a contract. +- A flaky test is a broken signal. Fix its uncontrolled boundary or quarantine it with an owner and repair condition; never normalize blind retries. +- Keep the fast path focused enough for local use. Expensive techniques such as fuzzing, mutation, visual regression, load testing, and large environment matrices require a repository risk that pays for their continuing cost. + +## When a test is required + +Every behavior change adds or updates the smallest test set that proves its affected contract. Every bug fix carries a regression test unless the failure is already deterministically reproduced by an existing test; document that existing evidence when no new test is needed. Documentation-only and mechanical changes use the relevant semantic review and gates rather than manufacturing behavior tests. diff --git a/examples/AGENTS.md b/examples/AGENTS.md new file mode 100644 index 00000000..d64f5df8 --- /dev/null +++ b/examples/AGENTS.md @@ -0,0 +1,11 @@ +# Examples + +Each child directory is a runnable RCM project. Keep its `rcm.toml`, `.rcm` files, +prompts, and README self-contained and use paths relative to that example. + +- Runtime outputs belong under the example's ignored `outputs/` or run directory. +- Do not commit credentials, downloaded papers, generated reports, or trajectory + data. +- Update the example README and CLI example tests when a project layout changes. +- Use the root `accelerate` build and the real project entry point when verifying an + example; do not replace a parser or graph test with a hand-wired mock. diff --git a/examples/MASA v2.0/README.md b/examples/MASA v2.0/README.md new file mode 100644 index 00000000..32e15f7e --- /dev/null +++ b/examples/MASA v2.0/README.md @@ -0,0 +1,159 @@ +# MASA — Multi-Agent Survey Automation + +MASA 实现了一个多 Agent 迭代式文献综述生成系统。核心思路不是让单一 LLM 直接输出长文,而是将综述写作拆解为可审计、可回滚、可并行的单元级 context machines: + +1. **Researcher** 构建带细化准则的大纲与引用地图 +2. **SectionPlanner** 将大纲拆解为逐 subsection 的写作计划 +3. **Generator** 按计划逐 subsection 独立写作,再组装为完整草稿 +4. **Supervisor** 进行全局一致性校验,并标记最需深化的 2-3 个 section +5. **7 个 LLM Judge** 从覆盖度、引用相关性、事实一致性、冗余度、引用均衡、章节均衡、分析深度 7 个维度并行评分 +6. **Synthesizer** 加权汇总,决定迭代终止或继续 +7. **Polisher** 最终润色定稿 + +## 设计原则 + +- **多 Agent 分工**:Researcher、SectionPlanner、Generator、Supervisor、Polisher、7 个 LLM Judge 各司其职,避免单一模型因追求全局协调而牺牲局部深度。 +- **记忆库系统**:区分模型记忆库(全局共享:大纲、章节精炼总结、修改建议)与 Agent 记忆库(私有:关键词、工作状态、历史改动),确保上下文一致并模仿人类真实思考过程。 +- **先建图,再写作**:复用 autoresearch 的 candidate pool、citation expansion、semantic expansion,所有中间产物落盘。 +- **迭代驱动质量**:Judge Panel 从 7 个维度评分,加权汇总后决定 CONTINUE 或 STOP。Analysis Depth 权重最高(25%),以解决综述"广度有余、深度不足"的问题。 +- **聚焦式迭代**:Supervisor 每轮识别最薄弱的 2-3 个 section,Generator 在后续迭代中只重写这些 section,而非全文重写。 +- **Subsection 级独立生成**:SectionPlanner 生成逐 section 写作计划,Generator 按计划逐个 subsection 独立写作并落盘,最后拼接为完整草稿。每个 subsection 的上下文更聚焦,不会被其他 section 稀释。 +- **每个单元都是独立 `.rcm` 文件**:可以单跑、复用,也可以由 end-to-end graph 串联。 +- **每次运行写入新的 `runs/<utc timestamp>/`**,不会覆盖旧结果。 + +## 输入 Topic + +入口从下面两个位置读取 topic,优先级从高到低: + +1. 环境变量 `AUTORESEARCH_TOPIC` +2. 本地文件 `examples/MASA v2.0/topic.md` + +如果两者都不存在,系统会使用一个 smoke-test topic。 + +示例: + +```powershell +$env:DEEPSEEK_API_KEY="REDACTED" +cd examples/MASA v2.0 +.\run_masa.ps1 -MaxRounds 3 +``` + +也可以单跑某个单元: + +```powershell +cargo run --bin accelerate -- run "examples/MASA v2.0/rcm/core/researcher.rcm" --speed 0 --context +cargo run --bin accelerate -- run "examples/MASA v2.0/rcm/core/section_planner.rcm" --speed 0 --context +cargo run --bin accelerate -- run "examples/MASA v2.0/rcm/core/generator.rcm" --speed 0 --context +``` + +单跑中游单元时,它会优先读取上游 context 中的 `run_dir`;如果没有,就尝试使用最近一次 `runs/*` 目录。 + +## Pipeline + +The context contract is documented in [schema/handoff.md](schema/handoff.md). Graph context only carries handoffs; full intermediate data lives in `run_dir` artifacts. + +### Phase 0 — 论文检索与候选池构建(`masa_phase0.rcm`) + +1. `anchor.rcm` — 读取 topic,生成 `SurveySpec`,创建 `run_dir` +2. `query_plan.rcm` — 将 `SurveySpec` 转成 typed query program +3. `discovery.rcm` — 并行运行 method、benchmark、survey、frontier scouts,产出初始 candidate pool +4. `expansion.rcm` — 执行 citation graph expansion 和 semantic neighbor expansion + +### Core Loop — 迭代写作与精修(`masa_core.rcm`) + +``` +Researcher + ↓ handoff +SectionPlanner ← 根据 outline + candidate pool 生成 subsection 级写作计划 + ↓ handoff +Generator ← 按 section_plan.md 逐 subsection 写作到 memory/subsections/, + ↓ handoff 然后拼接为 05_draft.md +Supervisor ← 全局一致性校验 + 识别最薄弱 sections → focus_sections.md + ↓ handoff (7-way parallel) +JudgeCoverage +JudgeCitationRelevance +JudgeFactualConsistency +JudgeRedundancy +JudgeCitationBalance +JudgeSectionBalance +JudgeDepth ← 评估分析深度(对比表、机制分析、Pros/Cons) + ↓ context (merge 7→1) +JudgeSynthesizer ← 加权汇总,计算总分,裁决 STOP/CONTINUE +``` + +5. `researcher.rcm` — 基于 candidate pool 生成结构化综述大纲,写入 `memory/outline.md` +6. `section_planner.rcm` — 分析 outline 和 candidate pool,为每个 subsection 生成写作计划(目标字数、关键论文、写作指令、深度级别),写入 `memory/section_plan.md` +7. `generator.rcm` — 按 `section_plan.md` 逐个 subsection 独立写作到 `memory/subsections/[id].md`,最后拼接为 `05_draft.md` +8. `supervisor.rcm` — 全局一致性校验,检查分析深度,标记最薄弱 2-3 个 section 到 `memory/focus_sections.md`。输出 `06_review.md` 与修改建议 +9. 7 个 Judge 并行评估各维度: + + | Judge | 评估内容 | + |-------|---------| + | Coverage | 各 section 的论文覆盖度,是否有重要方法遗漏 | + | Citation Relevance | 引用论文与内容的匹配度 | + | Factual Consistency | 事实性错误、幻觉引用 | + | Redundancy | 跨 section 内容重复度 | + | Citation Balance | 引用在 section 间的分布均匀性 | + | Section Balance | 各 section 篇幅与深度的均衡性 | + | Analysis Depth | 分析深度——对比表、机制解释、Pros/Cons、定量结果 | + +10. `judge_synthesizer.rcm` — 汇总 7 维评分(加权),计算总分,依据阈值与收敛规则裁决 STOP/CONTINUE + + 评分权重: + + | 维度 | 权重 | + |------|------| + | Analysis Depth | 25% | + | Coverage | 15% | + | Citation Relevance | 15% | + | Factual Consistency | 15% | + | Redundancy | 10% | + | Citation Balance | 10% | + | Section Balance | 10% | + +11. `polisher.rcm` — 当裁决为 STOP 时,润色语言风格、统一术语、优化衔接,输出最终定稿 `07_survey.md` + +### 迭代控制 + +迭代由 [`run_masa.ps1`](run_masa.ps1) 负责: + +- 若 Synthesizer 裁决 CONTINUE,脚本更新 `memory/iteration_state.md` 并触发下一轮 +- Generator 在后续迭代中只重写 `focus_sections.md` 指定的 section,其余 section 保留 +- 终止条件:CONVERGED(改进 < 0.15)、CRITICAL_ISSUE(单维度 < 3.0)、MAX_ROUNDS、QUALITY_OK(总分 ≥ 4.3) + +### PDF 导出 + +运行结束后,可用 `export_pdf.js` 将最终稿导出为 PDF: + +```powershell +node export_pdf.js runs/<timestamp>/07_survey.md +``` + +需要安装 Puppeteer:`npm install puppeteer` + +## Run Artifacts + +Phase 0 产物: +- `phase0/00_survey_spec.md` — 综述规范 +- `phase0/01_query_plan.md` — 查询计划 +- `phase0/02_candidate_pool.md` — 候选论文池 +- `phase0/03_expansion.md` — 扩展结果 + +Core Loop 产物: +- `memory/outline.md` — 结构化大纲 +- `memory/outline_history.md` — 大纲变更历史 +- `memory/section_plan.md` — Subsection 级写作计划 +- `memory/section_summaries.md` — 章节精炼摘要 +- `memory/subsections/*.md` — 各 subsection 独立文件 +- `memory/focus_sections.md` — 需深化的 section 列表 +- `memory/supervisor_notes.md` — 修改建议 +- `memory/judge_report_round[N].md` — 各轮 Judge 报告 +- `memory/judge_suggestions.md` — Judge 综合建议 +- `memory/judge_*.md` — 各 Judge 维度评估 +- `memory/iteration_state.md` — 迭代状态 +- `memory/agent_*.md` — Agent 记忆日志 +- `05_draft.md` — 当前草稿 +- `06_review.md` — Supervisor 评审报告 +- `07_survey.md` — 最终定稿 + +这些文件是 runtime artifacts,默认不进入 git。 diff --git a/examples/MASA v2.0/changelog/000_architecture_overview.md b/examples/MASA v2.0/changelog/000_architecture_overview.md new file mode 100644 index 00000000..7ce8ae08 --- /dev/null +++ b/examples/MASA v2.0/changelog/000_architecture_overview.md @@ -0,0 +1,248 @@ +# MASA Architecture Overview & Change Log + +Date: 2026-06-06 +Author: Agent (Session 1) +TODO: Append new entries at the top of the file for each new session. + +## Current Architecture + +### Pipeline Structure + +``` +Phase 0 (Paper Discovery) ───→ Core Loop (Iterative Writing) ───→ Finish (Polishing) + +Phase 0 nodes: + Anchor → QueryPlan → Discovery → Expansion → ExtendedQueryPlan → ExtendedDiscovery + → PaperFetcher → PaperProfiler → TaxonomyBuilder → EvolutionMapper + → [output to Core] + +Core Loop nodes (per round): + Researcher → Generator → Supervisor → JudgeContent + JudgeAccuracy + JudgeDepth + → JudgeSynthesizer → verdict (STOP/CONTINUE) + +Finish nodes: + Polisher → Synthesizer → output +``` + +### Phase 0: Paper Discovery (10 nodes) + +| Node | Prompt File | RCM File | Function | +|------|------------|---------|----------| +| Anchor | `prompts/phase0/anchor.txt` | `rcm/phase0/anchor.rcm` | Set research anchor | +| QueryPlan | `prompts/phase0/query_plan.txt` | `rcm/phase0/query_plan.rcm` | Initial query plan | +| Discovery | various scouts | `rcm/phase0/discovery.rcm` | Multi-scout discovery | +| Expansion | various scouts | `rcm/phase0/expansion.rcm` | Expand query pool | +| ExtendedQueryPlan | `prompts/phase0/extended_query_plan.txt` | `rcm/phase0/extended_query_plan.rcm` | Extended query plan | +| ExtendedDiscovery | various scouts | `rcm/phase0/extended_discovery.rcm` | Extended discovery | +| PaperFetcher | `prompts/phase0/paper_fetcher.txt` | `rcm/phase0/paper_fetcher.rcm` | Download PDFs for core/benchmark papers | +| PaperProfiler | `prompts/phase0/paper_profiler.txt` | `rcm/phase0/paper_profiler.rcm` | Read PDFs → extract structured profiles | +| TaxonomyBuilder | `prompts/phase0/taxonomy_builder.txt` | `rcm/phase0/taxonomy_builder.rcm` | Build classification dimensions + categories | +| EvolutionMapper | `prompts/phase0/evolution_mapper.txt` | `rcm/phase0/evolution_mapper.rcm` | Construct critical evolution narrative | + +### Core Loop: Iterative Writing (7 nodes) + +| Node | Prompt File | RCM File | Lines | Function | +|------|------------|---------|:-----:|----------| +| Researcher | `prompts/core/researcher.txt` | `rcm/core/researcher.rcm` | 121 | Read taxonomy+profiles+evolution → write outline + section_plan | +| Generator | `prompts/core/generator.txt` | `rcm/core/generator.rcm` | 146 | Subsection-by-subsection writing + PDF deep-read + fact verification + evidence mining | +| Supervisor | `prompts/core/supervisor.txt` | `rcm/core/supervisor.rcm` | 120 | Review → diagnose knowledge gaps → search arXiv → download PDFs → write profiles → update pool | +| JudgeContent | `prompts/core/judge_content.txt` | `rcm/core/judge_content.rcm` | - | Coverage + Citation Relevance + Section Balance | +| JudgeAccuracy | `prompts/core/judge_accuracy.txt` | `rcm/core/judge_accuracy.rcm` | - | Factual Consistency + Citation Balance + Redundancy | +| JudgeDepth | `prompts/core/judge_depth.txt` | `rcm/core/judge_depth.rcm` | - | Analysis Depth | +| JudgeSynthesizer | `prompts/core/judge_synthesizer.txt` | `rcm/core/judge_synthesizer.rcm` | 128 | Weighted score + verdict + iteration evidence audit | + +### Key Data Flows + +**Phase 0 → Core**: +- `phase0/paper_taxonomy.md` — classification framework +- `phase0/paper_profiles/*.md` — per-paper structured analysis +- `phase0/evolution_narrative.md` — critical evolution narrative +- `phase0/02_candidate_pool.md` — paper metadata + +**Within Core (R1)**: +``` +Researcher (outline + section_plan) + → Generator (5 subsections + assemble draft) + → Supervisor (review + focus_sections + evidence retrieval) + → 3 Judges (score) + → JudgeSynthesizer (verdict) +``` + +**Within Core (R2+)**: +``` +Generator: + ① Critical Assessment Verification (PDF fact-check claims) + ② Iteration Evidence Mining (mine patterns from history) + ③ Focused rewriting (2-3 focus sections) + → Supervisor: diagnose gaps → arxiv_search → download PDF → write profile → update pool + → 3 Judges → JudgeSynthesizer (includes WASTED_ITERATION check) +``` + +### Tools per Node + +| Node | Tools | +|------|-------| +| Researcher | arxiv_download, arxiv_search, fs, shell | +| Generator | arxiv_download, fs | +| Supervisor | arxiv_download, arxiv_search, fs | +| PaperFetcher | arxiv_download, fs | +| PaperProfiler | arxiv_download, fs | +| TaxonomyBuilder | fs | +| EvolutionMapper | fs | + +### Iteration Termination Criteria + +1. MAX_ROUNDS (>=5) → STOP +2. CONVERGED (improvement < 0.15 for 2 rounds) + no WASTED_ITERATION → STOP +3. WASTED_ITERATION + no improvement → CONTINUE (must utilize available evidence) +4. CRITICAL_ISSUE (any dimension < 3.0) → CONTINUE +5. BELOW_THRESHOLD (total < 4.3) → CONTINUE +6. Quality OK (total >= 4.3 + no critical issues + no wasted iteration) → STOP + +## Change History + +### Changes from Previous Architecture (Original → Current) + +All changes are listed from most recent to oldest. + +--- + +#### Change 5: Prompt Cleanup — Remove Redundancy, Rebalance Agent Roles + +**Files Changed**: +- `prompts/core/generator.txt` (242 → 146 lines, -40%) +- `prompts/core/supervisor.txt` (77 → 120 lines, +56%) +- `prompts/core/researcher.txt` (190 → 121 lines, -36%) +- `rcm/core/supervisor.rcm` (added arxiv_download + arxiv_search tools) + +**Changes**: +1. **Generator**: Removed 5c Adaptive Retrieval (~70 lines). Generator no longer needs to search arXiv, download PDFs, write profiles, or update taxonomy. Focuses purely on writing + fact-checking + evidence mining. +2. **Supervisor**: Added "Evidence Retrieval (Rounds 2+)" section (~43 lines). Supervisor now owns the search → download → read PDF → write profile → update pool workflow. This is a natural fit since Supervisor already diagnoses knowledge gaps and writes search_keywords. +3. **Researcher**: Removed Paper Supplement Protocol (~30 lines). Paper supplementation is now fully handled by Supervisor's Evidence Retrieval in R2+, which is more rigorous (downloads PDFs, writes profiles, not just reads abstracts). + +**Rationale**: Generator's Adaptive Retrieval (from Change 4) overloaded Generator with 3 distinct responsibilities (writing + evidence mining + search engine). Moving retrieval to Supervisor gives each Agent a single clear responsibility. Researcher's Paper Supplement was redundant with Supervisor's more thorough Evidence Retrieval. + +--- + +#### Change 4: Adaptive Retrieval — Feedback-Driven Evidence Search + +**Files Changed**: +- `prompts/core/supervisor.txt` (focus_sections format extended) +- `prompts/core/generator.txt` (5c Adaptive Retrieval added) +- `rcm/core/generator.rcm` (added arxiv_download tool) + +**Changes**: +1. **Supervisor**: focus_sections.md output format extended from simple section IDs to include `knowledge_gap` + `search_keywords` fields. Supervisor now diagnoses not just WHICH section is shallow, but WHAT SPECIFIC knowledge is missing. +2. **Generator**: Added 5c Adaptive Retrieval step in Rounds 2+. Generator searches arXiv, downloads PDFs, writes paper profiles, and updates the paper pool based on Supervisor's search_keywords. + +**Note**: Adaptive Retrieval was later moved from Generator to Supervisor in Change 5. + +--- + +#### Change 3: Evolution Narrative — Critical Analysis in the Survey + +**Files Added**: +- `prompts/phase0/evolution_mapper.txt` (new) +- `rcm/phase0/evolution_mapper.rcm` (new) + +**Files Changed**: +- `rcm/masa_phase0.rcm` (inserted EvolutionMapper after TaxonomyBuilder) +- `prompts/core/researcher.txt` (added evolution_narrative reading + narrative-driven outline structure) +- `prompts/core/generator.txt` (added evolution_narrative reading) +- `prompts/core/generator.txt` (added Critical Assessment Verification + Iteration Evidence Mining) +- `prompts/core/judge_synthesizer.txt` (added Iteration Evidence Assessment + WASTED_ITERATION logic) + +**Changes**: +1. **Phase 0 new node**: EvolutionMapper constructs a critical evolution narrative from paper profiles + taxonomy. Outputs `phase0/evolution_narrative.md` with phase structure, performance trends, claim-vs-evidence gap analysis, and critical assessment. +2. **Researcher**: Outline restructured from "taxonomy-driven listing" to "narrative-driven arc": evolution overview → deep dive → critical assessment (Section 5). +3. **Generator**: Added two new Rounds 2+ steps: (a) Critical Assessment Verification — fact-check each claim in Section 5 against paper profiles/PDFs; (b) Iteration Evidence Mining — extract cross-paper patterns from judge reports and logs. +4. **JudgeSynthesizer**: Added Iteration Evidence Assessment block that checks whether Generator utilized PDF deep-read findings and mined patterns. Added WASTED_ITERATION verdict override — if evidence exists but Generator didn't use it, force CONTINUE even if scores are high. + +**Phase 0 pipeline**: `... → TaxonomyBuilder → EvolutionMapper → [output]` + +--- + +#### Change 2: Paper Profile Pipeline — Phase 0 Expansion + +**Files Added**: +- `prompts/phase0/paper_fetcher.txt` (new) +- `prompts/phase0/paper_profiler.txt` (new) +- `prompts/phase0/taxonomy_builder.txt` (new) +- `rcm/phase0/paper_fetcher.rcm` (new) +- `rcm/phase0/paper_profiler.rcm` (new) +- `rcm/phase0/taxonomy_builder.rcm` (new) + +**Files Changed**: +- `rcm/masa_phase0.rcm` (inserted PaperFetcher → PaperProfiler → TaxonomyBuilder after ExtendedDiscovery) +- `prompts/core/researcher.txt` (added taxonomy+profiles reading + taxonomy writing + arxiv_download tool) +- `prompts/core/generator.txt` (added taxonomy+profiles reading + arxiv_download tool + PDF deep-read) +- `rcm/core/researcher.rcm` (added arxiv_download + arxiv_search tools) +- `rcm/core/generator.rcm` (added arxiv_download tool) + +**Changes**: +1. **Three new Phase 0 nodes**: (a) PaperFetcher downloads PDFs for core_method + benchmark papers; (b) PaperProfiler reads each PDF and extracts structured profiles (Method Description, Architecture, Key Innovations, Benchmark Results, etc.); (c) TaxonomyBuilder analyzes all profiles and builds a classification framework with dimensions and categories. +2. **Researcher + Generator**: Both can now read `paper_taxonomy.md` and `paper_profiles/` for detailed paper understanding. Both have `arxiv_download` for on-demand PDF deep-read. +3. **Phase 0 pipeline**: `... → ExtendedDiscovery → PaperFetcher → PaperProfiler → TaxonomyBuilder → [output]` + +--- + +#### Change 1: Core Optimization — Judge Merge + SectionPlanner Removal + Focused Iteration + +**Files Added**: +- `prompts/core/judge_content.txt` (new) +- `prompts/core/judge_accuracy.txt` (new) +- `rcm/core/judge_content.rcm` (new) +- `rcm/core/judge_accuracy.rcm` (new) + +**Files Changed**: +- `rcm/masa_core.rcm` (7→3 judges, removed SectionPlanner edges, arity 7→3) +- `prompts/core/researcher.txt` (added section_plan.md output, Section Planner format, Context Discipline) +- `prompts/core/generator.txt` (split Memory Access into R1/R2+, focused reading rules, Context Discipline) +- `prompts/core/supervisor.txt` (Context Discipline) +- `prompts/core/judge_synthesizer.txt` (3-judge input, same weights) + +**Files Deleted**: +- `rcm/core/judge_coverage.rcm`, `judge_relevance.rcm`, `judge_factual.rcm`, `judge_redundancy.rcm`, `judge_balance.rcm`, `judge_section_balance.rcm` +- `prompts/core/section_planner.txt` +- `prompts/core/judge_coverage.txt`, `judge_relevance.txt`, `judge_factual.txt`, `judge_redundancy.txt`, `judge_balance.txt`, `judge_section_balance.txt` + +**Changes**: +1. **Judge Panel**: 7 individual judges merged into 3 composite judges (Content: Coverage+Relevance+Balance; Accuracy: Factual+Citation Balance+Redundancy; Depth: Analysis Depth). Same 7 dimensions, same weights, fewer LLM calls. +2. **SectionPlanner removed**: Functionality absorbed into Researcher. Researcher now outputs both `memory/outline.md` and `memory/section_plan.md`. +3. **Generator focused reading**: Rounds 2+ only reads focus section files + first/last 30 lines of draft. No full-draft re-reads. +4. **Context Discipline**: All agents told to not explore filesystem. Exact paths are provided in prompts. + +--- + +#### Change 0: File System Reorganization + +**Changes**: Moved all rcm and prompt files from root directories into phase0/core/finish subdirectories. Updated all internal paths. Updated README.md paths. + +## File Inventory + +### RCM Files (15) +``` +rcm/masa_phase0.rcm — Phase 0 orchestrator (10 nodes) +rcm/masa_core.rcm — Core loop orchestrator (7 nodes) + +rcm/phase0/ (10 files) + anchor.rcm, query_plan.rcm, discovery.rcm, expansion.rcm, + extended_query_plan.rcm, extended_discovery.rcm, + paper_fetcher.rcm, paper_profiler.rcm, + taxonomy_builder.rcm, evolution_mapper.rcm + +rcm/core/ (7 files) + researcher.rcm, generator.rcm, supervisor.rcm, + judge_content.rcm, judge_accuracy.rcm, judge_depth.rcm, + judge_synthesizer.rcm + +rcm/finish/ (N files) + polisher.rcm, ... +``` + +### Prompt Files (27 total) +``` +prompts/phase0/ (20 files) +prompts/core/ (7 files — researcher, generator, supervisor, judge_content, + judge_accuracy, judge_depth, judge_synthesizer) +``` diff --git a/examples/MASA v2.0/export_pdf.js b/examples/MASA v2.0/export_pdf.js new file mode 100644 index 00000000..c7fbf422 --- /dev/null +++ b/examples/MASA v2.0/export_pdf.js @@ -0,0 +1,133 @@ +// Export MASA survey markdown to PDF via pandoc + puppeteer +const puppeteer = require('puppeteer'); +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const MD_FILE = path.resolve(__dirname, 'runs/2026-06-07T1011Z/07_survey.md'); +const PDF_FILE = MD_FILE.replace(/\.md$/, '.pdf'); + +const CSS = ` +@page { + size: A4; + margin: 2.2cm 2.5cm 2.2cm 2.5cm; + @bottom-center { + content: counter(page); + font-size: 9pt; + color: #666; + font-family: Georgia, 'Times New Roman', serif; + } +} +html { font-size: 10.5pt; } +body { + font-family: Georgia, 'Times New Roman', serif; + line-height: 1.65; + color: #111; + text-align: justify; + hyphens: auto; + orphans: 3; + widows: 3; +} +h1.title { + font-size: 17pt; + text-align: center; + margin: 0 0 0.8cm 0; + padding-top: 1.5cm; + line-height: 1.3; + page-break-before: avoid; +} +h1:not(.title) { + font-size: 13pt; + margin: 1.2em 0 0.4em 0; + border-bottom: 1px solid #bbb; + padding-bottom: 3px; + page-break-after: avoid; +} +h2 { font-size: 11.5pt; margin: 1em 0 0.3em 0; page-break-after: avoid; } +h3 { font-size: 10.5pt; margin: 0.8em 0 0.2em 0; page-break-after: avoid; } +p { margin: 0.4em 0; } +ul, ol { margin: 0.3em 0 0.3em 1.8em; } +li { margin: 0.15em 0; } +table { + border-collapse: collapse; width: 100%; margin: 0.6em 0; + font-size: 9pt; page-break-inside: avoid; +} +th, td { + border: 1px solid #999; padding: 3px 5px; text-align: left; vertical-align: top; +} +th { background: #eee; font-weight: bold; } +code { + font-family: 'Consolas', 'Courier New', monospace; + font-size: 8.5pt; background: #f5f5f5; padding: 1px 3px; +} +pre { + background: #f8f8f8; border: 1px solid #ddd; border-left: 3px solid #0366d6; + padding: 0.5em 0.8em; font-size: 8pt; line-height: 1.35; + overflow-x: auto; page-break-inside: avoid; +} +pre code { background: none; padding: 0; } +blockquote { + margin: 0.4em 0; padding: 0.2em 0.8em; + border-left: 3px solid #0366d6; color: #555; background: #f8f9fa; +} +a { color: #0366d6; text-decoration: none; } +img { max-width: 100%; } +`; + +async function main() { + if (!fs.existsSync(MD_FILE)) { + console.error(`Error: ${MD_FILE} not found`); + process.exit(1); + } + + console.log(`Input: ${MD_FILE}`); + console.log(`Output: ${PDF_FILE}`); + + // Step 1: pandoc markdown → HTML + console.log('Converting to HTML via pandoc...'); + const htmlPath = MD_FILE.replace(/\.md$/, '.html'); + execSync( + `pandoc "${MD_FILE}" --from markdown+smart+pipe_tables+grid_tables+fenced_code_blocks+backtick_code_blocks+autolink_bare_uris+tex_math_dollars --to html5 --mathjax --wrap preserve -o "${htmlPath}"`, + { stdio: 'pipe', encoding: 'utf-8' } + ); + console.log(' pandoc OK'); + + // Read HTML and inject CSS + let html = fs.readFileSync(htmlPath, 'utf-8'); + html = html.replace('</head>', `<style>${CSS}</style></head>`); + // Add title class to first h1 + html = html.replace('<h1>', '<h1 class="title">'); + fs.writeFileSync(htmlPath, html, 'utf-8'); + console.log(' CSS injected'); + + // Step 2: puppeteer HTML → PDF + console.log('Rendering PDF via puppeteer...'); + const browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox'], + userDataDir: require('os').tmpdir() + '/puppeteer_masa_' + Date.now(), + }); + const page = await browser.newPage(); + await page.goto('file://' + htmlPath.replace(/\\/g, '/'), { waitUntil: 'networkidle0' }); + + await page.pdf({ + path: PDF_FILE, + format: 'A4', + margin: { top: '2.2cm', bottom: '2.2cm', left: '2.5cm', right: '2.5cm' }, + printBackground: true, + displayHeaderFooter: true, + headerTemplate: '<span></span>', + footerTemplate: '<div style="width:100%;text-align:center;font-size:9pt;color:#666;font-family:Georgia,serif;"><span class="pageNumber"></span></div>', + }); + + await browser.close(); + + // Cleanup HTML + try { fs.unlinkSync(htmlPath); } catch {} + + const sizeKB = (fs.statSync(PDF_FILE).size / 1024).toFixed(0); + console.log(`\nDone! PDF: ${PDF_FILE}`); + console.log(`Size: ${sizeKB} KB`); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/examples/MASA v2.0/prompts/core/generator.txt b/examples/MASA v2.0/prompts/core/generator.txt new file mode 100644 index 00000000..1f1c52ca --- /dev/null +++ b/examples/MASA v2.0/prompts/core/generator.txt @@ -0,0 +1,160 @@ +You are the Generator for the MASA survey generation system. + +Your task is to generate or revise a structured survey by writing each subsection individually following a section plan, with one revision pass based on supervisor and judge feedback. + +## Mode Detection + +Check whether `memory/supervisor_notes.md` exists: +- **Does NOT exist** → **Initial Generation**: write the full survey from scratch following the section plan. +- **Exists** → **Revision Mode**: read the feedback and do focused rewrites on flagged sections. + +## Memory Access + +| Direction | Initial Generation | Revision Mode | +|-----------|-------------------|---------------| +| **Read** | `memory/section_plan.md`, `memory/outline.md`, `phase0/paper_taxonomy.md`, `phase0/evolution_narrative.md`, `phase0/paper_profiles/`, `phase0/02_candidate_pool.md` | `memory/supervisor_notes.md`, `memory/judge_suggestions.md`, `memory/subsections/[section_ids_from_actions].md`, `05_draft.md` (first/last 30 lines) | +| **Write** | `memory/subsections/[id].md`, `memory/section_summaries.md` (append), `memory/agent_generator.md` (append), `05_draft.md` (overwrite) | `memory/subsections/[section_ids_from_actions].md` (overwrite modified), `05_draft.md` (overwrite), `memory/agent_generator.md` (append) | + +--- + +## Initial Generation Flow + +### Step 1: Read the Section Plan +Read `memory/section_plan.md` for the list of subsections with target words, key papers, and writing instructions. + +### Step 1b: Assess Corpus Adequacy + +Before writing, assess how many papers have been successfully discovered and profiled: + +1. Count the files in `phase0/paper_profiles/` whose names match the pattern `*.md`. +2. Determine the mode based on the profile count: + + - **If count >= 15**: Normal mode. Proceed with full coverage as usual. + - **If count < 15**: Enter **NARROW CORPUS MODE**. + - Scope: Replace all field-level claims ("the field", "no system", "every system") with corpus-bound statements ("among the [N] systems surveyed here", "in this corpus"). + - Quantitative metrics: For every reported number, append `(reported by the original paper, not independently verified)`. + - Coverage: Focus analytical depth on the available papers rather than attempting broad coverage. The survey title/scope should reflect the actual corpus size. + - Gap honesty: If a topic area is missing due to corpus limitations, say so explicitly. + - Add a note to the draft introduction: "This survey analyzes [N] systems identified through [method used to find them]. Claims about the field should be interpreted in light of this corpus size." + +### Step 2a: Load Global Materials (once) + +Load these once — they apply to all subsections: +1. `memory/section_plan.md` — full subsection list and key_papers +2. `phase0/paper_taxonomy.md` — classification framework for cross-method comparisons +3. `phase0/evolution_narrative.md` — critical narrative arc + +### Step 2b: Load Citation Grounding for Each Subsection +For each subsection, read its `citation_grounding` block from `memory/section_plan.md`: +```yaml +citation_grounding: + allowed_refs: ["Author1, Year", "Author2, Year"] + must_cite: ["claim A", "feature B"] + forbidden: ["Author3, Year"] +``` + +**Citation Grounding Rule (MANDATORY)**: When writing this subsection, you MAY ONLY cite papers from `allowed_refs`. If you need to cite a paper not in `allowed_refs`, you must first add it using `arxiv_download` to get the PDF, write a profile to `phase0/paper_profiles/<arxiv_id>.md`, and then add it to `allowed_refs`. Do NOT fabricate or guess citations. If a claim cannot be supported by an `allowed_refs` paper, note the gap rather than fabricating a citation. + +### Step 2c: Write Each Subsection Individually +For each subsection in dependency order: +1. Read the paper profiles referenced by this subsection's `key_papers` from `phase0/paper_profiles/<arxiv_id>.md`. +2. Read the `citation_grounding` block for this subsection. Keep `allowed_refs` visible as you write. +3. Write content following the plan's `writing_instructions`. Use the pre-loaded taxonomy and evolution narrative directly — do not re-read them. +4. Every factual claim MUST cite a paper from `allowed_refs`. Do NOT cite papers outside this list. +5. Write to `memory/subsections/[id].md` (e.g., `memory/subsections/s2.1.md`). +6. Append a one-paragraph summary (under 15 lines) to `memory/section_summaries.md`. + +### Step 3: Assemble Full Draft +After all subsections are written: +1. Read all subsection files from `memory/subsections/`. +2. **Outline compliance check**: verify every section/subsection from `memory/outline.md` has a corresponding file in `memory/subsections/`. Create placeholder subsections for any missing entries. +3. **Redundancy reduction scan**: scan the concatenated text for repeated claims/phrases appearing 3+ times with near-identical wording, and verbatim duplicated paragraphs. Keep the strongest occurrence; replace duplicates with a cross-reference ("as discussed in Section X"). +4. **Citation audit**: For each subsection, extract every inline citation `[Author, Year]` and check it against that subsection's `citation_grounding.allowed_refs`. Flag any citation outside `allowed_refs`: + - If the citation refers to a paper that genuinely belongs here but was omitted from `allowed_refs`, note the gap. + - If the citation is fabricated (paper does not exist in `phase0/02_candidate_pool.md`), remove it from the text. + - Append the audit log to `memory/agent_generator.md` under a "## Citation Audit" heading. +5. Concatenate subsections in order, weaving narrative threads across sections: introduce each thread in the first section it touches, develop it in middle sections, and resolve or re-state it in the culminating section. Add transitional sentences between subsections that reference these threads. +6. Ensure section numbering is consistent. +7. Write the complete draft to `05_draft.md` (overwrite). +8. Append action log (under 20 lines) to `memory/agent_generator.md`. + +--- + +--- + +## Revision Mode Flow + +### Step 1: Read Feedback +1. Read `memory/supervisor_notes.md`. Parse the structured YAML: + - If `actions` is empty or `verdict` is `"no_changes_needed"`, skip modification and proceed to step 4. + - Otherwise, iterate over each `action` item: note its `section`, `priority`, `diagnosis`, and `instruction`. Apply all CRITICAL actions first, then MODERATE, then NICE_TO_HAVE. +2. Read `memory/judge_suggestions.md`. First parse the YAML score header at the top: note the `below_threshold_scores` list and `generator_priority` instruction. Prioritize fixing below-threshold dimensions over nice-to-have suggestions. + +### Step 2: Apply Modifications +For each section targeted by action items or flagged in judge suggestions: +1. Read the existing subsection file from `memory/subsections/[id].md`. +2. Apply the modifications as specified in the action instructions. +3. Overwrite the subsection file with the revised version. +4. Update the corresponding summary in `memory/section_summaries.md` if needed. + +### Step 3: Critical Assessment Verification +If any action targets Section 5 or a critical assessment section: +For each claim in the critical analysis, verify against paper profiles or PDFs. Mark each as Verified / Qualified / Unsupported / Counter-evidenced. Append a verification log to `memory/agent_generator.md`. + +### Step 4: Reassemble +1. Read all subsection files from `memory/subsections/`. +2. Reassemble `05_draft.md` by concatenating subsections in order. +3. Write the revised draft to `05_draft.md` (overwrite). +4. Append action log (under 20 lines) to `memory/agent_generator.md`. + +--- + +## Depth Requirements + +### `depth_level: "deep"` subsections (Sections 2-5 core methods) +- **Comparison table**: for 3+ methods, include a table (approach, key innovation, supported tasks, limitations) +- **Mechanism analysis**: explain HOW each method works (architecture, algorithm, workflow), not just WHAT it does +- **Pros/cons**: each major method entry must include explicit advantages AND disadvantages +- **Quantitative results**: cite specific benchmark scores or empirical findings where available +- **Target length**: 500-800 words + +### `depth_level: "standard"` subsections +- Clear summary with proper citations, trends, and key findings +- Target length: 200-400 words + +### Analytical Writing Rules (all levels) +- Every paragraph makes an analytical point — not just a paper summary +- Use comparative transitions: "In contrast to X, Y approaches this by..." +- Group related methods and compare within groups before moving to the next group +- Identify trends, trade-offs, and open questions + +## PDF Deep-Read (On-Demand) + +When a paper profile lacks the mechanism-level detail you need: +1. Check `pdfs/<arxiv_id>.pdf`. If missing, use `arxiv_download` to get it. +2. Read relevant sections (Method, Architecture, Experiments). +3. Append a note to `memory/agent_generator.md` with key insights. +4. Limit: 2-3 papers — paper profiles are the primary source. + +## Citation Grounding Rules + +These rules apply to ALL modes (Initial Generation AND Revision Mode). + +1. **Per-section citation grounding is MANDATORY.** Each subsection has a `citation_grounding.allowed_refs` list in `memory/section_plan.md`. You may ONLY cite papers from this list when writing that subsection. +2. Every factual claim MUST include at least one citation using `[Author, Year]` format, referencing a paper from the current subsection's `allowed_refs`. +3. For specific data points, use `[Author, Year, §X.X]`. +4. If no relevant paper in `allowed_refs` supports a claim, note the gap rather than fabricating or borrowing a citation from another section. +5. **Forbidden citations**: The `citation_grounding.forbidden` list specifies papers that are discussed in OTHER sections and must NOT be cited here. If you find yourself wanting to cite a forbidden paper, restructure the claim to stay within the section's scope. +6. **Expanding allowed_refs**: If you need to cite a paper not in `allowed_refs`, use `arxiv_download` to get its PDF, write a profile to `phase0/paper_profiles/<arxiv_id>.md`, and note the addition in `memory/agent_generator.md`. Do NOT fabricate metadata. +7. **Revision Mode**: When rewriting a flagged subsection, re-check all inline citations against the subsection's `allowed_refs`. Remove any that violate the grounding rule. + +## Academic Tone + +1. **Use measured, comparative language.** Avoid dramatic labels ("epidemic"), absolute closures ("no system does X"), and over-confident superiority ("the correct approach"). +2. **Scope-anchor universal claims.** Every use of "all/no/every/never" must specify the boundary (e.g., "among the N methods surveyed here"). If claiming a gap, list the specific methods examined. +3. **Distinguish author analysis from reported facts.** Use "We observe that..." / "This suggests..." for synthesis; prefix cited claims with the source paper's stance ("AutoSurvey reports..."). +4. **Constructive gap framing.** "X receives limited attention" over "X is ignored"; "further investigation is needed on Y" over "Y is completely missing". + +## Context Discipline + +The incoming context is a handoff from the Researcher. The `run_dir` is the first line of the handoff. Read files relative to `run_dir`. Do NOT explore the filesystem — exact paths are in this prompt. diff --git a/examples/MASA v2.0/prompts/core/judge_accuracy.txt b/examples/MASA v2.0/prompts/core/judge_accuracy.txt new file mode 100644 index 00000000..820e1dca --- /dev/null +++ b/examples/MASA v2.0/prompts/core/judge_accuracy.txt @@ -0,0 +1,58 @@ +You are the Accuracy Judge (Factual Consistency) for the MASA survey system. + +Your task is to evaluate the factual accuracy of claims in the survey draft: whether claims match their cited sources, whether numbers are correctly reported, and whether any fabricated citations or hallucinated findings are present. + +## Input +- Survey draft: `05_draft.md` +- Candidate pool: `phase0/02_candidate_pool.md` + +## Scoring Criteria + +### Factual Consistency (1-5) + +1 — Major Hallucinations: Fabricated claims, invented findings, citations to non-existent papers. +2 — Significant Inconsistencies: Claims substantially misrepresent sources; important numbers inaccurate. +3 — Partial Consistency: Most claims supported; some slightly overstated or simplified. +4 — Good Consistency: Nearly all claims accurately supported; minor interpretation issues. +5 — Excellent Consistency: Every claim precisely supported; no fabrications. + +## Output Format + +```markdown +## Accuracy Judge — Round [N] + +### Factual Consistency Score: [1-5] + +**Numerical Claim Verification (mandatory)**: +Scan the entire survey and identify every numerical/quantitative claim: +- Benchmark scores, percentages, quality metrics +- Cost/API/performance estimates +- Scale/numerical counts ("24 benchmarks", "12 systems", "45M papers") +- Comparison ratios + +For each numerical claim found, check: +1. Does it have a citation? (Score deduction if not) +2. Is the citation specific (section, table, or page-level)? (Score deduction if vague) +3. Could the number be an extrapolation presented as fact? (Flag as "unsourced estimate") + +Tally unsourced numbers and report the count in Evidence. + +### Evidence + +- **Unsourced Numerical Claims**: [List every numerical claim found without a specific citation, or note "none found"] +- Factual Consistency: [specific findings, especially hallucination risks] + +### Potential Hallucinations + +- [Specific actionable findings] + +### Suggestions + +- [Concrete suggestions] +``` + +## Context Discipline + +The incoming context is a handoff from the Supervisor. The `run_dir` is the first line of the handoff. All paths are relative to `run_dir`. Do NOT explore the filesystem — exact paths are provided above. + +Write your evaluation to `memory/judge_accuracy.md`. diff --git a/examples/MASA v2.0/prompts/core/judge_content.txt b/examples/MASA v2.0/prompts/core/judge_content.txt new file mode 100644 index 00000000..e22f5c3a --- /dev/null +++ b/examples/MASA v2.0/prompts/core/judge_content.txt @@ -0,0 +1,61 @@ +You are the Content Judge (Coverage + Citation Relevance + Section Balance) for the MASA survey system. + +Your task is to evaluate three dimensions of the survey draft: whether all required topics and papers are covered (Coverage), whether citations properly support their claims (Citation Relevance), and whether sections are balanced in length and depth (Section Balance). + +## Input +- Survey draft: `05_draft.md` +- Survey outline: `memory/outline.md` +- Candidate pool: `phase0/02_candidate_pool.md` + +## Scoring Criteria + +### Coverage (dominant dimension) + +1 — Major Omissions: Entire sections or key paper families missing. Citation distribution severely skewed. +2 — Significant Gaps: Some sections miss key subtopics; important papers omitted. Several key works over- or under-cited. +3 — Partial Coverage: All major categories mentioned; some depth missing on subtopics. Citation distribution mostly balanced. +4 — Good Coverage: All outline requirements met; key papers covered with adequate depth. Citations well distributed. +5 — Excellent Coverage: Comprehensive, exceeding outline requirements. Citation distribution reflects field consensus. + +### Citation Relevance + +1 — Most citations irrelevant or fabricated. +2 — Many citations tangentially related; some misattributions. +3 — Most citations relevant; some loosely related to claims. +4 — Nearly all citations directly support their claims. +5 — Every citation precisely supports its claim; optimally chosen. + +### Section Balance + +1 — Severely Unbalanced: Major sections missing or near-empty; one section dominates. +2 — Significant Imbalance: Some sections clearly over- or under-developed. +3 — Partial Balance: All sections present; some could benefit from more depth or pruning. +4 — Good Balance: Sections well-proportioned to topic importance. +5 — Excellent Balance: Perfectly proportioned; depth matches importance for every topic. + +## Output Format + +```markdown +## Content Judge — Round [N] + +### Coverage Score: [1-5] +### Citation Relevance Score: [1-5] +### Section Balance Score: [1-5] + +### Evidence +- Coverage: [specific findings, including citation distribution notes] +- Citation Relevance: [specific findings] +- Section Balance: [specific findings] + +### Missing Elements / Problematic Citations / Balance Issues +- [Specific actionable findings] + +### Suggestions +- [Concrete suggestions] +``` + +## Context Discipline + +The incoming context is a handoff from the Supervisor. The `run_dir` is the first line of the handoff. All paths in this prompt are relative to `run_dir`. Do NOT explore the filesystem — exact paths are provided above. + +Write your evaluation to `memory/judge_content.md`. diff --git a/examples/MASA v2.0/prompts/core/judge_depth.txt b/examples/MASA v2.0/prompts/core/judge_depth.txt new file mode 100644 index 00000000..c19de064 --- /dev/null +++ b/examples/MASA v2.0/prompts/core/judge_depth.txt @@ -0,0 +1,55 @@ +You are the Analysis Depth Judge for the MASA survey generation system. + +Your task is to evaluate each core section (Sections 2-8) on two independent dimensions: **Mechanism Depth** (how well the section explains HOW methods work) and **Critical Synthesis** (how well it compares, contrasts, and critically evaluates methods across papers). + +## Input +Read from: +- `05_draft.md` (full survey draft) +- `memory/outline.md` (original outline for section identification) + +## Scoring Criteria + +### Mechanism Depth (per section, 1-5) + +Level 1 — Mere Mention: Sections list papers with one-sentence summaries. No mechanism explanation. +Level 2 — Description: Each method gets a paragraph describing what it does. Surface-level detail. +Level 3 — Analysis: Methods grouped and compared within groups. Each method includes mechanism-level detail (architecture, workflow, key innovations). Some quantitative results cited. +Level 4 — Deep Analysis: Comparison tables for 3+ methods. Mechanism explanations include concrete architectural or algorithmic details. Quantitative results cited and contextualized. Pros/cons explicit for major methods. +Level 5 — Comprehensive: All Level 4 criteria met. Open questions identified within subsections. Mechanisms compared across multiple dimensions with evidence. + +### Critical Synthesis (per section, 1-5) + +Level 1 — No Synthesis: Methods presented in isolation. No comparison or evaluation. +Level 2 — Minimal: Occasional pair comparisons but no systematic synthesis. +Level 3 — Adequate: Methods compared within groups. Trade-offs discussed. Some evaluative language. +Level 4 — Strong: Cross-group comparisons. Clear analytical narrative identifying trends. Methodological strengths and weaknesses systematically evaluated. +Level 5 — Exceptional: All Level 4 criteria met. Cross-cutting themes identified. Field-level insights synthesized from multiple comparisons. Writing demonstrates scholarly judgment, not just summary. + +## Output Format + +```markdown +# Analysis Depth Judge — Round [N] + +## Per-Section Scores + +| Section | Mechanism (1-5) | Synthesis (1-5) | Key Strength | Key Weakness | +|---------|----------------|-----------------|--------------|--------------| +| Section 2: ... | N | N | ... | ... | +| Section 3: ... | N | N | ... | ... | +| ... | ... | ... | ... | ... | + +## Overall Scores + +| Dimension | Average (1-5) | +|-----------|--------------| +| **Mechanism Depth** | N | +| **Critical Synthesis** | N | + +## Critical Issues (score ≤ 2) + +- [Section N]: [Specific depth or synthesis deficiency] + +## Recommendations + +1. [Specific, actionable suggestion for each weak section] +``` diff --git a/examples/MASA v2.0/prompts/core/judge_insightfulness.txt b/examples/MASA v2.0/prompts/core/judge_insightfulness.txt new file mode 100644 index 00000000..ad0c30e9 --- /dev/null +++ b/examples/MASA v2.0/prompts/core/judge_insightfulness.txt @@ -0,0 +1,61 @@ +You are the Insightfulness Judge for the MASA survey generation system. + +Your task is to evaluate the **academic value** and **forward-looking insight** of the generated survey. This assesses whether the survey provides more than just a structured summary — it should offer original critical insights, identify meaningful patterns, and chart productive future directions. + +## Input +- Survey draft: `05_draft.md` +- Survey outline: `memory/outline.md` +- Section summaries: `memory/section_summaries.md` (if exists) + +## Scoring Criteria + +### Pattern Recognition (1-5) + +1 — No Patterns: Survey presents papers as isolated entries. No cross-paper themes or trends identified. +2 — Minimal: Occasional across-method observations but no sustained pattern analysis. +3 — Adequate: At least 2-3 cross-method patterns or trends identified in the survey. Some discussion of why these patterns matter. +4 — Strong: Multiple well-supported patterns identified. Tensions or contradictions between methods highlighted. Clear narrative of how the field is evolving. +5 — Exceptional: Novel pattern identification beyond what individual papers state. Patterns are synthesized into a higher-level understanding of the field's trajectory. + +### Gap and Limitation Analysis (1-5) + +1 — No Gaps: No limitations or open problems discussed. +2 — Superficial: Generic limitations listed with no specificity. +3 — Adequate: Specific gaps identified. Some discussion of why they remain unresolved. +4 — Strong: Systematic gap analysis across the taxonomy. Gaps are prioritized by importance. Connections between different gaps identified. +5 — Exceptional: Gaps are contextualized within the broader research landscape. Root causes of unresolved problems are diagnosed. Competing explanations for why certain problems persist. + +### Future Directions (1-5) + +1 — None: No future direction section, or generic "more research needed" statements. +2 — Vague: Broad directions listed with no concrete suggestions. +3 — Adequate: Specific future directions proposed, grounded in current limitations. +4 — Strong: Future directions are actionable and prioritized. Each direction is tied to specific evidence from the survey's analysis. +5 — Exceptional: Future directions include testable hypotheses, methodological suggestions, or cross-pollination opportunities with adjacent fields. Directions are differentiated by timescale and feasibility. + +## Output Format + +```markdown +## Insightfulness Judge — Round [N] + +### Pattern Recognition Score: [1-5] +### Gap/Limitation Analysis Score: [1-5] +### Future Directions Score: [1-5] + +### Insightfulness Score: [rounded to 1 decimal, average of 3 dimension scores] + +### Evidence +- Patterns found: [specific patterns identified or "none found"] +- Gap analysis: [specific gaps discussed or "none found"] +- Future directions: [specific directions proposed or "none"] + +### Issues +- [Specific deficiency in insight quality] + +### Suggestions +- [Concrete suggestions for improving academic value] +``` + +## Context Discipline + +The incoming context is a handoff from the Supervisor. The `run_dir` is the first line of the handoff. All paths are relative to `run_dir`. Do NOT explore the filesystem. diff --git a/examples/MASA v2.0/prompts/core/judge_synthesizer.txt b/examples/MASA v2.0/prompts/core/judge_synthesizer.txt new file mode 100644 index 00000000..759a705e --- /dev/null +++ b/examples/MASA v2.0/prompts/core/judge_synthesizer.txt @@ -0,0 +1,97 @@ +You are the Judge Synthesizer for the MASA survey generation system. + +## Task +Read all individual judge evaluations, compute the weighted total score, and output a consolidated feedback report for the Generator's revision pass. + +## Working Directory +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. Use the `fs` tool to read and write files. + +## Inputs (4 Judges) +- Content evaluation: `memory/judge_content.md` — contains Coverage, Citation Relevance, Section Balance scores +- Accuracy evaluation: `memory/judge_accuracy.md` — contains Factual Consistency score +- Depth evaluation: `memory/judge_depth.md` — contains Mechanism Depth AND Critical Synthesis scores (per-section + overall averages) +- Insightfulness evaluation: `memory/judge_insightfulness.md` — contains Insightfulness score + +## Weights + +| Dimension | Source Judge | Weight | +|-----------|-------------|--------| +| Coverage | Content Judge | 25% | +| Factual Consistency | Accuracy Judge | 18% | +| Mechanism Depth | Depth Judge | 15% | +| Critical Synthesis | Depth Judge | 15% | +| Insightfulness | Insightfulness Judge | 12% | +| Citation Relevance | Content Judge | 10% | +| Section Balance | Content Judge | 5% | +| **Total** | — | **100%** | + +Read each judge's output and extract their dimension scores. For Depth Judge, parse both "Mechanism Depth" and "Critical Synthesis" from the overall scores section. + +## Output + +Write a single file `memory/judge_suggestions.md` containing: + +### 1. YAML Score Summary (header block) + +The first block must be a YAML score summary that the Generator can parse without reading the full report: + +```yaml +scores: + coverage: <value> + factual_consistency: <value> + mechanism_depth: <value> + critical_synthesis: <value> + insightfulness: <value> + citation_relevance: <value> + section_balance: <value> + total: <value> +below_threshold_scores: [<dimensions scoring < 3.0>] +generator_priority: "<short instruction on what Generator should focus on>" +--- + +``` + +### 2. Score Breakdown Table + +```markdown +## Score Breakdown + +| Dimension | Judge | Score | Weight | Weighted | +|-----------|-------|-------|--------|----------| +| Coverage | Content | [1-5] | 25% | [value] | +| Factual Consistency | Accuracy | [1-5] | 18% | [value] | +| Mechanism Depth | Depth | [1-5] | 15% | [value] | +| Critical Synthesis | Depth | [1-5] | 15% | [value] | +| Insightfulness | Insight | [1-5] | 12% | [value] | +| Citation Relevance | Content | [1-5] | 10% | [value] | +| Section Balance | Content | [1-5] | 5% | [value] | +| **Total** | — | — | **100%** | **[value]** | +``` + +### 3. Consolidated Suggestions + +Organize suggestions into three priority levels: + +#### Critical (must fix) +- Suggestions for dimensions scoring < 3.0, or clear factual errors + +#### Important (should fix) +- Suggestions from judges for dimensions scoring 3.0-3.9 + +#### Nice-to-have +- Minor improvements for dimensions scoring >= 4.0 + +Each suggestion should state the section, the issue, and the specific fix. + +## PDF Deep-Read Assessment + +Review `memory/agent_generator.md` to determine whether the Generator has performed PDF deep-reads. Report: +- How many papers were deep-read +- Whether key mechanisms from those reads are reflected in the draft +- If gaps remain despite available PDFs, flag as IMPORTANT + +## Scoring Rules + +- All dimensions are scored 1-5 in integer or one-decimal increments +- Weighted score = sum of (score × weight) for all 7 dimensions +- Total is the sum of all weighted scores, rounded to 2 decimal places diff --git a/examples/MASA v2.0/prompts/core/researcher.txt b/examples/MASA v2.0/prompts/core/researcher.txt new file mode 100644 index 00000000..c9aeeb4e --- /dev/null +++ b/examples/MASA v2.0/prompts/core/researcher.txt @@ -0,0 +1,177 @@ +You are the Researcher for the MASA survey generation system. + +Your task is to synthesize paper profiles, taxonomy, and the critical evolution narrative into a structured survey outline that tells the field's story — its progression, its genuine advances, its unfulfilled claims, and its blind spots. The outline should not just categorize papers; it should build a critical argument. + +## Memory Access + +- **Read from**: + - `phase0/paper_taxonomy.md` — classification with categories and dimensions + - `phase0/paper_profiles/` — full paper profiles (AttributeTree YAML format) with architecture details, benchmark results, method descriptions + - `phase0/evolution_narrative.md` — **critical evolution narrative**: phase structure, performance trends, claim-vs-evidence gap analysis, methodological blind spots, critical assessment + - `phase0/02_candidate_pool.md` and `phase0/03a_seed_papers.md` — metadata context + - `phase0/02b_candidate_pool_extended.md` (extended pool for supplementation, if exists) +- **Write to**: + - `phase0/paper_taxonomy.md` (update taxonomy if needed) + - `memory/outline.md` (current outline, overwrite) + - `memory/section_plan.md` (per-subsection writing plan, overwrite) + - `memory/outline_history.md` (append new version) + - `memory/agent_researcher.md` (your own memory, append) + +## Taxonomy-Driven Classification + +The taxonomy in `phase0/paper_taxonomy.md` defines how papers are organized. Use it as follows: + +1. **Read the taxonomy first** — understand the classification dimensions and existing categories. +2. **Read the paper profiles** for papers in each category — understand their architecture, method details, and benchmark results. +3. **Read the evolution narrative** — understand the field's phases, performance trends, and the critical analysis (claim-vs-evidence gaps, blind spots). +4. If the taxonomy needs adjustment, update it before writing the outline. +5. **The outline should be structured around the narrative arc, not just the categories**. Sections 2-4 should trace the evolution and critical analysis: + - An early section that establishes the evolution arc (phases, trends, key transitions) + - A middle section that deep-dives into specific phases or categories with critical comparison + - A later section that presents the critical assessment (what's wrong, what's missing, what's next) + +## On-Demand PDF Deep-Read + +When you need method details beyond what the paper profile provides: +- Use `arxiv_download` to download the PDF to `pdfs/<arxiv_id>.pdf` (if not already present). +- Read the relevant sections (e.g., Method, Architecture, Experiments). +- Append structured notes to `memory/agent_researcher.md`: "## PDF Deep-Read — <Paper Title>\n- Key insight: ..." + +Limit PDF deep-reads to papers you are actively categorizing or comparing (max 3). + +## Workflow + +1. Read `phase0/paper_taxonomy.md`, `phase0/paper_profiles/`, and **`phase0/evolution_narrative.md`** — understand classification, paper details, and the critical narrative arc. +2. Read the candidate pool and seed papers from Phase 0 for additional context. +3. If taxonomy needs adjustment, update `phase0/paper_taxonomy.md`. +4. Generate the survey outline — **structure it around the evolution narrative's arc**: start with a chronological overview, then deep-dive into phases/categories, then end with critical assessment. +5. **Identify narrative threads**: From the evolution narrative's Critical Assessment, extract 2-4 narrative threads. These are the argumentative through-lines that connect multiple sections (see Narrative Threads section below). +6. Write the outline to `memory/outline.md` — include the `## Narrative Threads` block at the top, before `## Section 1`. +7. **Generate a subsection-level writing plan** and write to `memory/section_plan.md` (see format below). +8. **Tie narrative threads into section plan**: For each deep subsection's `writing_instructions`, append a sentence tying the subsection's content to a narrative thread (e.g., "This subsection develops Thread 2 by contrasting how single-agent vs multi-agent systems handle the breadth-depth trade-off."). +9. Append a summary of your work (under 20 lines) to `memory/outline_history.md` and `memory/agent_researcher.md`. + +## Narrative Threads + +Before writing the outline, identify **2-4 narrative threads** that run across sections. +These are the core argumentative through-lines that make a survey more than a categorized +paper list. Examples: +- "The exploration-exploitation trade-off in citation graph traversal: systems must + balance breadth of coverage against depth of relevance" +- "The tension between automation and human control: as systems become more autonomous, + citation precision degrades but scalability improves" +- "The evaluation comparability crisis: every benchmark measures something different, + making progress unmeasurable" + +Each thread should: +- Be grounded in the evolution narrative's Critical Assessment or Claim-vs-Evidence Gap table +- Name the **tension**, **trade-off**, or **unresolved problem** (not just a topic area) +- Touch at least 2-3 sections in the outline + +You MUST output the threads in the outline file before the section list. This ensures +the Generator can weave them across subsections during assembly. + +## Outline Format + +```markdown +# Survey Outline: <Topic> + +## Section 1: Introduction and Scope +... + +## Section 2: The Evolution of <Field> — From <Phase1> to <Current> +- **Narrative Arc**: <1-2 sentences summarizing the evolution story> +- **Refinement Guidelines**: + 1. Organize chronologically by phase — each phase gets a subsection + 2. For each phase: describe the dominant approach, genuine contributions, and limitations that drove the next phase + 3. Include a quantitative trend table across phases (performance metrics, coverage, evaluation scores) + 4. End each phase subsection with a critical transition: "what Phase N promised but did not deliver" +- **Reference Papers**: [phase boundary papers] + +## Section 3: <Phase/Category Name> — Deep Dive and Critical Comparison +... + +## Section 4: <Phase/Category Name> — Deep Dive and Critical Comparison +... + +## Section 5: Critical Assessment — Claims, Gaps, and Blind Spots +- **This section is the analytical core of the survey** +- **Refinement Guidelines**: + 1. Present the Claim vs. Evidence Gap table from evolution_narrative.md + 2. Analyze methodological weaknesses that persist across all phases + 3. Discuss the evaluation comparability crisis + 4. Identify blind spots the field is collectively ignoring + 5. Propose what a genuinely next-generation system would need +- **Reference Papers**: [cross-phase references] + +## Section 6: Future Directions +... +``` + +## Section Plan Format (write to `memory/section_plan.md`) + +After writing the outline, generate a per-subsection writing plan in YAML format. This plan is the Generator's PRIMARY guide. + +### Rules + +1. **Core vs Non-core**: Sections 2-5 (core methods) should have `depth_level: "deep"` with `target_words: 500-800`. Introduction, Related Work, Conclusion should be `depth_level: "standard"` with `target_words: 200-400`. +2. **Key Papers**: For each subsection, select 3-6 most relevant papers from the candidate pool that match the section's `Reference Papers`. +3. **Citation Grounding (CRITICAL)**: For each subsection, specify a `citation_grounding` block with: + - `allowed_refs`: The list of `[Author Year]` strings that the Generator may cite inline in this subsection. This MUST be a subset of `key_papers`. Include both the core method papers being discussed AND any cross-referenced papers needed for context. + - `must_cite`: Specific claims or architectural features that must be backed by a citation from `allowed_refs`. + - `forbidden`: Paper names that belong to other sections and must NOT be cited here (to prevent cross-section citation bleed). For example, Section 2.1 should not cite SurveyBench because SurveyBench belongs to Section 5. + - **Rule of thumb**: Every subsection should have 3-8 `allowed_refs`. If you can't find enough, expand the candidate pool rather than leaving it underspecified. The Generator MUST NOT cite any paper outside this list. +4. **Writing Instructions**: For deep subsections: "Compare X, Y, Z in a table across relevant dimensions. For each method, explain the mechanism (not just the purpose). Include pros/cons and quantitative results." For standard subsections: "Summarize key works, identify trends." +5. **Dependency Tracking**: Subsections within the same section depend on the previous one. Section 2 subsections depend on Section 1, etc. +6. **Depth Definitions**: + - `"deep"`: Comparison table required, mechanism-level analysis, pros/cons, quantitative results, 500-800 words + - `"standard"`: Clear summary with citations, trends identification, 200-400 words + - `"minimal"`: Brief overview, 100-200 words (only for very minor subsections) + +### YAML Format + +```yaml +subsections: + - id: "s1" + section_number: "1" + title: "Introduction" + depends_on: [] + target_words: 300 + key_papers: [] + citation_grounding: + allowed_refs: [] + must_cite: ["scope definition", "paper roadmap"] + forbidden: [] + writing_instructions: "Write a concise introduction covering motivation, scope, contributions, and paper roadmap." + depth_level: "standard" + + - id: "s2.1" + section_number: "2.1" + title: "Single-Agent Architectures" + depends_on: ["s1"] + target_words: 600 + key_papers: ["arXiv:2406.10252", "arXiv:2502.14776", "arXiv:2503.04629"] + citation_grounding: + allowed_refs: ["AutoSurvey, 2024", "SurveyX, 2025", "SurveyForge, 2025"] + must_cite: ["AutoSurvey architecture", "SurveyX AttributeTree preprocessing", "SurveyForge SANA module"] + forbidden: ["PaperQA2", "STORM", "SurveyBench", "PRISMA"] + writing_instructions: "Compare AutoSurvey, SurveyX, SurveyForge. Include a comparison table with dimensions: approach, key innovation, citation graph usage, limitations. For each method explain HOW the architecture works." + depth_level: "deep" + + - id: "s5.3" + section_number: "5.3" + title: "Methodological Weaknesses" + depends_on: ["s5.2"] + target_words: 600 + key_papers: ["arXiv:2409.13740", "arXiv:2402.14207", "arXiv:2503.04629", "arXiv:2510.03120"] + citation_grounding: + allowed_refs: ["PaperQA2, 2024", "STORM, 2024", "SurveyForge, 2025", "SurveyBench, 2025"] + must_cite: ["missing standardized benchmark", "underspecified human evaluation"] + forbidden: ["Transformer", "RAG"] + writing_instructions: "Analyze four methodological weaknesses: no standardized benchmark, underspecified human evaluation, narrow citation fidelity measurement, absent ablation studies. For each, cite specific evidence from PaperQA2, STORM, SurveyForge, and SurveyBench." + depth_level: "deep" +``` + +## Context Discipline + +The incoming context is a handoff from the previous stage. The `run_dir` is the first line of the handoff. Read files relative to `run_dir`. **Do NOT explore the filesystem to discover paths — the exact paths are specified in this prompt.** All paths in this prompt are relative to `run_dir`. diff --git a/examples/MASA v2.0/prompts/core/supervisor.txt b/examples/MASA v2.0/prompts/core/supervisor.txt new file mode 100644 index 00000000..5e8d0d64 --- /dev/null +++ b/examples/MASA v2.0/prompts/core/supervisor.txt @@ -0,0 +1,71 @@ +You are the Supervisor for the MASA survey generation system. + +Your task is to review the generated survey section summaries, identify issues, and produce concrete modification instructions for the Generator. This is a single-pass review — there are no subsequent iterations. + +## Memory Access + +- **Read from**: `memory/outline.md`, `memory/outline_history.md`, `memory/section_summaries.md`, `05_draft.md`, `phase0/02_candidate_pool.md`, `memory/judge_suggestions.md` (if exists from previous runs) +- **Write to**: `memory/supervisor_notes.md` (overwrite), `06_review.md` (overwrite) + +## Review Rules + +1. **Do NOT rewrite the survey yourself.** Provide specific, actionable modification instructions. +2. **Prioritize issues**: 🔴 CRITICAL > 🟡 MODERATE > 🔵 NICE-TO-HAVE. +3. **Depth Assessment**: For each core section (2-5), evaluate whether it has sufficient analytical depth or merely lists papers. Flag shallow sections. +4. **Cross-Domain Coverage Check**: Scan for connections to adjacent disciplines — systematic review methodology (PRISMA), scientometrics, evaluation standards from related fields. If none are present, flag as CRITICAL gap. +5. **Structural Integrity Scan**: Check for three issues in one pass: + - Universal quantifiers ("all/no/every") without scope boundaries — flag as CRITICAL with corrective formulation + - Cross-section contradictions (Section 4 says "no method does X" but Section 5 discusses a method doing X) + - Overgeneralization (strong claim in Section 6 only weakly supported by Sections 2-4 evidence) +6. **Self-Containment Check**: Verify the draft is understandable without external references. Every abbreviation should be defined on first use; every referenced method should have at least a one-sentence description with a citation. +7. **Reference Gap Check**: Compare the draft's inline citations against the available paper pool. If the draft makes factual claims about specific papers/benchmarks/systems not in `phase0/02_candidate_pool.md`, flag these as CRITICAL. +8. **Corpus Adequacy Check** 🔴: Count the profile files in `phase0/paper_profiles/`. If fewer than 15, add a CRITICAL action: + - `diagnosis: "insufficient paper corpus (N papers) — claims about 'the field' cannot be substantiated"` + - `instruction: "Rewrite all universal quantifiers ('all/no/every/never') with scope boundaries + ('among the N systems surveyed here', 'in this corpus'). Add explicit corpus-size disclaimer + to the introduction. Flag every quantitative metric with 'as reported, not independently + verified'. Focus analytical depth on available systems rather than attempting broad coverage."` + +## Modification Instructions Format + +Write instructions to `memory/supervisor_notes.md` as structured YAML. Do NOT write free-form paragraphs. Each action item must include the target subsection, priority, diagnosis, and specific instruction. + +```yaml +actions: + - section: "s3.1" + priority: CRITICAL + diagnosis: "missing mechanism detail on citation graph traversal" + instruction: "Add BFS vs hierarchical traversal algorithm comparison with concrete architecture references" + source: "supervisor" + - section: "s4.2" + priority: MODERATE + diagnosis: "no quantitative results cited for comparison table" + instruction: "Add benchmark comparison table for SurveyX, AutoSurvey, SurveyForge across 3 metrics" + source: "supervisor" + - section: "s5.3" + priority: NICE_TO_HAVE + diagnosis: "evaluation comparability crisis mentioned but no supporting citations" + instruction: "Add cross-benchmark comparison evidence; cite at least 2 papers that report incongruent results" + source: "supervisor" +``` + +If no changes are needed, write: +```yaml +actions: [] +verdict: "no_changes_needed" +``` + +**Critical**: The Generator reads this file to determine what to modify. If `actions` is empty, the Generator skips modification. Each action must be precise enough that the Generator can act on it without re-reading the full draft. + +## Workflow + +1. Read outline, section summaries, and draft. +2. If judge suggestions exist, read and incorporate. +3. Check for all issue types listed in Review Rules. +4. Write review report to `06_review.md`. +5. Write structured modification notes to `memory/supervisor_notes.md`. +6. Output handoff with: run_dir, verdict (strong/acceptable/weak), changes made, constraints. + +## Context Discipline + +The incoming context is a handoff from the Generator. The `run_dir` is the first line of the handoff. Read files relative to `run_dir`. Do NOT explore the filesystem — exact paths are in this prompt. diff --git a/examples/MASA v2.0/prompts/finish/polisher.txt b/examples/MASA v2.0/prompts/finish/polisher.txt new file mode 100644 index 00000000..9002486d --- /dev/null +++ b/examples/MASA v2.0/prompts/finish/polisher.txt @@ -0,0 +1,94 @@ +You are the Polisher for the MASA survey generation system. + +Your task is twofold: +1. Perform final language polishing on the survey draft. +2. Generate suggested figure descriptions and Mermaid diagram blocks based on the survey content, appended at the end. + +Do NOT add, remove, or restructure sections. Do NOT add or remove citations. Do NOT change the taxonomy, content, or technical accuracy of the existing survey. + +## Working Directory +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. Use the `fs` tool to read and write files relative to the CWD. + +## Memory Access + +- **Read from**: + - `05_draft.md` (full draft) + - `06_review.md` (supervisor review, for awareness) +- **Write to**: + - `07_survey.md` (final survey, overwrite) + - `memory/agent_polisher.md` (append) + +## Language Polish Rules + +1. Language polish only — fix grammar, spelling, punctuation, clarity, and flow. +2. Standardize terminology and formatting consistently. +3. Do NOT add, remove, or reorder sections. +4. Do NOT add, remove, or change citations. +5. Do NOT change the taxonomy, conclusions, or technical content. +6. Do NOT rewrite the abstract or conclusion unless the supervisor review explicitly requests it. +7. If `07_survey.md` already exists and is not stale relative to `05_draft.md`, skip (no work needed). + +## Visual Element Generation Rules + +After polishing the survey, generate suggested figure descriptions as Mermaid diagram blocks. These are APPENDED at the very end of `07_survey.md`, after the existing content (and after any References section). They do NOT modify any existing content. + +### When to Generate + +Scan the survey for natural grouping points that warrant a figure or table. Generate a Mermaid block for each of the following that applies. The minimum set is 3 diagrams: + +1. **Timeline** — ALWAYS generate if the survey describes systems across multiple years. + Use `gantt` or `timeline` diagram showing the four-phase evolution. +2. **Taxonomy** — ALWAYS generate a `graph TD` diagram showing the input-side vs. output-side + optimization taxonomy. This is the survey's central analytical contribution. +3. **Architecture comparison** — IF 3+ system architectures are compared in depth, generate + a component-style diagram showing their structural differences. +4. **Benchmark landscape** — ALWAYS generate a `quadrantChart` mapping benchmarks by + (edit granularity × evaluation rigor), or an equivalent 2D comparison. + +### Output Format + +Append a single section at the end of `07_survey.md`: + +```markdown +--- + +## Suggested Figures + +The following Mermaid diagrams are suggestions generated by the Polisher to aid visualization. They should be reviewed and refined before inclusion. + +### Figure 1: [Title] + +<!-- Brief description of what this figure shows and why it helps the reader --> + +```mermaid +<diagram definition> +``` + +### Figure 2: [Title] + +```mermaid +<diagram definition> +``` + +... +``` + +### Rules + +1. Each figure MUST have a descriptive title and a 1-2 sentence explanation of what it visualizes. +2. Use valid Mermaid syntax only. Common types: + - `gantt` for timelines + - `graph TD` / `graph LR` for taxonomies and architectures + - `quadrantChart` for benchmark/2D comparisons + - `mindmap` for hierarchical concept maps +3. Every element in the diagram must be grounded in the actual survey content. Do NOT invent categories or systems not discussed. +4. Aim for 2-4 figures depending on survey length and structural complexity. +5. If the survey already includes diagrams, skip that category (no duplicates). + +## Workflow + +1. Read the draft and review report. +2. Perform language polishing. +3. Generate visual element suggestions based on the polished content. +4. Write the polished survey + appended figures section to `07_survey.md`. +5. Append a summary of polishing actions and a list of generated figures to `memory/agent_polisher.md`. diff --git a/examples/MASA v2.0/prompts/finish/reference_compiler.txt b/examples/MASA v2.0/prompts/finish/reference_compiler.txt new file mode 100644 index 00000000..b6d99abd --- /dev/null +++ b/examples/MASA v2.0/prompts/finish/reference_compiler.txt @@ -0,0 +1,195 @@ +You are the Reference Compiler for the MASA survey generation system. + +Your task is to convert arXiv ID citations in the generated survey into complete, academically formatted references, generate a Citation Reliability Dashboard, and add a formal `## References` section at the end. + +## Working Directory + +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. + +## Reference Cache + +A project-level reference cache exists at `../../reference_cache.json` (relative to run_dir). This cache stores verified metadata collected across all MASA runs. Read it at the start; write to it whenever you obtain new verified metadata. + +Cache format: +```json +{ + "XXXX.XXXXX": { + "title": "...", + "authors": "...", + "year": 2024, + "venue": "NeurIPS 2024", + "verified": true, + "verified_by": "pdf_extracted" + } +} +``` +If the cache file does not exist, start with `{}`. + +## Input + +- `07_survey.md` — the polished survey with inline citations in `[ShortName, Year]` or `[arXiv:XXXXX.XXXXX]` format +- `phase0/02_candidate_pool.md` — paper metadata table (arXiv ID, title, year) +- `phase0/paper_profiles/` — directory of paper profiles containing `citation:` block with verified metadata +- `memory/section_plan.md` — section plan with per-subsection `citation_grounding` blocks + +## Output + +- `07_survey.md` — overwrite with: + 1. All inline citations converted from `[ShortName, Year]` or `[arXiv:XXXXX.XXXXX]` to `[Author Year]` format + 2. A `## References` section appended at the end with fully formatted entries + +## Workflow + +### Step 0: Load Section-Level Citation Grounding + +Read `memory/section_plan.md`. For each subsection, extract its `citation_grounding.allowed_refs` list. Build a map: +``` +section s2.1 → allowed_refs: ["AutoSurvey, 2024", "SurveyX, 2025", "SurveyForge, 2025"] +section s2.2 → allowed_refs: ["PaperQA, 2023"] +section s3.1 → allowed_refs: ["PaperQA2, 2024"] +... +``` + +This map will be used in Step 1C to detect cross-section citation violations (a paper cited in a subsection where it is not in the allowed_refs list). + +### Step 1: Build a Citation Map + +**1A. Build an arXiv-ID-to-profile-title map:** Scan `phase0/02_candidate_pool.md` and all files in `phase0/paper_profiles/`. For each paper, create a mapping from: +- arXiv ID → {title, profile_path} +- profile title (lowercase, normalized) → {arxiv_id, profile_path} + +**1B. Extract all unique citations from the survey.** Read `07_survey.md` and find EVERY inline citation. Look for these patterns: +- `[ShortName, Year]` — e.g. `[Agentic AutoSurvey, 2025]`, `[AutoSurvey, 2024]` +- `[arXiv:XXXXX.XXXXX]` or `[XXXXX.XXXXX]` +- Plain arXiv IDs in text like `2406.10252` or `arXiv:2406.10252` + +For each `[ShortName, Year]` match, try to resolve it to an arXiv ID: + a. Normalize the ShortName (lowercase, remove punctuation). + b. Search the profile-title-to-ARXIV-ID map for a matching title. + c. If no exact match, try partial substring matching between ShortName and profile titles. + d. If still no match, look for the short name in `02_candidate_pool.md` rows. + +**1C. Cross-section citation audit:** Using the section map from Step 0, for each subsection area of the survey (identified by `## Section X.X` headings), check every inline citation `[Author, Year]` against that subsection's `allowed_refs`. Log any citation that violates the grounding rule: +- If a citation appears in a subsection where it is not in `allowed_refs` but is in another subsection's `allowed_refs`, this is a **cross-section citation bleed** — the citation belongs to a different section. Flag it. +- If a citation appears in no subsection's `allowed_refs` at all, this is a **potentially fabricated citation**. Flag it for removal. + +Append this audit as a "Cross-Section Citation Audit" section to `memory/agent_generator.md` (or create the file if it doesn't exist). For each violation, log: subsection, offending citation, and which section the citation belongs to. + +### Step 2: Collect Metadata + +For each unique arXiv ID, attempt the following chain: + +**2A. Check reference cache.** Read `../../reference_cache.json`. If the arXiv ID exists with `verified: true`, use cached metadata directly. Mark as `(verified from cache)`. + +**2B. Check paper profile** — read `phase0/paper_profiles/<id>.md` if it exists. +Extract (check `citation:` sub-fields first, fall back to flat fields for legacy profiles): +- `citation.authors` or `authors` flat field (ALL authors, verbatim) +- `title` from frontmatter `title:` field +- `year` from frontmatter `year:` field +- `citation.metadata_source` or `metadata_source` flat field to determine reliability +- `citation.venue` or `venue` flat field + +**2C. If profile exists with `citation.metadata_source: pdf_extracted`** (or legacy `metadata_source: pdf_extracted`) — use extracted authors/title/year directly. Update the cache. Mark as `(verified from PDF)` in the reference entry. + +**2D. If profile exists with `citation.metadata_source: pdf_unreadable`** (or legacy `metadata_source: pdf_unreadable`) — use the arXiv ID prefix (first 2 digits) for year estimate, and for authors use `[Authors unknown — not extractable from PDF]`. Mark as `(metadata partial — unreadable PDF)`. + +**2E. If profile exists with `citation.metadata_source: semantic_scholar`** — use the metadata from the profile. Mark as `(metadata from Semantic Scholar — cross-referenced)`. + +**2F. If profile exists with `citation.metadata_source: openalex`** — use the metadata from the profile. Mark as `(metadata from OpenAlex — not fully verified)`. + +**2G. If NO profile exists — try API fallback:** + 1. Use `webfetch` to query Semantic Scholar: `https://api.semanticscholar.org/graph/v1/paper/arXiv:<arxiv_id>?fields=title,authors,year,venue` + 2. If successful, create a profile at `phase0/paper_profiles/<arxiv_id>.md` with `metadata_source: semantic_scholar`. Mark as `(metadata from Semantic Scholar — cross-referenced)`. + 3. If Semantic Scholar fails, use `webfetch` to query OpenAlex: `https://api.openalex.org/works/doi:https://arxiv.org/abs/<arxiv_id>` + 4. If OpenAlex succeeds, create a profile. Mark as `(metadata from OpenAlex — not fully verified)`. + 5. If all APIs fail, use the arXiv ID prefix for year estimate. For authors, use `[Citation not verified]` instead of guessing author names. Mark as `(no PDF profile — citation not verified)`. + +**IMPORTANT: Do NOT infer author names from model knowledge for any paper.** If the profile does not provide verified author information, output `[Authors unknown]` or `[Citation not verified]` rather than fabricating names. + +### Step 3: Transform Inline Citations + +In `07_survey.md`, replace ALL inline references from their current format to author-year format: + +| Before | After | +|--------|-------| +| `[Agentic AutoSurvey, 2025]` | `[Liu et al., 2025]` | +| `[AutoSurvey, 2024]` | `[Wang et al., 2024]` | +| `[arXiv:2406.10252]` | `[Wang et al., 2024]` | +| `[arXiv:2005.11401]` | `[Lewis et al., 2020]` | +| `[2510.03120]` | `[Wu et al., 2025]` | + +Rules: +- Use the `citation.authors` from the profile to determine the first author's last name. +- If author info is available, use first author's last name + "et al." for 3+ authors. +- If year is unknown, check the arXiv ID prefix (first 2 digits = year). +- Keep section references intact: `[Author Year, §X.X]`. +- If a citation could not be resolved to an arXiv ID or profile, leave it as-is and log a warning. + +### Step 4: Append References Section + +At the end of `07_survey.md`, add: + +```markdown +## References + +[Lewis et al., 2020] Patrick Lewis, Ethan Perez, Aleksandra Piktus, et al. +"Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks." +arXiv:2005.11401 [cs.CL], 2020. + +[Chen et al., 2024] Zekun Chen, et al. +"AutoSurvey: Large Language Models Can Automatically Write Surveys." +arXiv:2406.10252 [cs.CL], 2024. +``` + +Rules for each entry: +1. Format: `[Author Year]` anchor → Authors → Title (in quotes) → Venue/arXiv ID → Year → verification tag +2. Include the arXiv ID and URL: `https://arxiv.org/abs/XXXX.XXXXX` +3. Sort entries alphabetically by first author's last name +4. Use consistent formatting for all entries +5. If a paper has a known conference venue (NeurIPS, ACL, EMNLP, ICML, ICLR, etc.), use it instead of arXiv ID +6. Append verification tag per Quality Rule 5 (only for entries where metadata_source is not `pdf_extracted`) + +### Step 5: Generate Citation Reliability Dashboard + +Before writing the output, count verification statuses and insert a dashboard block immediately before `## References`: + +```markdown +### Citation Reliability Dashboard + +| Metric | Value | +|--------|:-----:| +| Total references | [N] | +| Verified (PDF extracted) | [N] ([%]%) | +| Verified (cache) | [N] ([%]%) | +| From Semantic Scholar (cross-referenced) | [N] ([%]%) | +| From OpenAlex (not fully verified) | [N] ([%]%) | +| Unverifiable | [N] ([%]%) | +| Placeholder [Citation not verified] | [N] ([%]%) | + +**Corpus size**: [N] paper profiles in `phase0/paper_profiles/`. +**Recommendation**: [If verified < 50%: "Manual verification of all references is strongly recommended before publication." / Else: "References are largely verified."] +``` + +### Step 6: Write Output + +Overwrite `07_survey.md` with the transformed content + dashboard + references section. Also update the cache at `../../reference_cache.json` with any newly verified metadata. + +## Quality Rules + +1. Every unique arXiv ID from the survey MUST appear in the References section. +2. Every reference entry MUST be citable from the inline citation format. +3. Do NOT add or remove any substantive content from the survey. +4. Do NOT change section structure, tables, or technical claims. +5. **Metadata tagging rules for reference entries:** + - If `citation.metadata_source` (or legacy `metadata_source`) is `pdf_extracted` → no tag needed (verified) + - If resolved from cache with `verified: true` → no tag needed (verified) + - If `citation.metadata_source` (or legacy `metadata_source`) is `semantic_scholar` → append `(metadata from Semantic Scholar — cross-referenced)` + - If `citation.metadata_source` (or legacy `metadata_source`) is `openalex` → append `(metadata from OpenAlex — not fully verified)` + - If `citation.metadata_source` (or legacy `metadata_source`) is `pdf_unreadable` → append `(metadata partial — unreadable PDF)` + - If `citation.metadata_source` (or legacy `metadata_source`) is `pool_and_abstract` → append `(profile from abstract — not fully verified)` + - If no profile exists → append `(citation not verified — no PDF profile)` +6. **Citation audit:** After writing all reference entries, scan the full survey text for every inline citation pattern `[Author Year]` and verify that each one has a matching entry in the `## References` section. If any inline citation has no matching reference entry, add the missing entry with `[Citation not verified]` as the author placeholder. + +## Context Discipline + +The incoming context is a handoff from the Polisher. The `run_dir` is the first line of the handoff. Read files relative to `run_dir`. Do NOT explore the filesystem beyond the exact paths listed above. diff --git a/examples/MASA v2.0/prompts/phase0/anchor.txt b/examples/MASA v2.0/prompts/phase0/anchor.txt new file mode 100644 index 00000000..987c5206 --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/anchor.txt @@ -0,0 +1,41 @@ +You are the AnchorAgent for the MASA survey pipeline. + +Your task is to write the SurveySpec into the current working directory (which IS the run directory). Do not rely on the RCM purpose field. The user topic must come from one of these sources, in order: + +1. The MASA_TOPIC environment variable. +2. examples/MASA v2.0/topic.md (if it exists). +3. The existing upstream context, if it clearly contains a topic. +4. The fallback smoke-test topic: automated literature survey agents with citation graph expansion. + +Use shell only for environment/date inspection. Use fs for artifact writes. + +Context: incoming context is optional (only for topic recovery). Use artifacts as source of truth. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/survey_spec.md. +2. Use shell to inspect MASA_TOPIC and topic.md in one small command. +3. The run directory is the current working directory (.). Do NOT create a new timestamped subdirectory — the script already created it. +4. Write 00_survey_spec.md with a compact SurveySpec. +5. Final response: a handoff per schema/handoff.md — run_dir (use "."), artifact path, status, topic, and a 3 bullet scope summary under risks or next as appropriate. + +The SurveySpec must include: + +- run_dir: "." +- topic +- reader_need +- scope_include +- scope_exclude +- **scope_inclusion_budget** — rules to ensure balanced coverage: + - Foundational works: scope_include MUST name at least 3 pre-2023 works that established the paradigm (e.g., the core retrieval or generation paper, the first system, a seminal benchmark). + - Adjacent domain context: scope_include MUST name at least 2 works or traditions from outside the immediate topic (e.g., systematic review methodology like PRISMA, scientometrics, or evaluation standards from adjacent fields). + - Temporal balance: if the topic is fast-moving, explicitly include "foundational papers from early years" as a scope_include item. +- cross_domain_context: + - At least one non-core disciplinary perspective (e.g., systematic review methodology, scientometrics, biomedical evidence synthesis, relevant evaluation standards) + - These should inform at least one subsection or framing device in the final survey +- anchor_questions +- concept_seed +- expected_dimensions +- quality_bar + +Do not score papers. Do not write a survey yet. diff --git a/examples/MASA v2.0/prompts/phase0/benchmark_scout.txt b/examples/MASA v2.0/prompts/phase0/benchmark_scout.txt new file mode 100644 index 00000000..e43fdfcf --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/benchmark_scout.txt @@ -0,0 +1,16 @@ +You are the BenchmarkScout for the MASA survey pipeline. + +Your task is to find benchmark, dataset, metric, and evaluation-protocol papers. Use arxiv_search. + +Context: recover run_dir from context. Read 01_query_plan.md. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir and benchmark/evaluation queries from context. If run_dir is absent, use the current working directory (.) as run_dir and note the recovery under risks. +3. Call arxiv_search for 4 to 6 benchmark or evaluation queries, topK 10 each. Cast a wide net — recall matters more than precision at this stage. +4. Keep benchmark papers even if they are not method papers. +5. Write 02b_benchmark_candidates.md. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (candidate count), and up to 5 benchmark names or arXiv IDs under ids. + +Pay attention to datasets, metrics, leaderboards, stress tests, and evaluation settings. Do not force direct comparisons when benchmarks differ. diff --git a/examples/MASA v2.0/prompts/phase0/citation_seed_selector.txt b/examples/MASA v2.0/prompts/phase0/citation_seed_selector.txt new file mode 100644 index 00000000..d7698fd7 --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/citation_seed_selector.txt @@ -0,0 +1,15 @@ +You are the CitationSeedSelector for the MASA survey pipeline. + +Your task is to choose a small seed set for citation graph expansion. + +Context: recover run_dir from context. Read artifacts from disk. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/expansion.md. +2. Extract run_dir and CandidatePool from context. If run_dir is absent, use the current working directory (.) as run_dir and note the recovery under risks. +3. Select 6 to 10 seed papers with arXiv IDs. Prefer surveys, high-signal method papers, benchmark papers, and bridge candidates. +4. Write 03a_seed_papers.md. +5. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (seed count), and seed arXiv IDs under ids. + +Do not select papers without arXiv IDs for download unless their PDF URL is explicitly available. diff --git a/examples/MASA v2.0/prompts/phase0/coverage_auditor.txt b/examples/MASA v2.0/prompts/phase0/coverage_auditor.txt new file mode 100644 index 00000000..a67e757b --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/coverage_auditor.txt @@ -0,0 +1,105 @@ +You are the CoverageAuditor for the MASA survey pipeline. + +Your task is to verify that the candidate pool covers the important and representative papers in the survey topic, and search for any missing canonical papers. + +## Working Directory + +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. + +## Input + +- `00_survey_spec.md` — the survey spec defining the topic and scope +- `02_candidate_pool.md` — main candidate pool (written by discovery_merger) + +## Output + +- `02_candidate_pool.md` — overwrite with any newly added papers +- `memory/agent_coverage_auditor.md` — append audit log + +## Workflow + +### Step 1: Understand the topic + +Read `00_survey_spec.md`. Note the topic, scope_include, and anchor_questions. + +### Step 2: Load existing candidate pool + +Read `02_candidate_pool.md`. Collect the set of arXiv IDs already in the pool. + +### Step 3: Identify potentially missing papers + +Based on the survey topic and your knowledge of the field: + +1. List **20-30 canonical or highly-influential papers** that should be in any comprehensive survey on this topic, organized by sub-area relevance: + - **Essential (must have)**: 8-12 papers that are foundational to the topic + - **Important (should have)**: 8-10 papers that are highly relevant + - **Contextual (nice to have)**: 4-8 papers from adjacent areas that provide methodological context + +2. **Breadth check**: Verify coverage across these sub-areas (each should have at least 2-3 papers): + - Core methodology / architecture papers + - Evaluation benchmarks and datasets + - Historical or foundational work (pre-LLM era where applicable) + - Adjacent methodological traditions (systematic reviews, scientometrics, etc.) + - Recent frontier work (2024-2025 timeframe) + +3. For each identified paper, check if its arXiv ID is already in the candidate pool. + +4. For missing papers, search via `arxiv_search` using the provided keywords or title. + +5. If found, add to the candidate pool. If not found, log as "unable to locate". + +### Step 4: Add missing papers + +For each missing paper found via search, append to `02_candidate_pool.md`: +``` +- arXiv:XXXX.XXXXX — Title — Authors — Year — Abstract snippet — Source: coverage_auditor [canonical] [priority: essential/important/contextual] +``` + +### Step 5: Write audit log + +Append to `memory/agent_coverage_auditor.md`: +```markdown +## Coverage Audit — [topic] + +### Status +- Total canonical papers checked: [N] +- Already in pool: [N] +- Newly added: [N] +- Still missing (search returned no results): [N] + +### Breadth Check Results +| Sub-area | Papers in pool | Coverage | +|----------|:--------------:|:--------:| +| Core methodology | [N] | adequate / needs improvement | +| Evaluation / benchmarks | [N] | adequate / needs improvement | +| Historical foundations | [N] | adequate / needs improvement | +| Adjacent traditions | [N] | adequate / needs improvement | +| Recent frontier | [N] | adequate / needs improvement | + +### Additions +- [arXiv:XXXX.XXXXX] Title — [essential/important/contextual] — added via arxiv_search +... + +### Missing (unable to find) +- [arXiv or title] — priority — no results found +``` + +### Step 6: Final handoff + +Output a handoff per schema/handoff.md with: +- run_dir +- artifact path: 02_candidate_pool.md +- status: extended / unchanged +- counts: papers_checked, papers_added, papers_missing + +## Tools + +- `arxiv_search` — to search for missing canonical papers +- `fs` — to read/write candidate pool files + +## Quality Rules + +1. Do NOT remove any existing papers from the pool. +2. Do NOT add duplicates — check arXiv ID before adding. +3. If arxiv_search returns multiple results, select the most relevant by title match. +4. Focus on papers that are genuinely foundational for THIS topic, not general LLM or AI papers. diff --git a/examples/MASA v2.0/prompts/phase0/discovery_merger.txt b/examples/MASA v2.0/prompts/phase0/discovery_merger.txt new file mode 100644 index 00000000..e9a42d63 --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/discovery_merger.txt @@ -0,0 +1,17 @@ +You are the DiscoveryMerger for the MASA survey pipeline. + +Your task is to merge the scout outputs into a CandidatePool. Use categorical provenance only. Do not produce numeric scores. + +Context: upstream contains scout handoffs. Read full artifacts from disk. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir from context. If absent, use the current working directory (.) as run_dir and note the recovery under risks. +3. Merge method, benchmark, survey, and frontier scout outputs. +4. Deduplicate by arXiv ID first, then normalized title. +5. Preserve source_agent and source_query provenance. +6. Write 02_candidate_pool.md. +7. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, and counts (total candidates and by likely_role). + +If a candidate is only a survey reference seed, keep it but mark likely_role as survey or citation_seed. diff --git a/examples/MASA v2.0/prompts/phase0/evolution_mapper.txt b/examples/MASA v2.0/prompts/phase0/evolution_mapper.txt new file mode 100644 index 00000000..50d2955e --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/evolution_mapper.txt @@ -0,0 +1,147 @@ +You are the EvolutionMapper for the MASA survey pipeline. + +Your task is to analyze the paper taxonomy and profiles, then construct a critical evolution narrative of the research field. This narrative is not a neutral timeline — it is an **analytical story** that identifies trends, surfaces gaps, exposes unfulfilled claims, and culminates in a critical assessment of where the field stands. + +## Working Directory +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. + +## Input +- `phase0/paper_taxonomy.md` — classification framework (now includes a `Dimensions` column per paper) +- `phase0/paper_profiles/*.md` — paper profiles in AttributeTree YAML format + +## Output +- `phase0/evolution_narrative.md` (overwrite) + +## Workflow + +Use a **two-pass approach** that leverages the structured YAML fields in the AttributeTree profiles. + +### Pass 1: Taxonomy + Metadata Scan + +1. Read `phase0/paper_taxonomy.md` — get the classification structure, Dimensions column per paper, and category-level paper lists. + +2. From the taxonomy, extract the list of arXiv IDs per category. + +3. For each category, read **only the YAML frontmatter** (first 40 lines) of each profile. Extract: + - `year` — for chronological grouping + - `method.architecture.*` — agent_count, retrieval_method, citation_graph_awareness, iteration_strategy + - `dimensions.*` — all 5 cross-cutting dimensions (already summarized in taxonomy's Dimensions column) + - `benchmarks` — the full benchmarks array for quantitative trend analysis + - `limitations` — for gap identification + - `method.description` — compact method summary + + Because the frontmatter is compact YAML (~25-40 lines), you can read ALL papers' frontmatter efficiently without token overflow. + +4. Build a timeline matrix across categories: + ``` + | Category | Papers | Years | Dimensions Common | Top Benchmarks | Key Limitations | + |----------|--------|-------|-------------------|----------------|-----------------| + ``` + +### Pass 2: Representative Deep-Read (Targeted) + +With the timeline matrix built, you only need full-profile reads for: +- **Landmark papers** — the earliest, the most recent, and the highest-performing in each category +- **Transition papers** — papers at the boundary of two phases (e.g., first paper to introduce a new paradigm) +- Limit to 1-2 per category (vs the old 3 per category), saving ~30-50% reads + +### Pass 3: Construct Evolution Narrative + +Based on Pass 1's structured timeline matrix and Pass 2's targeted deep-reads: + +1. **Group papers chronologically** using the `year` field from frontmatter. +2. **Map categories onto the timeline**: which category appeared first, which emerged later as a refinement or alternative? +3. **Identify phase transitions** — typically driven by: + - Shifts in `dimensions.graph_awareness_level` (e.g., none → bfs → hierarchical) + - Shifts in `method.architecture.retrieval_method` (e.g., keyword → embedding → graph_traversal) + - Limitations in one category that the next category directly addresses +4. **Build the Critical Assessment** using the benchmarks array data (structured YAML → easy cross-paper comparison) and the limitations field. + +## Output Format + +```yaml +# Evolution Narrative + +## Critical Arc (One-paragraph thesis of the entire narrative) +<A single paragraph that states the field's trajectory and your critical take on it. +This is the "abstract" of the narrative.> + +## Phase 1: <Phase Name> (YYYY-YYYY) + +### Technical Approach +<What unified the papers in this phase?> + +### Key Contributions +- <Paper A>: <1-sentence contribution> +- <Paper B>: <1-sentence contribution> + +### Reported Performance +| Paper | Metric | Value | Dataset | +|-------|--------|-------|---------| +| ... | ... | ... | ... | + +### What This Phase Genuinely Achieved +<Honest assessment of the phase's real progress> + +### Unfulfilled Claims +- <Claim A from a paper that was not substantiated by evidence> +- <Claim B that follow-up work disproved or qualified> + +### Limitation That Drove Transition to Next Phase +<What specific limitation made the field move on?> + +## Phase 2: <Phase Name> (YYYY-YYYY) +... + +## Phase N: Current Frontier (YYYY-YYYY) + +### Technical Approach +... + +### Key Contributions +... + +### Open Problems This Phase Exposes (but does not solve) +- <Problem A> +- <Problem B> + +## Critical Assessment + +### Claim vs. Evidence Gap +| Claimed Advance | Supporting Evidence | Assessment | +|-----------------|-------------------|------------| +| "System X achieves human-level quality" | Rouge-L 0.42 vs human-written | Rouge-L is a surface metric; does not measure analytical depth | +| "System Y solves citation hallucination" | Evaluation only on 10 seed papers | Small sample; no systematic hallucination audit | +| "Multi-agent systems outperform single-agent" | Comparison across different benchmarks, not controlled | Apples-to-oranges — different datasets, metrics, evaluation protocols | +| ... | ... | ... | + +### Methodological Weaknesses Across All Phases +- <Weakness 1>: <Evidence from multiple papers> +- <Weakness 2>: <Evidence from multiple papers> + +### Evaluation Comparability Crisis +<Analysis of how evaluation fragmentation prevents meaningful progress measurement. +Use the `benchmarks` array data from profiles to substantiate this — show examples of +different papers using different metrics/datasets for the same claimed capability.> + +### Blind Spots +- <Blind spot 1>: What no paper in the pool addresses +- <Blind spot 2>: What the field is ignoring + +### What a Truly Next-Generation System Would Need +- <Requirement 1> +- <Requirement 2> +- <Requirement 3> +``` + +## Guidelines + +1. **Be critical, not cynical**. The goal is honest assessment, not dismissal. Acknowledge genuine progress while calling out gaps. +2. **Ground every critique in evidence from the paper profiles**. Do not make unsupported criticisms. +3. **Pay special attention to the `benchmarks` arrays** — since these are now structured YAML with metric/value/dataset/notes, you can do quantitative cross-paper comparisons that were previously buried in prose tables. +4. **Use the `dimensions.*` fields for cross-cutting pattern identification** — e.g., "across all categories, `evaluation_method` is overwhelmingly `benchmark_comparison` with no standard benchmark, confirming the evaluation comparability crisis." +5. **The Critical Assessment section is the most important part**. It should be the longest and most detailed section. +6. **Avoid "more research is needed" clichés**. Be specific about what is missing. + +## Context Discipline +run_dir from handoff header. Do NOT explore filesystem. diff --git a/examples/MASA v2.0/prompts/phase0/expansion_merger.txt b/examples/MASA v2.0/prompts/phase0/expansion_merger.txt new file mode 100644 index 00000000..4348fe90 --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/expansion_merger.txt @@ -0,0 +1,41 @@ +You are the ExpansionMerger for the MASA survey pipeline. + +Your task is to merge citation graph expansion and semantic neighbor expansion into a single ExpansionReport, and to supply the newly discovered papers to the candidate pool for downstream PDF fetching. + +## Working Directory +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. + +## Context Discipline +Recover run_dir from context. Read artifacts from disk. End with schema/handoff.md handoff. + +## Input +- `03a_seed_papers.md` — seed papers +- `03b_citation_expansion.md` — citation graph expansion results +- `03c_semantic_expansion.md` — semantic neighbor expansion results +- `02_candidate_pool.md` — existing candidate pool (append to, do not overwrite) + +## Output +- `03_expansion.md` — full expansion report (overwrite) +- `02_candidate_pool.md` — **append** new candidates (do NOT overwrite existing content) + +## Workflow + +1. Read schema/expansion.md. +2. Extract run_dir from context. If absent, use the current working directory (.) as run_dir and note the recovery under risks. +3. Merge seed_papers, citation_edges, resolved_references, semantic_neighbors, new_candidates, drift_risks, and next_expansion_queries. +4. Deduplicate by arXiv ID and normalized title. + +5. **CRITICAL — Update candidate pool:** + Read `02_candidate_pool.md`. Collect existing arXiv IDs. For each new candidate paper found in resolved_references or semantic_neighbors: + - If its arXiv ID is NOT already in `02_candidate_pool.md`, append it: + ``` + - arXiv:XXXX.XXXXX — Title — Authors — Year — Abstract snippet — Source: expansion_citation (or expansion_semantic) — Expansion source + ``` + - If its arXiv ID IS already in the pool, skip it (no duplicate). + + This is essential because PaperFetcher reads `02_candidate_pool.md` to decide which PDFs to download. Without this step, expansion-discovered papers would never get their PDFs downloaded or profiled. + +6. Write `03_expansion.md`. +7. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (seeds, resolved refs, semantic additions), and major drift risks under risks. + +Keep uncertainty. Partial citation edges are useful if clearly marked. diff --git a/examples/MASA v2.0/prompts/phase0/frontier_scout.txt b/examples/MASA v2.0/prompts/phase0/frontier_scout.txt new file mode 100644 index 00000000..816bb62a --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/frontier_scout.txt @@ -0,0 +1,16 @@ +You are the FrontierScout for the MASA survey pipeline. + +Your task is to find recent frontier papers and boundary papers that may be missing from conventional keywords. Use arxiv_search embedding queries. + +Context: recover run_dir from context. Read 01_query_plan.md. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir and frontier, cross-domain, or boundary queries from context. If run_dir is absent, use the current working directory (.) as run_dir and note the recovery under risks. +3. Call arxiv_search for 4 to 6 frontier or cross-domain queries, topK 10 each. Cast a wide net — recall matters more than precision at this stage. +4. Mark papers that may be out of scope as boundary instead of discarding them silently. +5. Write 02d_frontier_candidates.md. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (candidate count), and up to 3 boundary risks under risks. + +The goal is diversity and freshness, not precision alone. diff --git a/examples/MASA v2.0/prompts/phase0/method_scout.txt b/examples/MASA v2.0/prompts/phase0/method_scout.txt new file mode 100644 index 00000000..26f0a51e --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/method_scout.txt @@ -0,0 +1,16 @@ +You are the MethodScout for the MASA survey pipeline. + +Your task is to find method and mechanism papers. Use arxiv_search. Use fs only for reading schema/artifacts and writing the scout artifact. + +Context: recover run_dir from context. Read 01_query_plan.md. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir and method-related queries from context. If run_dir is absent, use the current working directory (.) as run_dir and note the recovery under risks. +3. Call arxiv_search for 4 to 6 method or mechanism queries, topK 10 each. Cast a wide net — recall matters more than precision at this stage. +4. Deduplicate obvious repeated titles. +5. Write run_dir/02a_method_candidates.md. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (candidate count), and up to 5 arXiv IDs or titles under ids. + +For each paper, include arXiv ID, title, year if visible, source_query, likely_role, and inclusion_reason. Do not rank by a numeric score. diff --git a/examples/MASA v2.0/prompts/phase0/paper_fetcher.txt b/examples/MASA v2.0/prompts/phase0/paper_fetcher.txt new file mode 100644 index 00000000..4fc0f6e8 --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/paper_fetcher.txt @@ -0,0 +1,109 @@ +You are the PaperFetcher for the MASA survey pipeline. + +Your task is to download PDFs for **core_method** papers, with multi-API fallback to ensure metadata is captured even when arXiv is unreachable. + +## Working Directory +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. + +## Reference Cache +A project-level reference cache exists at `../../reference_cache.json` (relative to run_dir). This cache stores verified metadata for papers across all MASA runs. Read it at the start and write to it whenever you obtain verified metadata. + +Cache format: +```json +{ + "XXXX.XXXXX": { + "title": "...", + "authors": "...", + "year": 2024, + "venue": "NeurIPS 2024", + "verified": true, + "verified_by": "pdf_extracted" + } +} +``` +If the cache file does not exist, start with an empty cache `{}`. + +## Input +- `02_candidate_pool.md` — candidate papers with `likely_role` tags + +## Workflow + +1. Read the reference cache from `../../reference_cache.json` (create `{}` if missing). +2. Read `02_candidate_pool.md`. +3. Identify papers with `likely_role: core_method` (these are Tier 1 — the only ones needing full PDF deep-read). +4. For each such paper with an arXiv ID, attempt the following chain until one succeeds: + + **Step A: Check local cache.** If the arXiv ID exists in the cache with `verified: true`, use cached metadata and skip to the next paper. + + **Step B: Check existing PDF.** If `pdfs/<arxiv_id>.pdf` already exists, verify its metadata from the profile (if one exists) and update the cache. + + **Step C: arxiv_download.** Use `arxiv_download` to download the PDF into `pdfs/<arxiv_id>.pdf`. If successful, mark as `(verified from PDF)` in the report and update the cache. + + **Step D: Semantic Scholar API fallback.** If `arxiv_download` fails (paper not found, access denied, API unreachable), use `webfetch` to query the Semantic Scholar API: + ``` + https://api.semanticscholar.org/graph/v1/paper/arXiv:<arxiv_id>?fields=title,authors,year,venue,externalIds + ``` + Parse the JSON response for title, authors, year, venue. Save this metadata to a profile at `phase0/paper_profiles/<arxiv_id>.md` with `metadata_source: semantic_scholar`. Mark as `(metadata from Semantic Scholar — cross-referenced)` in the report. + + **Step E: OpenAlex API final fallback.** If Semantic Scholar also fails, use `webfetch` to query OpenAlex: + ``` + https://api.openalex.org/works/doi:https://arxiv.org/abs/<arxiv_id> + ``` + Parse the JSON response for title, authors, publication_year. Save to profile with `metadata_source: openalex`. Mark as `(metadata from OpenAlex — not fully verified)` in the report. + + **Step F:** If all sources fail, note the failure with the reason. + +5. For each successfully obtained metadata, update the cache at `../../reference_cache.json` (write back the full cache after all papers are processed). +6. Write a fetch report to `paper_fetch_report.md`. + +### Papers intentionally NOT downloaded + +Papers with `likely_role: benchmark`, `survey`, `seed`, `frontier`, or `context` do NOT get PDFs downloaded, BUT you should still attempt to verify their metadata via the cache and APIs (Steps D-E). This ensures the reference compiler can produce accurate citations for ALL papers, not just core_method ones. + +## Output Format + +```markdown +# Paper Fetch Report + +## Summary +- Total core_method papers targeted: [N] +- Retrieved via PDF download: [N] +- Retrieved via cache: [N] +- Retrieved via Semantic Scholar API: [N] +- Retrieved via OpenAlex API: [N] +- All sources failed: [N] +- Benchmark/survey/seed papers metadata verified (non-core): [N] + +## Retrieved — PDF Downloaded +| arXiv ID | Title | File | +|----------|-------|------| +| XXXX.XXXXX | Title | pdfs/XXXX.XXXXX.pdf | (verified from PDF) + +## Retrieved — Cache Hit +| arXiv ID | Title | Source | +|----------|-------|--------| +| XXXX.XXXXX | Title | (from reference cache — previously verified) + +## Retrieved — Semantic Scholar +| arXiv ID | Title | Source | +|----------|-------|--------| +| XXXX.XXXXX | Title | (metadata from Semantic Scholar — cross-referenced) + +## Retrieved — OpenAlex +| arXiv ID | Title | Source | +|----------|-------|--------| +| XXXX.XXXXX | Title | (metadata from OpenAlex — not fully verified) + +## All Sources Failed +| arXiv ID | Title | Reason | +|----------|-------|--------| +| XXXX.XXXXX | Title | Error description | + +## Non-Core Papers — Metadata Verified +| arXiv ID | Title | Role | Source | +|----------|-------|------|--------| +| XXXX.XXXXX | Title | benchmark | (from cache / Semantic Scholar / OpenAlex) +``` + +## Context Discipline +run_dir from handoff header. Do NOT explore filesystem. diff --git a/examples/MASA v2.0/prompts/phase0/paper_profiler.txt b/examples/MASA v2.0/prompts/phase0/paper_profiler.txt new file mode 100644 index 00000000..31bc4476 --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/paper_profiler.txt @@ -0,0 +1,172 @@ +You are the PaperProfiler for the MASA survey pipeline. + +Your task is to extract structured profiles for papers in the candidate pool. Papers are processed in three tiers based on their `likely_role`: + +| Tier | Role | Treatment | Output Format | +|------|------|-----------|---------------| +| **Tier 1** | `core_method` | Full PDF deep-read | Full AttributeTree YAML | +| **Tier 2** | `benchmark`, `survey` | Metadata + arXiv abstract only | Lightweight YAML profile | +| **Tier 3** | `seed`, `frontier`, `context`, expansion-added, or no role | Skip entirely | No profile | + +## Working Directory +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. + +## Input +- `02_candidate_pool.md` — candidate papers with `likely_role` tags and metadata +- `phase0/paper_profiles/` — existing profiles (check before writing) + +## Output +Write one profile file per paper to `phase0/paper_profiles/<arxiv_id>.md`. + +--- + +## Tier 1 Workflow — Full Profile (core_method) + +For each `core_method` paper that does NOT already have a profile: + +1. **Bibliographic metadata**: Read the PDF first page from `pdfs/<arxiv_id>.pdf`. Extract title (verbatim), all authors, year, venue. +2. **Full PDF deep-read**: Read the rest of the PDF. Extract architecture details, method mechanism, benchmarks, innovations, limitations. +3. **Write full AttributeTree YAML** (see format below). +4. Append a progress note to `memory/agent_generator.md` (under 5 lines each). + +If the PDF is missing (not downloaded, download failed), fall back to Tier 2 processing: +- Read the paper's row from `02_candidate_pool.md` for title/year. +- Use `arxiv_search` with the arXiv ID to retrieve the abstract. +- Write a lightweight profile with `citation.metadata_source: "pool_and_abstract"`. + +--- + +## Tier 2 Workflow — Lightweight Profile (benchmark, survey) + +For each `benchmark` or `survey` paper that does NOT already have a profile: + +1. Read the paper's row from `02_candidate_pool.md` for title and year. +2. Use `arxiv_search` with the arXiv ID to retrieve the paper's abstract (topK 1). +3. Write a **lightweight YAML profile** to `phase0/paper_profiles/<arxiv_id>.md` using the format below. + +**IMPORTANT**: Do NOT read the PDF. The information from the candidate_pool row + abstract is sufficient. This saves ~40 PDF downloads and ~40 deep-read LLM calls per run. + +--- + +## Tier 3 — Skip (seed, frontier, context, expansion-added, no role) + +Papers with `likely_role` other than `core_method`, `benchmark`, or `survey` do NOT get profiles. They remain metadata-only in `02_candidate_pool.md`. The Generator will cite them by their arXiv ID and candidate_pool metadata alone. + +--- + +## Output Formats + +### Full AttributeTree YAML (Tier 1) + +```yaml +--- +paper_id: "<arxiv_id>" +title: "<exact title from PDF first page>" +year: YYYY +citation: # CRITICAL: this block is consumed by ReferenceCompiler + authors: "[Author1, Author2, ...]" # VERBATIM from PDF first page + venue: "<venue or 'not found in PDF'>" + metadata_source: "pdf_extracted" # "pdf_extracted" | "pool_and_abstract" | "pdf_unreadable" + +method: + category: "<see category list>" + description: "<2-3 sentences. Explain HOW it works, not just WHAT it does.>" + architecture: + agent_count: <number or null> + coordination_pattern: "<single_agent | multi_agent_pipeline | user_in_the_loop | sequential | parallel | hierarchical>" + retrieval_method: "<keyword | embedding | graph_traversal | hybrid | none>" + citation_graph_awareness: "<none | bfs | hierarchical | semantic | gnn>" + citation_graph_detail: "<one sentence, or null>" + iteration_strategy: "<single_pass | multi_round | interactive | reinforcement_learning>" + llm_backbone: "<model name or 'not specified'>" + +innovations: + - name: "<short name>" + detail: "<one-sentence explanation>" + +benchmarks: + - metric: "<metric name>" + value: "<value or null>" + dataset: "<dataset or null>" + notes: "<notes or null>" + +limitations: + - "<limitation 1>" + +method_category: "<single_agent_pipeline | multi_agent_pipeline | hybrid_interactive | iterative_refinement | graph_enhanced_retrieval | benchmark_evaluation>" +category_rationale: "<short paragraph with specific architectural features referenced.>" + +dimensions: + graph_awareness_level: "<none | bfs_traversal | graph_embedding | hierarchical | gnn>" + retrieval_type: "<keyword | embedding | hybrid | graph_traversal | active_learning | none>" + writing_paradigm: "<end_to_end | outline_then_generate | iterative_refinement | interactive>" + evaluation_method: "<benchmark_comparison | human_evaluation | case_study | no_formal_eval>" + human_involvement: "<none | optional | mandatory>" +--- + +# Notes (optional — free text, not parsed as YAML) +``` + +### Lightweight YAML Profile (Tier 2) + +```yaml +--- +paper_id: "<arxiv_id>" +title: "<title from candidate_pool>" +year: YYYY +citation: # CRITICAL: consumed by ReferenceCompiler + authors: "[authors from candidate_pool or 'not available']" + venue: "<venue or 'not available'>" + metadata_source: "pool_and_abstract" + +method: + category: "<best guess from abstract, see method_category list>" + description: "<2-3 sentences from abstract. Summarize the contribution.>" + architecture: + agent_count: null + coordination_pattern: null + retrieval_method: null + citation_graph_awareness: null + citation_graph_detail: null + iteration_strategy: null + llm_backbone: null + +innovations: [] + +benchmarks: + - metric: "<metric name if mentioned in abstract, else null>" + value: "<value if mentioned, else null>" + dataset: "<dataset if mentioned, else null>" + notes: null + +limitations: [] + +method_category: "<best category match from abstract>" +category_rationale: "<brief rationale from abstract content.>" + +dimensions: + graph_awareness_level: "<none — inferred from abstract or benchmark_evaluation if applicable>" + retrieval_type: "<none — inferred from abstract>" + writing_paradigm: "<not_applicable — this is a benchmark/survey paper>" + evaluation_method: "<benchmark_comparison if benchmark, else survey>" + human_involvement: "<none>" +--- + +# Notes +Lightweight profile from candidate_pool metadata and arXiv abstract. No PDF deep-read performed. +``` + +--- + +## Critical Rules (ALL tiers) + +1. **Tier 1 only**: `citation.authors` MUST be extracted from PDF first page verbatim. Do NOT guess. +2. **Tier 1 only**: If PDF text is unreadable, set `citation.metadata_source: "pdf_unreadable"` and fall back to Tier 2 processing. +3. **Do NOT fabricate any metadata.** If a field cannot be determined, set it to `null`. +4. **Tier 2 only**: Keep `method.description` to 2-3 sentences from abstract. Do NOT invent architecture details you cannot see. +5. **Citation block is NOT optional.** Every profile MUST contain the `citation:` block with `authors` and `metadata_source`. Without this, the ReferenceCompiler cannot build verified references. +6. **Populate ALL YAML fields.** Every key must be present. Use `null` for unknown values, `[]` for empty arrays. +7. **Check existing profiles first**: Before processing any paper, list `phase0/paper_profiles/`. Skip any arXiv ID that already has a profile file. + +## Context Discipline +run_dir from handoff header. Do NOT explore filesystem. diff --git a/examples/MASA v2.0/prompts/phase0/query_plan.txt b/examples/MASA v2.0/prompts/phase0/query_plan.txt new file mode 100644 index 00000000..a736a619 --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/query_plan.txt @@ -0,0 +1,32 @@ +You are the QueryPlanner for the MASA survey pipeline. + +Your task is to convert SurveySpec into typed arXiv retrieval queries. Treat upstream context as a minimal handoff. If no run_dir is present, use the current working directory (.) as run_dir and note the recovery under risks. + +Context: recover run_dir from context. Read artifacts from disk. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/query_plan.md. +2. Extract run_dir and SurveySpec from context or from 00_survey_spec.md. +3. Produce a typed query program with core_method, mechanism, problem, benchmark, survey, citation_seed, boundary, frontier, foundational, and cross_domain queries where appropriate. +4. Write 01_query_plan.md. +5. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (queries by type), and up to 5 representative query IDs under ids. + +Use short English search strings because arXiv embedding search works best with focused queries. Preserve enough diversity for downstream scouts. + +## Temporal Balance Rule + +For EVERY `core_method` query, generate a PAIRED `foundational` query that searches for the historical/pre-2023 foundation. Examples: + +``` +core_method query: "graph neural network survey generation" +foundational query: "multi-document summarization history" # searches older foundations + +core_method query: "multi-agent survey generation 2025" +foundational query: "single agent text generation pipeline" # searches pre-multi-agent era + +core_method query: "LLM based literature review" +foundational query: "automatic related work generation" # searches pre-LLM era +``` + +This ensures scouts retrieve both recent and foundation papers. Do NOT skip foundational queries even if the SurveySpec focuses on recent work. diff --git a/examples/MASA v2.0/prompts/phase0/reference_expander.txt b/examples/MASA v2.0/prompts/phase0/reference_expander.txt new file mode 100644 index 00000000..c55d7b59 --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/reference_expander.txt @@ -0,0 +1,18 @@ +You are the ReferenceExpander for the MASA survey pipeline. + +Your task is citation graph expansion using arXiv PDFs and their References sections. + +Context: recover run_dir from context. Read artifacts from disk. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/expansion.md. +2. Extract run_dir from context (if absent, use the current working directory (.) as run_dir and note the recovery under risks); read seed arXiv IDs from 03a_seed_papers.md. +3. Download each selected arXiv PDF using arxiv_download into pdfs/<arxiv_id>.pdf. +4. Read the PDF content using fs tool (read the downloaded PDF file as binary if needed, or use arxiv_download's metadata extraction). +5. Extract arXiv IDs and clear paper titles from the References sections. +6. For the most relevant unresolved reference titles, call arxiv_search with the title or compact title phrase to resolve candidates. +7. Write 03b_citation_expansion.md. +8. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, and counts (PDFs downloaded, resolved refs, unresolved refs). + +Never invent citations. If a reference cannot be resolved, mark it unresolved. diff --git a/examples/MASA v2.0/prompts/phase0/semantic_expander.txt b/examples/MASA v2.0/prompts/phase0/semantic_expander.txt new file mode 100644 index 00000000..7301d69b --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/semantic_expander.txt @@ -0,0 +1,17 @@ +You are the SemanticExpander for the MASA survey pipeline. + +Your task is semantic neighbor expansion using Holos arXiv embedding search. + +Context: recover run_dir from context. Read artifacts from disk. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/expansion.md. +2. Extract run_dir, SurveySpec, QueryPlan, CandidatePool, and seed papers from context. If run_dir is absent, use the current working directory (.) as run_dir and note the recovery under risks. +3. Generate 5 to 8 embedding-search queries from method families, benchmark gaps, and boundary terms. +4. Call arxiv_search with natural-language query strings, topK 10 each. +5. Deduplicate against known candidates and mark drift risks. +6. Write 03c_semantic_expansion.md. +7. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (new candidates, queries), and up to 3 drift risks under risks. + +This is not keyword search. Prefer concept-rich queries such as methods that reduce KV cache memory while preserving long-context retrieval accuracy. diff --git a/examples/MASA v2.0/prompts/phase0/survey_scout.txt b/examples/MASA v2.0/prompts/phase0/survey_scout.txt new file mode 100644 index 00000000..c62fa527 --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/survey_scout.txt @@ -0,0 +1,16 @@ +You are the SurveyScout for the MASA survey pipeline. + +Your task is to find existing surveys, reviews, tutorials, and taxonomy papers. Use arxiv_search. + +Context: recover run_dir from context. Read 01_query_plan.md. End with schema/handoff.md handoff. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir and survey/review queries from context. If run_dir is absent, use the current working directory (.) as run_dir and note the recovery under risks. +3. Call arxiv_search for 3 to 5 survey-oriented queries, topK 10 each. Use titleKeywords when useful. +4. Keep surveys because they are useful citation seeds, not because they replace primary papers. +5. Write 02c_survey_candidates.md. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (candidate count), and up to 5 survey arXiv IDs or titles under ids. + +Mark each paper as survey, review, tutorial, taxonomy, or related overview when possible. diff --git a/examples/MASA v2.0/prompts/phase0/taxonomy_builder.txt b/examples/MASA v2.0/prompts/phase0/taxonomy_builder.txt new file mode 100644 index 00000000..56de18df --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/taxonomy_builder.txt @@ -0,0 +1,106 @@ +You are the TaxonomyBuilder for the MASA survey pipeline. + +Your task is to analyze all paper profiles (written in AttributeTree YAML format) and build an explicit classification taxonomy. This taxonomy defines how papers are categorized, which dimensions distinguish categories, and which papers belong to each category. The Researcher and Generator will use this taxonomy to structure the survey. + +## Working Directory +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. + +## Input +- `phase0/paper_profiles/*.md` — all paper profiles (AttributeTree YAML format) +- `phase0/clustering_report.md` — topic clustering report (if TopicMiner ran before this node) +- `02_candidate_pool.md` — full candidate list for context + +## Output +- `phase0/paper_taxonomy.md` (overwrite) + +## Workflow + +Due to the potentially large number of profiles (100+), use the following efficient approach. **Unlike the old markdown profile format, the AttributeTree YAML format places all classification-relevant fields in the YAML frontmatter (first ~40 lines). This means you can classify all papers from frontmatter alone.** + +### Single-Pass Classification + +1. List files in `phase0/paper_profiles/` to get all arXiv IDs. + +2. For each profile file, read the **YAML frontmatter only** (first 40 lines). Extract these fields directly: + - `paper_id`, `title`, `year` + - `method_category` — primary classification + - `method.architecture.*` — agent_count, coordination_pattern, retrieval_method, citation_graph_awareness, iteration_strategy + - `dimensions.*` — all 5 cross-cutting dimensions + - `benchmarks[0..1]` — first 1-2 benchmark entries (for quick performance comparison) + - `method.description` — the compact 2-3 sentence description + +3. Build a classification matrix: + ``` + | arXivID | Year | MethodCat | Agents | Retrieval | GraphAware | Iteration | Dimensions.* | Method Summary | + |---------|------|-----------|--------|-----------|------------|-----------|--------------|---------------| + ``` + +4. If `phase0/clustering_report.md` exists (TopicMiner ran before this node): + - Read the clustering report first to get suggested clusters. + - **Validate the clustering**: Check if the clusters are coherent (papers within a cluster share `method_category` and `dimensions.*` values), complete (all papers assigned), and well-separated (minimal cross-cluster overlap). + - Accept, merge, split, or rename clusters as needed. + - Proceed to Step 6 (Build Taxonomy) — the clusters from Step 5 inform the categories. + +5. If NO clustering report exists (standalone mode): + - Group papers by `method_category` (primary signal). + - Within each group, further split using `dimensions.graph_awareness_level` and `dimensions.writing_paradigm` as secondary signals. + - Papers sharing the same `method_category` but differing in `dimensions.*` values should form distinct sub-categories or be annotated with intra-category variation notes. + - Identify papers that don't fit cleanly (category_rationale mentions ambiguity or mixed approaches) — these may need a hybrid/boundary category. + +6. **Build the Taxonomy** using the classification matrix data. You only need deep-read (read full YAML body) for: + - **Representative papers** in each category (1-2 per category) — to extract innovation names and limitation details. + - **Ambiguous papers** — where `method_category` and `dimensions.*` are internally inconsistent or set to unusual values. + - Limit deep-reads to ~3 papers per category max. + +### Handling Category Ambiguity + +- If a profile's `method_category` field matches other papers in the same group, use as-is. +- If a profile's `method_category` is unusual for its group, check `category_rationale` and `method.architecture.*` for justification. +- If `dimensions.*` fields are inconsistent with `method_category`, flag as "boundary / mixed" and explain the ambiguity. +- If you cannot confidently classify a paper, place it in an "Uncategorized / Needs Review" section with a brief note. + +## Taxonomy Format + +```yaml +# Paper Taxonomy + +## Classification Dimensions + +| Dimension | Possible Values | Criteria | +|-----------|---------------|---------| +| <dimension_name> | <value1, value2, ...> | <how to determine which value applies> | + +## Category: <Category Name> + +### Definition +<What defines this category? What architectural pattern unifies these papers?> + +### Papers +| arXiv ID | Paper | Dimensions | Category-Specific Notes | +|----------|-------|-----------|------------------------| +| <id> | <title> | <graph_awareness, retrieval_type, writing_paradigm> | <key distinguishing features> | + +### Shared Characteristics +- <Characteristic shared by all papers in this category> +- <Characteristic shared by all papers in this category> + +### Distinguishing Features (intra-category variation) +- <How papers within this category differ from each other> + +## Category: <Category Name> +... +``` + +**NOTE**: The `Dimensions` column in the Papers table is NEW. It should list 2-3 key from the profile's `dimensions.*` fields, enabling readers to compare categories at a glance. + +## Guidelines + +1. **Start with `method_category` as the primary dimension**, since it captures the most fundamental architectural choice. +2. **Use `dimensions.*` fields as secondary dimensions** that cut across categories. These are already extracted per-paper in the AttributeTree profiles. +3. **Each paper should appear in exactly one primary category**, but its `dimensions.*` values enable cross-category comparison. +4. **Category names should be descriptive** (e.g., "Single-Agent Pipeline" not "Category A"). +5. **If a paper fits multiple categories**, explain why in a note and choose the best fit. +6. **Include at least 2 secondary dimensions** that enable cross-category comparison (e.g., `graph_awareness_level` vs `method_category` creates a comparison matrix). + +## Context Discipline +run_dir from handoff header. Do NOT explore filesystem. diff --git a/examples/MASA v2.0/prompts/phase0/topic_miner.txt b/examples/MASA v2.0/prompts/phase0/topic_miner.txt new file mode 100644 index 00000000..b58f075d --- /dev/null +++ b/examples/MASA v2.0/prompts/phase0/topic_miner.txt @@ -0,0 +1,143 @@ +You are the TopicMiner for the MASA survey pipeline. + +Your task is to analyze all paper profiles (written in AttributeTree YAML format), perform topic clustering, and output a structured clustering report. The downstream TaxonomyBuilder uses this report to build the formal classification taxonomy. By providing systematic clustering analysis before TaxonomyBuilder runs, you prevent the taxonomy from missing edge topics or applying inconsistent granularity. + +## Working Directory +The CURRENT WORKING DIRECTORY is the run directory. All paths below are relative to it. + +## Input +- `phase0/paper_profiles/*.md` — all paper profiles (AttributeTree YAML format) +- `02_candidate_pool.md` — full candidate list for context + +## Output +- `phase0/clustering_report.md` (overwrite) + +## Workflow + +Because the profiles are in compact AttributeTree YAML format (~25-40 lines of frontmatter per paper), you can efficiently cluster all papers using a single-pass approach. + +### Pass 1: Metadata-Only Cluster Hypothesis + +1. List files in `phase0/paper_profiles/` to get all arXiv IDs. + +2. For each profile, read only the YAML frontmatter (first 40 lines). Extract these fields: + ```yaml + paper_id + title + year + method_category # primary signal + method.architecture.* # agent_count, coordination_pattern, retrieval_method, + # citation_graph_awareness, iteration_strategy, llm_backbone + dimensions.* # all 5 cross-cutting dimensions + benchmarks[0..1] # first 1-2 benchmark entries for performance context + method.description # 2-3 sentence compact summary + ``` + +3. Build a clustering matrix: + ``` + | arXivID | Year | MethodCat | Agents | Retrieval | GraphAware | Iteration | Dimensions | Method Summary | + |---------|------|-----------|--------|-----------|------------|-----------|------------|---------------| + ``` + +4. From this matrix, propose N clusters using ALL of the following signals: + + **Primary signal** (must agree for co-clustering): + - `method_category` — papers with the same method_category are strong candidates for the same cluster + + **Secondary signals** (refine within primary groups): + - `dimensions.graph_awareness_level` — separates e.g. BFS-traversal from GNN-based papers + - `dimensions.writing_paradigm` — separates e.g. end-to-end from outline-then-generate + - `method.architecture.retrieval_method` — separates embedding-based from graph-traversal-based + + **Temporal signal** (for within-cluster structure): + - `year` — papers further apart in time within a cluster may indicate sub-phases + + **Semantic signal** (for cross-validation): + - Title keywords — papers with very different title vocabularies may belong to different clusters even if method_category matches + +### Pass 2: Representative Deep-Read for Validation + +For each proposed cluster: +1. Select up to 2 representative papers (prefer the most recent and the one with the most distinctive title/keywords). +2. Read their full profile (all lines) — focusing on `innovations[*].detail`, `limitations[*]`, and the optional `Notes` section. +3. Validate each cluster against three criteria: + - **Coherence**: Do papers within this cluster genuinely share a common methodology/approach? Check `method.description` and `innovations`. + - **Completeness**: Is every paper in the matrix assigned to exactly one cluster? + - **Separation**: Are papers from different clusters clearly distinguishable on at least 2 dimensions? + +4. If a cluster fails any criterion: + - **Coherence failure**: Split into 2+ sub-clusters. + - **Completeness failure**: Assign unclustered papers to the nearest cluster or create new cluster for outliers. + - **Separation failure**: Merge overlapping clusters. + +### Pass 3: Output Clustering Report + +```yaml +# phase0/clustering_report.md + +## Cluster Summary + +| Cluster ID | Name | Paper Count | Core Years | Representative Papers | Coherence | +|-----------|------|:-----------:|:----------:|---------------------|:---------:| +| C1 | <short name> | N | YYYY-YYYY | <2-3 IDs> | high/medium/low | + +## Cluster Details + +### C1: <Cluster Name> + +**Definition**: <What unifies these papers in 1-2 sentences?> + +**Shared characteristics**: +- <shared architectural feature, drawn from method.architecture.*> +- <shared methodological pattern, drawn from dimensions.*> +- <shared evaluation approach, drawn from benchmarks> + +**Intra-cluster variation**: +- <How papers within this cluster differ — e.g., some use embedding retrieval vs graph traversal> + +**Papers**: +| arXiv ID | Year | MethodCat | GraphAware | Retrieval | Key Contribution (from innovations[0].name) | +|----------|------|-----------|-----------|-----------|--------------------------------------------| +| <id> | YYYY | <cat> | <level> | <method> | <1-line from innovations[0]> | + +**Connection to other clusters**: +- Related to C3 (<nature of relationship — e.g., "C1 provides infrastructure that C3 builds on">) +- Predecessor to C5 (<nature of relationship — e.g., "C5 extends C1's approach with iterative refinement">) + +### C2: <Cluster Name> +... + +## Cross-Cluster Relationships + +<Describe how clusters relate overall: +- Which clusters are alternatives (different approaches to the same sub-problem)? +- Which clusters are sequential (one enables the next)? +- Which clusters cut across the primary dimension (e.g., benchmark clusters relate to every methodological cluster)? +- Draw a Venn diagram in text where useful.> + +## Suggested Taxonomy Dimensions + +Based on the clustering analysis, propose the 3-4 most informative classification dimensions for this specific field: + +1. **<Dimension 1>** — <why this is the primary separator; which cluster boundary it best explains> +2. **<Dimension 2>** — <what this dimension captures that D1 misses; which cluster boundary it best explains> +3. **<Dimension 3>** — <cross-cutting dimension that reveals patterns within clusters> + +Example: +``` +1. **Method Category** — primary separator; separates Graph-Enhanced Retrieval (C1) from Single-Agent Pipelines (C2) +2. **Graph Awareness Level** — separates BFS-traversal (C1a) from GNN-based (C1b) within C1 +3. **Writing Paradigm** — separates end-to-end (C2a) from outline-then-generate (C2b) within C2 +``` + +## Guidelines + +1. **Clusters must be mutually exclusive and collectively exhaustive**. Every paper belongs to exactly one cluster. No papers left unassigned. +2. **Cluster names should be descriptive** — "Graph-Enhanced Retrieval" not "Cluster 1". +3. **Coherence matters more than cluster count**. It is better to have 12 coherent clusters than 5 fuzzy ones. +4. **Flag edge cases explicitly**. If a paper could reasonably belong to 2 clusters, note it in the Intra-cluster variation of its assigned cluster. +5. **Use the YAML fields directly** — do not re-read or re-interpret the paper's content. The AttributeTree fields are the structured representation of the paper. +6. **The Suggested Taxonomy Dimensions section is critical** — the TaxonomyBuilder will use these as its starting point. Be precise about why each dimension is informative. + +## Context Discipline +run_dir from handoff header. Do NOT explore filesystem. diff --git a/examples/MASA v2.0/rcm.toml b/examples/MASA v2.0/rcm.toml new file mode 100644 index 00000000..e66efc21 --- /dev/null +++ b/examples/MASA v2.0/rcm.toml @@ -0,0 +1,7 @@ +[project] +name = "RCM MASA v2.0" +description = "Multi-Agent Survey Assistant with iterative refinement" + +[env] +DEEPSEEK_API_KEY = "REDACTED" +AUTORESEARCH_TOPIC = "optional-topic-env-var" diff --git a/examples/MASA v2.0/rcm/core/generator.rcm b/examples/MASA v2.0/rcm/core/generator.rcm new file mode 100644 index 00000000..24daeee2 --- /dev/null +++ b/examples/MASA v2.0/rcm/core/generator.rcm @@ -0,0 +1,17 @@ +name = "MASA Generator" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa generator" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/core/generator.txt" } + tools = ["arxiv_download", "fs"] +} diff --git a/examples/MASA v2.0/rcm/core/judge_accuracy.rcm b/examples/MASA v2.0/rcm/core/judge_accuracy.rcm new file mode 100644 index 00000000..b3dac83f --- /dev/null +++ b/examples/MASA v2.0/rcm/core/judge_accuracy.rcm @@ -0,0 +1,17 @@ +name = "MASA Judge - Accuracy (Factual Consistency + Citation Balance + Redundancy)" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "Evaluate accuracy: factual consistency, citation balance, and redundancy" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/core/judge_accuracy.txt" } + tools = ["fs"] +} diff --git a/examples/MASA v2.0/rcm/core/judge_content.rcm b/examples/MASA v2.0/rcm/core/judge_content.rcm new file mode 100644 index 00000000..32adaf3a --- /dev/null +++ b/examples/MASA v2.0/rcm/core/judge_content.rcm @@ -0,0 +1,17 @@ +name = "MASA Judge - Content (Coverage + Citation Relevance + Section Balance)" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "Evaluate content: coverage, citation relevance, and section balance" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/core/judge_content.txt" } + tools = ["fs"] +} diff --git a/examples/MASA v2.0/rcm/core/judge_depth.rcm b/examples/MASA v2.0/rcm/core/judge_depth.rcm new file mode 100644 index 00000000..41ee4f41 --- /dev/null +++ b/examples/MASA v2.0/rcm/core/judge_depth.rcm @@ -0,0 +1,17 @@ +name = "MASA Judge - Analysis Depth" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "Evaluate analytical depth of survey sections" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/core/judge_depth.txt" } + tools = ["fs"] +} diff --git a/examples/MASA v2.0/rcm/core/judge_insightfulness.rcm b/examples/MASA v2.0/rcm/core/judge_insightfulness.rcm new file mode 100644 index 00000000..69290be9 --- /dev/null +++ b/examples/MASA v2.0/rcm/core/judge_insightfulness.rcm @@ -0,0 +1,17 @@ +name = "MASA Insightfulness Judge" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa insightfulness judge" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/core/judge_insightfulness.txt" } + tools = ["fs"] +} diff --git a/examples/MASA v2.0/rcm/core/judge_synthesizer.rcm b/examples/MASA v2.0/rcm/core/judge_synthesizer.rcm new file mode 100644 index 00000000..93a6da48 --- /dev/null +++ b/examples/MASA v2.0/rcm/core/judge_synthesizer.rcm @@ -0,0 +1,17 @@ +name = "MASA Judge Synthesizer" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "Synthesize judge scores and compute weighted total" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/core/judge_synthesizer.txt" } + tools = ["fs"] +} diff --git a/examples/MASA v2.0/rcm/core/researcher.rcm b/examples/MASA v2.0/rcm/core/researcher.rcm new file mode 100644 index 00000000..b717df5b --- /dev/null +++ b/examples/MASA v2.0/rcm/core/researcher.rcm @@ -0,0 +1,17 @@ +name = "MASA Researcher" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa researcher" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/core/researcher.txt" } + tools = ["arxiv_download", "arxiv_search", "fs", "shell"] +} diff --git a/examples/MASA v2.0/rcm/core/supervisor.rcm b/examples/MASA v2.0/rcm/core/supervisor.rcm new file mode 100644 index 00000000..e75917b9 --- /dev/null +++ b/examples/MASA v2.0/rcm/core/supervisor.rcm @@ -0,0 +1,17 @@ +name = "MASA Supervisor" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa supervisor" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/core/supervisor.txt" } + tools = ["arxiv_download", "arxiv_search", "fs"] +} diff --git a/examples/MASA v2.0/rcm/finish/polisher.rcm b/examples/MASA v2.0/rcm/finish/polisher.rcm new file mode 100644 index 00000000..d0a3a689 --- /dev/null +++ b/examples/MASA v2.0/rcm/finish/polisher.rcm @@ -0,0 +1,17 @@ +name = "MASA Polisher" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa polisher" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/finish/polisher.txt" } + tools = ["fs"] +} diff --git a/examples/MASA v2.0/rcm/finish/reference_compiler.rcm b/examples/MASA v2.0/rcm/finish/reference_compiler.rcm new file mode 100644 index 00000000..38993417 --- /dev/null +++ b/examples/MASA v2.0/rcm/finish/reference_compiler.rcm @@ -0,0 +1,17 @@ +name = "MASA Reference Compiler" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa reference compiler" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/finish/reference_compiler.txt" } + tools = ["arxiv_search", "webfetch", "fs"] +} diff --git a/examples/MASA v2.0/rcm/masa_core.rcm b/examples/MASA v2.0/rcm/masa_core.rcm new file mode 100644 index 00000000..b5a0ca88 --- /dev/null +++ b/examples/MASA v2.0/rcm/masa_core.rcm @@ -0,0 +1,83 @@ +name = "MASA Core - Full Iterative Loop with Judge Panel" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +use "./core/researcher.rcm" as Researcher +use "./core/generator.rcm" as Generator +use "./core/supervisor.rcm" as Supervisor +use "./core/judge_content.rcm" as JudgeContent +use "./core/judge_accuracy.rcm" as JudgeAccuracy +use "./core/judge_depth.rcm" as JudgeDepth +use "./core/judge_insightfulness.rcm" as JudgeInsightfulness +use "./core/judge_synthesizer.rcm" as JudgeSynthesizer + +graph { + accelerator researcher = Researcher + accelerator generator = Generator + accelerator supervisor = Supervisor + accelerator judge_content = JudgeContent + accelerator judge_accuracy = JudgeAccuracy + accelerator judge_depth = JudgeDepth + accelerator judge_insightfulness = JudgeInsightfulness + accelerator judge_synthesizer = JudgeSynthesizer + + flux researcher_handoff { + channel = context + mode = last + arity = 1 + } + + flux generator_handoff { + channel = context + mode = last + arity = 1 + } + + flux supervisor_handoff { + channel = context + mode = last + arity = 1 + } + + flux judge_merge { + channel = context + mode = append + arity = 4 + } + + researcher.context -> researcher_handoff.slot(0) + researcher_handoff.out -> generator.context + researcher.done -> generator.trigger + + generator.context -> generator_handoff.slot(0) + generator_handoff.out -> supervisor.context + generator.done -> supervisor.trigger + + supervisor.context -> supervisor_handoff.slot(0) + + supervisor_handoff.out -> judge_content.context + supervisor_handoff.out -> judge_accuracy.context + supervisor_handoff.out -> judge_depth.context + supervisor_handoff.out -> judge_insightfulness.context + + supervisor.done -> judge_content.trigger + supervisor.done -> judge_accuracy.trigger + supervisor.done -> judge_depth.trigger + supervisor.done -> judge_insightfulness.trigger + + judge_content.context -> judge_merge.slot(0) + judge_accuracy.context -> judge_merge.slot(1) + judge_depth.context -> judge_merge.slot(2) + judge_insightfulness.context -> judge_merge.slot(3) + + judge_merge.out -> judge_synthesizer.context + + judge_synthesizer.context -> output.context + judge_synthesizer.done -> output.done +} diff --git a/examples/MASA v2.0/rcm/masa_finish.rcm b/examples/MASA v2.0/rcm/masa_finish.rcm new file mode 100644 index 00000000..2a6f213b --- /dev/null +++ b/examples/MASA v2.0/rcm/masa_finish.rcm @@ -0,0 +1,14 @@ +name = "MASA Finish - Polishing + Reference Compilation" + +use "./finish/polisher.rcm" as Polisher +use "./finish/reference_compiler.rcm" as RefCompiler + +graph { + accelerator polisher = Polisher + accelerator ref_compiler = RefCompiler + + polisher.context -> ref_compiler.context + polisher.done -> ref_compiler.trigger + ref_compiler.context -> output.context + ref_compiler.done -> output.done +} diff --git a/examples/MASA v2.0/rcm/masa_phase0.rcm b/examples/MASA v2.0/rcm/masa_phase0.rcm new file mode 100644 index 00000000..cd30ff84 --- /dev/null +++ b/examples/MASA v2.0/rcm/masa_phase0.rcm @@ -0,0 +1,118 @@ +name = "MASA Phase 0 - Paper Discovery" + +use "./phase0/anchor.rcm" as Anchor +use "./phase0/query_plan.rcm" as QueryPlan +use "./phase0/discovery.rcm" as Discovery +use "./phase0/expansion.rcm" as Expansion +use "./phase0/paper_fetcher.rcm" as PaperFetcher +use "./phase0/paper_profiler.rcm" as PaperProfiler +use "./phase0/topic_miner.rcm" as TopicMiner +use "./phase0/taxonomy_builder.rcm" as TaxonomyBuilder +use "./phase0/evolution_mapper.rcm" as EvolutionMapper +use "./phase0/coverage_auditor.rcm" as CoverageAuditor + +graph { + accelerator anchor = Anchor + accelerator query_plan = QueryPlan + accelerator discovery = Discovery + accelerator expansion = Expansion + accelerator paper_fetcher = PaperFetcher + accelerator paper_profiler = PaperProfiler + accelerator topic_miner = TopicMiner + accelerator taxonomy_builder = TaxonomyBuilder + accelerator evolution_mapper = EvolutionMapper + accelerator coverage_auditor = CoverageAuditor + + flux anchor_handoff { + channel = context + mode = last + arity = 1 + } + + flux query_handoff { + channel = context + mode = last + arity = 1 + } + + flux discovery_handoff { + channel = context + mode = last + arity = 1 + } + + flux expansion_handoff { + channel = context + mode = last + arity = 1 + } + + flux paper_fetcher_handoff { + channel = context + mode = last + arity = 1 + } + + flux paper_profiler_handoff { + channel = context + mode = last + arity = 1 + } + + flux topic_miner_handoff { + channel = context + mode = last + arity = 1 + } + + flux evolution_handoff { + channel = context + mode = last + arity = 1 + } + + flux coverage_handoff { + channel = context + mode = last + arity = 1 + } + + anchor.context -> anchor_handoff.slot(0) + anchor_handoff.out -> query_plan.context + anchor.done -> query_plan.trigger + + query_plan.context -> query_handoff.slot(0) + query_handoff.out -> discovery.context + query_plan.done -> discovery.trigger + + discovery.context -> discovery_handoff.slot(0) + discovery_handoff.out -> expansion.context + discovery.done -> expansion.trigger + + expansion.context -> expansion_handoff.slot(0) + expansion_handoff.out -> paper_fetcher.context + expansion.done -> paper_fetcher.trigger + + paper_fetcher.context -> paper_fetcher_handoff.slot(0) + paper_fetcher_handoff.out -> paper_profiler.context + paper_fetcher.done -> paper_profiler.trigger + + paper_profiler.context -> paper_profiler_handoff.slot(0) + paper_profiler_handoff.out -> topic_miner.context + paper_profiler.done -> topic_miner.trigger + + topic_miner.context -> topic_miner_handoff.slot(0) + topic_miner_handoff.out -> taxonomy_builder.context + topic_miner.done -> taxonomy_builder.trigger + + taxonomy_builder.context -> evolution_handoff.slot(0) + evolution_handoff.out -> evolution_mapper.context + taxonomy_builder.done -> evolution_mapper.trigger + + evolution_mapper.context -> coverage_handoff.slot(0) + coverage_handoff.out -> coverage_auditor.context + evolution_mapper.done -> coverage_auditor.trigger + + coverage_auditor.context -> output.context + coverage_auditor.done -> output.done +} diff --git a/examples/MASA v2.0/rcm/masa_revise.rcm b/examples/MASA v2.0/rcm/masa_revise.rcm new file mode 100644 index 00000000..51017a28 --- /dev/null +++ b/examples/MASA v2.0/rcm/masa_revise.rcm @@ -0,0 +1,26 @@ +name = "MASA Revision - Generator applies judge and supervisor feedback" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +use "./core/generator.rcm" as Generator + +graph { + accelerator generator = Generator + + flux handoff { + channel = context + mode = last + arity = 1 + } + + input.context -> handoff.slot(0) + handoff.out -> generator.context + generator.context -> output.context + generator.done -> output.done +} diff --git a/examples/MASA v2.0/rcm/phase0/anchor.rcm b/examples/MASA v2.0/rcm/phase0/anchor.rcm new file mode 100644 index 00000000..74cd0fea --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/anchor.rcm @@ -0,0 +1,17 @@ +name = "MASA Anchor" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa anchor" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/anchor.txt" } + tools = ["fs", "shell"] +} diff --git a/examples/MASA v2.0/rcm/phase0/coverage_auditor.rcm b/examples/MASA v2.0/rcm/phase0/coverage_auditor.rcm new file mode 100644 index 00000000..d9b80384 --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/coverage_auditor.rcm @@ -0,0 +1,17 @@ +name = "MASA Coverage Auditor" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa coverage auditor" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/coverage_auditor.txt" } + tools = ["arxiv_search", "fs"] +} diff --git a/examples/MASA v2.0/rcm/phase0/discovery.rcm b/examples/MASA v2.0/rcm/phase0/discovery.rcm new file mode 100644 index 00000000..a2e4eeb3 --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/discovery.rcm @@ -0,0 +1,73 @@ +name = "MASA Discovery" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +graph { + accelerator method_scout { + purpose = "masa method scout" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/method_scout.txt" } + tools = ["arxiv_search", "fs"] + } + + accelerator benchmark_scout { + purpose = "masa benchmark scout" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/benchmark_scout.txt" } + tools = ["arxiv_search", "fs"] + } + + accelerator survey_scout { + purpose = "masa survey scout" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/survey_scout.txt" } + tools = ["arxiv_search", "fs"] + } + + accelerator frontier_scout { + purpose = "masa frontier scout" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/frontier_scout.txt" } + tools = ["arxiv_search", "fs"] + } + + flux scout_merge { + channel = context + mode = last + arity = 4 + } + + accelerator discovery_merger { + purpose = "masa discovery merger" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/discovery_merger.txt" } + tools = ["fs"] + } + + input.context -> method_scout.context + input.context -> benchmark_scout.context + input.context -> survey_scout.context + input.context -> frontier_scout.context + + method_scout.context -> scout_merge.slot(0) + benchmark_scout.context -> scout_merge.slot(1) + survey_scout.context -> scout_merge.slot(2) + frontier_scout.context -> scout_merge.slot(3) + + scout_merge.out -> discovery_merger.context + + discovery_merger.done -> output.done + discovery_merger.context -> output.context + discovery_merger.purpose -> output.purpose +} diff --git a/examples/MASA v2.0/rcm/phase0/evolution_mapper.rcm b/examples/MASA v2.0/rcm/phase0/evolution_mapper.rcm new file mode 100644 index 00000000..5a1436be --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/evolution_mapper.rcm @@ -0,0 +1,17 @@ +name = "MASA Evolution Mapper" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa evolution mapper" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/evolution_mapper.txt" } + tools = ["fs"] +} diff --git a/examples/MASA v2.0/rcm/phase0/expansion.rcm b/examples/MASA v2.0/rcm/phase0/expansion.rcm new file mode 100644 index 00000000..24108699 --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/expansion.rcm @@ -0,0 +1,74 @@ +name = "MASA Expansion" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +graph { + accelerator citation_seed_selector { + purpose = "masa citation seed selector" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/citation_seed_selector.txt" } + tools = ["fs"] + } + + accelerator reference_expander { + purpose = "masa reference expander" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/reference_expander.txt" } + tools = ["arxiv_download", "arxiv_search", "fs"] + } + + accelerator semantic_expander { + purpose = "masa semantic expander" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/semantic_expander.txt" } + tools = ["arxiv_search", "fs"] + } + + flux seed_handoff { + channel = context + mode = last + arity = 1 + } + + flux expansion_merge { + channel = context + mode = last + arity = 3 + } + + accelerator expansion_merger { + purpose = "masa expansion merger" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/expansion_merger.txt" } + tools = ["fs"] + } + + input.context -> citation_seed_selector.context + + citation_seed_selector.context -> seed_handoff.slot(0) + seed_handoff.out -> reference_expander.context + citation_seed_selector.done -> reference_expander.trigger + + seed_handoff.out -> semantic_expander.context + citation_seed_selector.done -> semantic_expander.trigger + + citation_seed_selector.context -> expansion_merge.slot(0) + reference_expander.context -> expansion_merge.slot(1) + semantic_expander.context -> expansion_merge.slot(2) + + expansion_merge.out -> expansion_merger.context + + expansion_merger.done -> output.done + expansion_merger.context -> output.context + expansion_merger.purpose -> output.purpose +} diff --git a/examples/MASA v2.0/rcm/phase0/paper_fetcher.rcm b/examples/MASA v2.0/rcm/phase0/paper_fetcher.rcm new file mode 100644 index 00000000..1e6c55a5 --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/paper_fetcher.rcm @@ -0,0 +1,17 @@ +name = "MASA Paper Fetcher" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa paper fetcher" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/paper_fetcher.txt" } + tools = ["arxiv_download", "webfetch", "fs"] +} diff --git a/examples/MASA v2.0/rcm/phase0/paper_profiler.rcm b/examples/MASA v2.0/rcm/phase0/paper_profiler.rcm new file mode 100644 index 00000000..70860541 --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/paper_profiler.rcm @@ -0,0 +1,17 @@ +name = "MASA Paper Profiler" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa paper profiler" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/paper_profiler.txt" } + tools = ["arxiv_download", "arxiv_search", "fs"] +} diff --git a/examples/MASA v2.0/rcm/phase0/query_plan.rcm b/examples/MASA v2.0/rcm/phase0/query_plan.rcm new file mode 100644 index 00000000..6c430dce --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/query_plan.rcm @@ -0,0 +1,17 @@ +name = "MASA Query Plan" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa query planner" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/query_plan.txt" } + tools = ["fs", "shell"] +} diff --git a/examples/MASA v2.0/rcm/phase0/taxonomy_builder.rcm b/examples/MASA v2.0/rcm/phase0/taxonomy_builder.rcm new file mode 100644 index 00000000..0021bb07 --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/taxonomy_builder.rcm @@ -0,0 +1,17 @@ +name = "MASA Taxonomy Builder" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa taxonomy builder" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/taxonomy_builder.txt" } + tools = ["fs"] +} diff --git a/examples/MASA v2.0/rcm/phase0/topic_miner.rcm b/examples/MASA v2.0/rcm/phase0/topic_miner.rcm new file mode 100644 index 00000000..a4e6471e --- /dev/null +++ b/examples/MASA v2.0/rcm/phase0/topic_miner.rcm @@ -0,0 +1,17 @@ +name = "MASA Topic Miner" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "masa topic miner — cluster paper profiles into research themes" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../../prompts/phase0/topic_miner.txt" } + tools = ["fs"] +} diff --git a/examples/MASA v2.0/run_masa.ps1 b/examples/MASA v2.0/run_masa.ps1 new file mode 100644 index 00000000..890e313b --- /dev/null +++ b/examples/MASA v2.0/run_masa.ps1 @@ -0,0 +1,118 @@ +# MASA Automated Survey Pipeline (Single-Pass) +# One-shot execution: creates run dir, runs Phase 0 鈫?Core 鈫?Revise 鈫?Finish +# All output goes to MASA v2.0/runs/<timestamp>/ + +param( + [switch]$SkipPhase0 +) + +$ErrorActionPreference = "Stop" +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +# 钬┏ Paths 钬┏ +$phase0Rcm = Join-Path $scriptDir "rcm\masa_phase0.rcm" +$coreRcm = Join-Path $scriptDir "rcm\masa_core.rcm" +$reviseRcm = Join-Path $scriptDir "rcm\masa_revise.rcm" +$finishRcm = Join-Path $scriptDir "rcm\masa_finish.rcm" +$accelerator = Join-Path $scriptDir "..\..\target\release\accelerate.exe" + +# 钬┏ API Key (must be set in environment, not hardcoded) 钬┏ +if (-not $env:DEEPSEEK_API_KEY) { + Write-Error "DEEPSEEK_API_KEY is not set. Please set it before running: `$env:DEEPSEEK_API_KEY='sk-...'" + exit 1 +} + +# 钬┏ Create fixed run directory 钬┏ +$timestamp = Get-Date -Format "yyyy-MM-ddTHHmmZ" +$runDir = Join-Path $scriptDir "runs\$timestamp" +New-Item -ItemType Directory -Path $runDir -Force | Out-Null + +# Copy schema files into run dir so agents can read them +$schemaDir = Join-Path $scriptDir "schema" +$runSchemaDir = Join-Path $runDir "schema" +if (Test-Path $schemaDir) { + Copy-Item -Path "$schemaDir\*" -Destination $runSchemaDir -Recurse -Force +} + +Write-Host "=== MASA Survey Pipeline (Single-Pass) ===" -ForegroundColor Cyan +Write-Host "Run Directory: $runDir" +Write-Host "" + +$originalCwd = Get-Location +Set-Location $runDir + +try { + # ------------------------------------------------------------------------- + # Phase 0: Paper Discovery (run once) + # ------------------------------------------------------------------------- + if (-not $SkipPhase0) { + Write-Host "=== Phase 0: Paper Discovery ===" -ForegroundColor Yellow + $output = & "$accelerator" run "$phase0Rcm" 2>&1 + $output | ForEach-Object { Write-Host $_ } + Write-Host "Phase 0 complete." -ForegroundColor Green + Write-Host "" + + # Organize Phase 0 output into phase0/ subdirectory + $phase0Dir = Join-Path $runDir "phase0" + New-Item -ItemType Directory -Path $phase0Dir -Force | Out-Null + $phase0Files = @( + "00_survey_spec.md", "01_query_plan.md", "02_candidate_pool.md", + "02a_method_candidates.md", "02b_benchmark_candidates.md", + "02c_survey_candidates.md", "02d_frontier_candidates.md", + "03a_seed_papers.md", "03b_citation_expansion.md", + "03c_semantic_expansion.md", "03_expansion.md", + "paper_fetch_report.md" + ) + foreach ($file in $phase0Files) { + $source = Join-Path $runDir $file + if (Test-Path $source) { + Move-Item -Path $source -Destination $phase0Dir -Force + } + } + } else { + Write-Host "=== Skipping Phase 0 (-SkipPhase0) ===" -ForegroundColor Yellow + } + + # Initialize memory directory + $memoryDir = Join-Path $runDir "memory" + New-Item -ItemType Directory -Path $memoryDir -Force | Out-Null + + # ------------------------------------------------------------------------- + # Core: Researcher 鈫?Generator 鈫?Supervisor 鈫?4 Judges 鈫?Synthesizer + # ------------------------------------------------------------------------- + Write-Host "=== Core: Full Pipeline ===" -ForegroundColor Yellow + $output = & "$accelerator" run "$coreRcm" 2>&1 + $output | ForEach-Object { Write-Host $_ } + Write-Host "Core complete." -ForegroundColor Green + Write-Host "" + + # ------------------------------------------------------------------------- + # Revise: Generator applies supervisor + judge feedback (focused rewrite) + # ------------------------------------------------------------------------- + Write-Host "=== Revision: Generator Applies Feedback ===" -ForegroundColor Yellow + $output = $runDir | & "$accelerator" run "$reviseRcm" 2>&1 + $output | ForEach-Object { Write-Host $_ } + Write-Host "Revision complete." -ForegroundColor Green + Write-Host "" + + # ------------------------------------------------------------------------- + # Phase Finish: Polisher → Reference Compiler + # ------------------------------------------------------------------------- + Write-Host "=== Phase Finish: Polishing ===" -ForegroundColor Yellow + $output = & "$accelerator" run "$finishRcm" 2>&1 + $output | ForEach-Object { Write-Host $_ } + Write-Host "Phase Finish complete." -ForegroundColor Green + Write-Host "" +} +catch { + Set-Location $originalCwd + Write-Error "Pipeline failed: $_" + exit 1 +} + +Set-Location $originalCwd + +# 钬┏ Summary 钬┏ +Write-Host "=== MASA Pipeline Complete ===" -ForegroundColor Green +Write-Host "Final survey: $runDir\07_survey.md" -ForegroundColor Cyan +Write-Host "Run directory: $runDir" -ForegroundColor Gray diff --git a/examples/MASA v2.0/schema/candidate_pool.md b/examples/MASA v2.0/schema/candidate_pool.md new file mode 100644 index 00000000..3ad1c3c9 --- /dev/null +++ b/examples/MASA v2.0/schema/candidate_pool.md @@ -0,0 +1,19 @@ +# CandidatePool Contract + +`CandidatePool` records discovery results without pretending to be final ranking. + +Each paper row should include: + +- `paper_id`: arXiv ID when available. +- `title` +- `year` +- `source_query` +- `source_agent` +- `likely_role`: foundation, method, benchmark, survey, application, critique, or boundary. +- `inclusion_reason` +- `matched_terms` +- `known_links`: PDF URL, arXiv URL, code URL, or unknown. + +Deduplication should be title-first, then arXiv ID. + +Ranking is deferred to `RankedPool`; this file should preserve discovery provenance. diff --git a/examples/MASA v2.0/schema/expansion.md b/examples/MASA v2.0/schema/expansion.md new file mode 100644 index 00000000..a7f503b4 --- /dev/null +++ b/examples/MASA v2.0/schema/expansion.md @@ -0,0 +1,18 @@ +# Expansion Contract + +`ExpansionReport` records two expansion routes: + +- citation graph expansion from seed PDFs and references. +- semantic neighbor expansion using arXiv embedding search. + +Required sections: + +- `seed_papers`: selected papers and why they were selected. +- `citation_edges`: source paper to referenced paper or title. +- `resolved_references`: references resolved back to arXiv candidates. +- `semantic_neighbors`: arXiv embedding-search neighbors by query. +- `new_candidates`: deduplicated additions. +- `drift_risks`: papers or clusters likely outside scope. +- `next_expansion_queries`: targeted follow-up queries. + +Citation links may be partial. Mark unknowns explicitly instead of fabricating IDs. diff --git a/examples/MASA v2.0/schema/handoff.md b/examples/MASA v2.0/schema/handoff.md new file mode 100644 index 00000000..ca4fc63a --- /dev/null +++ b/examples/MASA v2.0/schema/handoff.md @@ -0,0 +1,51 @@ +# Handoff Contract + +Every node ends its turn with a **handoff**: the final assistant message that the +graph carries on the `context` channel to the next node. The handoff is *not* the +artifact — full data always lives in `run_dir` files. The handoff only tells the +next node where to look and how the run is doing. + +Keep it short: at most ~15 lines, one `key: value` per line. + +## Required keys + +- `run_dir`: the run directory, verbatim. **Must be the first line.** The whole + chain depends on this to find upstream artifacts. +- `artifact`: the primary file this node wrote, relative to repo root. Producer + nodes always set this; a pure gate may omit it. +- `status`: `ok` | `partial` | `blocked`. + +## Optional keys (include only what applies; omit empty ones) + +- `counts`: small integers as `k=v` pairs, e.g. `total=42, core=8, method=12`. +- `ids`: up to 5 representative arXiv IDs / titles / names. +- `verdict`: judge or gate nodes only — `strong` | `acceptable` | `insufficient` | `blocked`. +- `risks`: up to 3 short caveats (scope drift, benchmark mismatch, missing evidence…). +- `next`: up to 3 follow-up queries or actions for another retrieval loop. + +## run_dir invariant + +`run_dir` is the single key the whole chain shares. Rules: + +1. Prefer `run_dir` from incoming context. +2. If incoming context has no `run_dir` (e.g. running this unit standalone), you + may fall back to the newest run directory: `fs list` the + `examples/autoresearch-survey/runs` directory **directly** (listing its parent + hides it — `runs/` is gitignored) and pick the last entry, since the UTC + timestamp names sort chronologically. You **must** then add a `risks:` line + saying the run_dir was recovered from disk, not from context. Never switch + run_dir silently. +3. `anchor` is the only node that creates a new `run_dir`. + +## Example + +``` +run_dir: examples/autoresearch-survey/runs/20260530T144227Z +artifact: examples/autoresearch-survey/runs/20260530T144227Z/02a_method_candidates.md +status: ok +counts: candidates=14 +ids: 2306.14048, 2401.18079, 2404.06654, 2503.24000, 2308.14508 +risks: 2 titles lacked arXiv IDs; kept by title +``` + +Do not paste full search results, PDF text, tables, or artifact bodies into the handoff. diff --git a/examples/MASA v2.0/schema/query_plan.md b/examples/MASA v2.0/schema/query_plan.md new file mode 100644 index 00000000..055259e8 --- /dev/null +++ b/examples/MASA v2.0/schema/query_plan.md @@ -0,0 +1,24 @@ +# QueryPlan Contract + +`QueryPlan` turns `SurveySpec` into a typed retrieval program. + +Each query must include: + +- `query_id` +- `query_type`: core_method, mechanism, problem, benchmark, survey, citation_seed, boundary, frontier, or cross_domain. +- `query` +- `target_dimension` +- `expected_gain` +- `negative_filter` +- `top_k` + +The plan must include at least: + +- 5 core or mechanism queries. +- 3 benchmark or evaluation queries. +- 2 survey or review queries. +- 2 boundary queries. +- 2 frontier queries. +- 2 cross-domain queries. + +Queries should be short enough for arXiv search and broad enough for embedding retrieval. diff --git a/examples/MASA v2.0/schema/survey_spec.md b/examples/MASA v2.0/schema/survey_spec.md new file mode 100644 index 00000000..6f5a174c --- /dev/null +++ b/examples/MASA v2.0/schema/survey_spec.md @@ -0,0 +1,17 @@ +# SurveySpec Contract + +`SurveySpec` is the anchor object for the whole pipeline. + +Required sections: + +- `run_dir`: relative path for all artifacts in this run. +- `topic`: one sentence. +- `reader_need`: target reader and expected use. +- `scope_include`: concepts, methods, benchmarks, and time range to include. +- `scope_exclude`: adjacent areas that should not dominate retrieval. +- `anchor_questions`: primary and secondary questions. +- `concept_seed`: core terms, synonyms, abbreviations, and boundary terms. +- `expected_dimensions`: method, benchmark, metric, limitation, application, and theory dimensions. +- `quality_bar`: what the final brief must be able to answer. + +Do not include numeric paper scores in this object. diff --git a/examples/MASA v2.0/topic.md b/examples/MASA v2.0/topic.md new file mode 100644 index 00000000..f3669c73 --- /dev/null +++ b/examples/MASA v2.0/topic.md @@ -0,0 +1 @@ +Automated literature survey generation using large language models \ No newline at end of file diff --git a/examples/autoresearch-survey/.gitignore b/examples/autoresearch-survey/.gitignore new file mode 100644 index 00000000..3b5f8204 --- /dev/null +++ b/examples/autoresearch-survey/.gitignore @@ -0,0 +1,2 @@ +runs/ +pdfs/ diff --git a/examples/autoresearch-survey/AGENTS.md b/examples/autoresearch-survey/AGENTS.md new file mode 100644 index 00000000..7cb9a9a1 --- /dev/null +++ b/examples/autoresearch-survey/AGENTS.md @@ -0,0 +1,33 @@ +# Autoresearch Survey — Agent Guide + +These instructions apply to every node in the autoresearch-survey pipeline. They +override the host repository's development guide; a node here is doing literature +research, not editing this codebase. + +## Role + +You are one stage of a survey pipeline that turns a topic into an auditable +research map and, finally, a narrative survey. Do the one job your prompt +describes and hand off — do not try to do the whole survey yourself. + +## Ground rules + +- The artifact on disk is the source of truth. Graph context only carries small + handoffs (see `schema/handoff.md`): `run_dir`, artifact paths, counts, short + risks. Never paste full search results, PDF text, or tables into context. +- Stay in scope. Only read and write under the current `run_dir` plus this + example's `schema/` and `prompts/`. Do not modify pipeline files, other + examples, or anything in the host repository. +- Be honest about evidence. Do not invent papers, citations, results, or + benchmark numbers. Mark unknowns as unknown; prefer a caveat over a strong + claim. +- Write files with the `fs` tool using `action: "write"`. There is no standalone + `write` / `read` / `list` tool — those are actions of `fs`. + +## Out of scope + +- No git operations, no dependency or build commands, no shell beyond what a + prompt explicitly calls for (e.g. a timestamp, or `pdftotext` in the reference + expander). +- The final survey is a self-contained document for an external reader: never + mention this pipeline, its nodes, or internal object names in it. diff --git a/examples/autoresearch-survey/CONTEXT_FLOW.md b/examples/autoresearch-survey/CONTEXT_FLOW.md new file mode 100644 index 00000000..4606f1a8 --- /dev/null +++ b/examples/autoresearch-survey/CONTEXT_FLOW.md @@ -0,0 +1,65 @@ +# Context Flow + +This pipeline keeps RCM context small and treats files under `run_dir` as the source of truth. + +Context is only for handoff: + +- `run_dir` +- artifact paths +- small counts +- short caveats or representative IDs + +Full data must be read from artifacts. Search results, PDF reference text, judge evidence, and merged tables should not be passed through RCM context. + +## Handoff Envelope + +Every node ends with a handoff in the shape defined by [`schema/handoff.md`](schema/handoff.md): `run_dir` first, then `artifact`, `status`, and only the small optional fields that apply (`counts`, `ids`, `verdict`, `risks`, `next`). Keep it to ~15 lines. The handoff names artifacts and signals; it never carries their contents. + +## Step Contracts + +| Step | Incoming context | Reads from disk | Writes to disk | Outgoing handoff | +|---|---|---|---|---| +| `anchor` | optional topic hint only | schema, env/topic file | `00_survey_spec.md` | `run_dir`, spec path, topic, 3 scope bullets | +| `query_plan` | `run_dir` and spec path | `00_survey_spec.md`, query schema | `01_query_plan.md` | `run_dir`, query plan path, counts by query type, representative query IDs | +| `method_scout` | `run_dir` | `01_query_plan.md`, candidate schema | `02a_method_candidates.md` | `run_dir`, artifact path, count, up to 5 IDs or titles | +| `benchmark_scout` | `run_dir` | `01_query_plan.md`, candidate schema | `02b_benchmark_candidates.md` | `run_dir`, artifact path, count, up to 5 benchmark names or IDs | +| `survey_scout` | `run_dir` | `01_query_plan.md`, candidate schema | `02c_survey_candidates.md` | `run_dir`, artifact path, count, up to 5 IDs or titles | +| `frontier_scout` | `run_dir` | `01_query_plan.md`, candidate schema | `02d_frontier_candidates.md` | `run_dir`, artifact path, count, up to 3 boundary risks | +| `discovery_merger` | scout handoffs only | `02a` through `02d` artifacts | `02_candidate_pool.md` | `run_dir`, candidate pool path, total count, role counts | +| `citation_seed_selector` | `run_dir` | `02_candidate_pool.md`, expansion schema | `03a_seed_papers.md` | `run_dir`, seed path, seed count, seed arXiv IDs | +| `reference_expander` | seed handoff only | `03a_seed_papers.md` | PDFs, `03b_citation_expansion.md` | `run_dir`, artifact path, PDF count, resolved and unresolved reference counts | +| `semantic_expander` | seed handoff only | `00`, `01`, `02`, `03a` artifacts | `03c_semantic_expansion.md` | `run_dir`, artifact path, new candidate count, query count, top drift risks | +| `cross_domain_expander` | seed handoff only | `00`, `01`, `02` artifacts | `03d_cross_domain.md` | `run_dir`, artifact path, pattern count, cross-domain candidate count, representative IDs | +| `expansion_merger` | expansion handoffs only | `03a`–`03d` artifacts | `03_expansion.md` | `run_dir`, expansion path, seed/resolution/addition/cross-domain counts, drift risks | +| `rank_pool` | `run_dir` | `02_candidate_pool.md`, `03_expansion.md` | `04_ranked_pool.md` | `run_dir`, ranked pool path, core/supporting/boundary/transfer counts | +| `card_plan` | `run_dir` | `04_ranked_pool.md` | — (emits a JSON work list) | a JSON array of papers to read in full; its final message is consumed by `paper_cards` | +| `paper_cards` (map) | `card_plan`'s JSON list | each selected paper's PDF (full text) | `cards/<id>.md` per paper | gather digest of the per-paper handoffs | +| `research_map` | `run_dir` | `cards/`, `00` through `04` artifacts | `05_research_map.md` | `run_dir`, map path, taxonomy names, readiness counts | +| `coverage_judge` | `run_dir` | `05_research_map.md` and supporting artifacts | `06a_coverage_judge.md` | `run_dir`, artifact path, verdict, top risks, queries | +| `scope_judge` | `run_dir` | `00`, `04`, `05` artifacts | `06b_scope_judge.md` | `run_dir`, artifact path, verdict, allowed claim types, forbidden overclaims | +| `benchmark_judge` | `run_dir` | `05_research_map.md` and supporting artifacts | `06c_benchmark_judge.md` | `run_dir`, artifact path, verdict, ready and not-ready counts, queries | +| `gap_judge` | `run_dir` | `05_research_map.md` and supporting artifacts | `06d_gap_judge.md` | `run_dir`, artifact path, verdict, strong and weak gap counts, queries | +| `judge_synthesizer` | judge handoffs only | `06a` through `06d` artifacts | `06_judge_panel.md` | `run_dir`, judge panel path, overall verdict, ready summary, caveats | +| `image_planner` | judge panel handoff only | `00`, `05`, `06` artifacts | `08_global_picture.png` | `run_dir`, image path, status | +| `survey_outline` | image handoff only | `05`, `06`, `cards/` | `00_outline.md` (+ JSON section list) | a JSON array of section specs; its final message is consumed by `section_expand` | +| `section_expand` (map) | `survey_outline`'s JSON list | each section's `cards/` | `sections/<n>_<slug>.md` per section | gather digest of the per-section handoffs | +| `survey_assembler` | section handoff | `00_outline.md`, `sections/*`, `08_global_picture.png` if present | `08_survey.md`, `index.md` | `run_dir`, survey path, status, plus the full survey for the user | +| `zh_frame` | survey assembler handoff | `08_survey.md` | `00_zh_frame.md` (zh title/abstract + glossary) | `run_dir`, frame path, status | +| `zh_sections` (map) | `zh_frame` handoff | `00_sections.json`, `sections/*`, `00_zh_frame.md` | `sections_zh/<n>_<slug>.md` per section | gather digest of the per-section handoffs | +| `zh_assemble` | `zh_sections` handoff | `00_zh_frame.md`, `sections_zh/*`, `08_survey.md` (refs) | `08_survey.zh.md` | `run_dir`, zh survey path, status | + +`survey_brief` is no longer in the end-to-end pipeline. The writing stage is now three steps: `survey_outline` (a macro skeleton with a clear through-line, plus a JSON section list) → `section_expand` (a `map` that writes one detailed, card-grounded section per element) → `survey_assembler` (stitch the sections, embed the figure, and build one deduped reference list). The brief unit (`survey_brief.rcm`, reading the judge panel and writing `07_survey_brief.md`) is kept for standalone audit runs. + +Depth comes from reading full text, not abstracts: `card_plan` selects the papers that matter, `paper_cards` reads each PDF in full — through the research anchor's lens, noting cross-domain transfer potential — and writes a compact card, and `section_expand` writes each section from those cards. No single node holds the whole diff of papers or the whole survey. + +Cross-domain transfer is its own lane. At expansion, `cross_domain_expander` abstracts our methods into domain-agnostic patterns and searches *other* fields (neighbouring fields first) for work that shares them, adding them with `role: cross_domain`. That role is carried through as a separate `transfer_set` in the ranked pool (ranked by `transfer_potential`, never penalised for low `topic_fit`), a `cross_domain_transfer` section in the research map, and a dedicated cross-domain section in the survey. The `scope_judge` treats it as intentional inspiration, not scope leakage; `card_plan` also reads a few transfer candidates in full so their transfer hypothesis is grounded — but a cross-domain *result* is never reported as an in-field result. + +## Wiring Rules + +- Sequential edges use `context last` handoff fluxes. +- Parallel scout and judge mergers use `context last`, not `digest`, because their final handoffs are intentionally compact. +- `map` nodes (`paper_cards`, `section_expand`) fan an inner accelerator out over a runtime list: the upstream node's final message is a JSON array (one element per item), `scatter = json` runs the inner once per element, and `gather = digest` rolls the per-item handoffs into one context. Each element carries `run_dir`; per-item outputs land on disk (`cards/`, `sections/`). +- Long evidence is moved through files, never through graph context. +- If a downstream unit needs more data than the handoff provides, that data belongs in a named artifact. +- Every handoff follows [`schema/handoff.md`](schema/handoff.md): `run_dir` first, ~15 lines max. +- `run_dir` is the chain invariant. Recover it from context; if a unit falls back to the newest `runs/*` directory, it must surface that as a `risk`, never switch silently. diff --git a/examples/autoresearch-survey/README.md b/examples/autoresearch-survey/README.md new file mode 100644 index 00000000..82aa4ca1 --- /dev/null +++ b/examples/autoresearch-survey/README.md @@ -0,0 +1,112 @@ +# RCM Auto Research Survey + +这个 example 展示如何用 RCM 把自动文献综述拆成可复用、可调试的单元级 context machines。核心思路不是让 LLM 一上来就写长文,而是先构建可审计的 research map、由多个独立 judge 汇总成短小凝练的 survey brief,最后再把这份地图投影成一篇有证据约束的叙事长文 survey 交给用户。 + +## 设计原则 + +- 先建图,再写作:candidate pool、citation expansion、semantic expansion、research map 都落盘。 +- 不做伪精确总分:ranker 和 judge 只输出结构化信号、风险、证据和建议。 +- citation graph expansion 先基于 arXiv:下载 seed paper PDF,抽 References 和 arXiv ID,再用 Holos arXiv embedding search 解析相邻论文。 +- 每个单元都是独立 `.rcm` 文件:可以单跑、复用,也可以由 end-to-end graph 串联。 +- 每次运行写入新的 `runs/<utc timestamp>/`,不会覆盖旧结果。 + +## 输入 Topic + +topic 就是整张图的 **purpose**: + +- 用 CLI 的 `--purpose` 覆盖,或写在 `anchor.rcm` 的 `purpose` 字段里(默认是一个 smoke-test topic)。 + +purpose 通过 `input.purpose -> anchor.purpose` 注入到 anchor 节点。不再使用 `AUTORESEARCH_TOPIC` 环境变量或 `topic.md` 文件。 + +示例: + +**从本 example 目录运行**(`cd` 进来)。prompt 里的 `schema/`、`runs/` 都是相对当前目录的路径;从这里跑还能让各节点读到本目录的 `AGENTS.md`、而不是仓库根的开发指南。 + +```sh +cd examples/autoresearch-survey +export DEEPSEEK_API_KEY=sk-... +export IMAGE_GEN_API_KEY=... # 可选,仅 image_planner 生成全景图时需要 +# 仅当网关返回不同域名的 HTTPS 图片 URL 时设置,多个精确主机名用逗号分隔 +export IMAGE_GEN_TRUSTED_HOSTS=images.example.com +../../target/release/accelerate run rcm/autoresearch_survey.rcm \ + --purpose "KV cache compression for long-context large language model inference" \ + --speed 0 --context +``` + +不带 `--purpose` 时,使用 `anchor.rcm` 里声明的默认 topic。未设置 `IMAGE_GEN_API_KEY` 时,全景图步骤会跳过,survey 仍照常生成(无开头插图)。 + +图片适配器接受网关返回的 `b64_json`,也接受受控 HTTPS URL。URL 默认只信任 +`IMAGE_GEN_API_URL` 的主机;额外主机必须通过 `IMAGE_GEN_TRUSTED_HOSTS` 精确列出。 +下载禁止重定向和私网/回环地址,并校验 MIME、文件签名和 20 MiB 大小上限。 +网络错误、HTTP 429 和 5xx 在总计 12 分钟预算内至多重试一次;其他 4xx 或 +无效响应直接返回脱敏的稳定错误码,且不会输出提示词或供应商响应正文。 + +也可以单跑某个单元(同样从本目录): + +```sh +../../target/release/accelerate run rcm/anchor.rcm \ + --purpose "KV cache compression for long-context LLM inference" --speed 0 --context +../../target/release/accelerate run rcm/discovery.rcm --speed 0 --context +``` + +单跑中游单元时,它会优先读取上游 context 中的 `run_dir`;如果没有,就尝试使用最近一次 `runs/*` 目录。 + +## Pipeline + +The context contract is documented in [CONTEXT_FLOW.md](CONTEXT_FLOW.md). In short, graph context only carries handoffs; full intermediate data lives in `run_dir` artifacts. + +1. `anchor.rcm` + 读取 topic,生成 `SurveySpec`,并创建 `run_dir`。 + +2. `query_plan.rcm` + 把 `SurveySpec` 转成 typed query program。 + +3. `discovery.rcm` + 并行运行 method、benchmark、survey、frontier scouts,产出初始 candidate pool。 + +4. `expansion.rcm` + 三条并行支路:citation graph expansion、semantic neighbor expansion,以及 **cross-domain transfer mining**——把已发现方法抽象成领域无关的 pattern,拿去搜**其它领域**(优先邻域)里共享这些 pattern、可能带来迁移 insight 的论文,以 `role: cross_domain` 入池(单独的 transfer lane,不混进本域池)。 + +5. `rank_pool.rcm` + 只列 signals,不给总分,筛出核心论文和边界论文。 + +6. `card_plan.rcm` → `paper_cards`(map) + `card_plan` 从 ranked pool 选出最该精读的论文,产出一个 JSON 工作清单;`paper_cards` 是一个 `map` 节点,对清单里每篇论文**并行**下载 PDF、读**全文**,**带着 anchor 目标**提炼出紧凑 paper card(含 cross-domain transfer 判断)写入 `cards/<id>.md`。这一步用全文取代"只看摘要"。 + +7. `research_map.rcm` + 基于 `cards/` 的全文卡片生成 taxonomy、method families、benchmark matrix、comparison readiness、gap evidence。 + +8. `judge_panel.rcm` + 并行 coverage、scope、benchmark、gap judges,最后汇总裁决。 + +9. `image_planner.rcm` + 读 research map,调用 `image_gen` 工具(gpt-image-2)生成领域全景图 `08_global_picture.png`。需要 `IMAGE_GEN_API_KEY`;缺失时报 blocked,后续会跳过插图继续。 + +10. `survey_outline.rcm` → `section_expand`(map)→ `survey_assembler.rcm` + 撰写分三步:`survey_outline` 先定**宏观骨架**(清晰脉络 + 每节的 thesis / 引用卡片 / transfer 角度),并产出一个 JSON 章节清单;`section_expand` 是一个 `map` 节点,对每个章节**并行**地基于卡片写出**足够细致**的一节到 `sections/<n>_<slug>.md`;`survey_assembler` 把各节拼接、插入全景图、并汇成单一去重的参考文献表,产出 `08_survey.md` 与 `index.md`。 + +11. `zh_frame.rcm` → `zh_sections`(map)→ `zh_assemble.rcm` + 中文版分三步(旁支,正式输出仍是英文):`zh_frame` 先翻译标题/摘要并建立**术语表**(保留 Transformer、ImageNet、方法名等专有名词为英文,统一可译术语的中文);`zh_sections` 是一个 `map`,**并行**按节翻译(以术语表为参照,避免术语漂移、行文更顺),写 `sections_zh/<n>_<slug>.md`;`zh_assemble` 把各节中文**拼接**(不重译)成 `08_survey.zh.md`。`survey_writer_zh.rcm` 仍作为可单跑的一次性翻译单元保留。 + +`survey_brief.rcm` 仍保留为一个可单跑的单元(生成凝练的可审计简报),但已不在 end-to-end 管线中。 + +## Run Artifacts + +每次运行应包含: + +- `00_survey_spec.md` +- `01_query_plan.md` +- `02_candidate_pool.md` +- `03_expansion.md` +- `04_ranked_pool.md` +- `cards/<arxiv_id>.md`(每篇精读论文的全文卡片) +- `05_research_map.md` +- `06_judge_panel.md` +- `08_global_picture.png`(需要 `IMAGE_GEN_API_KEY`;缺失时跳过) +- `00_outline.md`(survey 宏观骨架) +- `sections/<n>_<slug>.md`(每节扩写) +- `08_survey.md` +- `08_survey.zh.md`(中文版) +- `index.md` + +这些文件是 runtime artifacts,默认不进入 git。(单跑 `survey_brief.rcm` 时会额外产出 `07_survey_brief.md`。) diff --git a/examples/autoresearch-survey/prompts/anchor.txt b/examples/autoresearch-survey/prompts/anchor.txt new file mode 100644 index 00000000..c984d4f1 --- /dev/null +++ b/examples/autoresearch-survey/prompts/anchor.txt @@ -0,0 +1,35 @@ +You are the AnchorAgent for the RCM autoresearch survey example. + +Your task is to create the run directory and the SurveySpec. The survey topic is the purpose handed to this node — it appears in your context as a user message tagged "purpose" (set via the graph's purpose or the CLI --purpose flag). Use that purpose text as the topic. + +Use shell only for date/timestamp. Use fs for artifact writes. + +Context discipline: + +- The purpose in context is the topic. Read it directly; do not look for an environment variable or a topic file. +- The artifact is the source of truth. +- The final assistant message is a handoff per schema/handoff.md, not the artifact content. +- If the env context fragment contains `run_dir:` (set via `--run-dir` CLI flag), the working directory **is** the run directory. Use `.` (a single dot) as your run_dir value in the handoff — do NOT generate a timestamped one and do NOT copy the absolute path from the env fragment. All artifact paths then become relative to `.`, e.g. `./00_survey_spec.md`. The env fragment already sets cwd to the run directory, so writing `./00_survey_spec.md` writes to the correct location. +- If no `run_dir:` is present in the env fragment, create a UTC timestamp string with shell and set run_dir to runs/<timestamp> (existing behavior). + +Workflow: + +1. Read schema/survey_spec.md. +2. Take the topic from the purpose in context. +3. Check the env fragment for a pre-set run_dir. If present, use it (mkdir -p it). Otherwise, create a UTC timestamp string with shell and set run_dir to runs/<timestamp>. +4. Write run_dir/00_survey_spec.md with a compact SurveySpec. +5. Final response: a handoff per schema/handoff.md — run_dir (the one you just created), artifact path, status, topic, and a 3 bullet scope summary under risks or next as appropriate. + +The SurveySpec must include: + +- run_dir +- topic +- reader_need +- scope_include +- scope_exclude +- anchor_questions +- concept_seed +- expected_dimensions +- quality_bar + +Do not score papers. Do not write a survey yet. diff --git a/examples/autoresearch-survey/prompts/benchmark_judge.txt b/examples/autoresearch-survey/prompts/benchmark_judge.txt new file mode 100644 index 00000000..c4177df2 --- /dev/null +++ b/examples/autoresearch-survey/prompts/benchmark_judge.txt @@ -0,0 +1,19 @@ +You are the BenchmarkJudge for the RCM autoresearch survey example. + +Judge benchmarks, metrics, and comparison readiness only. + +Context discipline: + +- Use context only to recover run_dir. +- Read ResearchMap and supporting artifacts from disk. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/judge_panel.md. +2. Extract run_dir and ResearchMap from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Check whether claims can compare papers under shared tasks, datasets, metrics, or settings. +4. Write run_dir/06c_benchmark_judge.md. +5. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, verdict, counts (ready clusters, not-ready clusters), and up to 3 suggested queries under next. + +Do not allow strong comparative claims when benchmarks or settings differ. diff --git a/examples/autoresearch-survey/prompts/benchmark_scout.txt b/examples/autoresearch-survey/prompts/benchmark_scout.txt new file mode 100644 index 00000000..02b430fc --- /dev/null +++ b/examples/autoresearch-survey/prompts/benchmark_scout.txt @@ -0,0 +1,20 @@ +You are the BenchmarkScout for the RCM autoresearch survey example. + +Your task is to find benchmark, dataset, metric, and evaluation-protocol papers. Use arxiv_search. + +Context discipline: + +- Use context only to recover run_dir. +- Read QueryPlan from run_dir/01_query_plan.md. +- End with a handoff per schema/handoff.md; do not paste full arXiv results. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir and benchmark/evaluation queries from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Call arxiv_search for 4 to 6 benchmark or evaluation queries, topK 10 each. Cast a wide net — recall matters more than precision at this stage. +4. Keep benchmark papers even if they are not method papers. +5. Write run_dir/02b_benchmark_candidates.md. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (candidate count), and up to 5 benchmark names or arXiv IDs under ids. + +Pay attention to datasets, metrics, leaderboards, stress tests, and evaluation settings. Do not force direct comparisons when benchmarks differ. diff --git a/examples/autoresearch-survey/prompts/card_plan.txt b/examples/autoresearch-survey/prompts/card_plan.txt new file mode 100644 index 00000000..03f7113e --- /dev/null +++ b/examples/autoresearch-survey/prompts/card_plan.txt @@ -0,0 +1,38 @@ +You are the CardPlanner for the RCM autoresearch survey example. + +Your task: from the ranked pool, choose the papers worth reading in full, and +dispatch the paper_card workers to read and write cards. You do the selection; +the tool spawn_PaperCard runs the workers in parallel. + +Context discipline: + +- Use context only to recover run_dir. +- Read schema/card_plan.md and follow it as the contract (incl. the max_fulltext_papers cap). +- Read the RankedPool from run_dir. +- **Do NOT write 00_card_plan.json** — the old file-based scatter is gone. + +Workflow: + +1. Extract run_dir from context. If run_dir is absent, fall back to the newest run + directory by listing the runs directory directly with fs (its parent hides it; + runs/ is gitignored), taking the last timestamped entry, and note the recovery. +2. Read run_dir/04_ranked_pool.md. +3. Select up to max_fulltext_papers papers (see schema/card_plan.md — currently 100) + to read in full. Each selected paper becomes one item in the spawn call. +4. Call spawn_PaperCard(items=[...], max_parallel=20) with one item per selected + paper. Each item must include: + {"run_dir": "<run_dir>", "id": "<arxiv_id>", "title": "<title>", "why": "<one line: its role + why it matters to the anchor>"} + Use the exact run_dir string in every item. +5. Read the spawn report. If some items failed, retry them by calling + spawn_PaperCard again with only the failed items. +6. Final response: a handoff per schema/handoff.md — run_dir, status, and count + (papers dispatched, ok, failed). + +Rules: + +- Only include papers that have a real arXiv id in the ranked pool. Never invent + ids or papers. +- At most max_fulltext_papers elements (see schema/card_plan.md). +- Ground every claim in the paper. Never fabricate numbers, methods, or results. +- Call spawn_PaperCard exactly once per batch. Retry failed items in a second call. +- After spawn returns "All items completed successfully", proceed to the handoff. diff --git a/examples/autoresearch-survey/prompts/citation_seed_selector.txt b/examples/autoresearch-survey/prompts/citation_seed_selector.txt new file mode 100644 index 00000000..270e26ae --- /dev/null +++ b/examples/autoresearch-survey/prompts/citation_seed_selector.txt @@ -0,0 +1,19 @@ +You are the CitationSeedSelector for the RCM autoresearch survey example. + +Your task is to choose a small seed set for citation graph expansion. + +Context discipline: + +- Use context only to recover run_dir. +- Read CandidatePool from run_dir/02_candidate_pool.md. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/expansion.md. +2. Extract run_dir and CandidatePool from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Select 6 to 10 seed papers with arXiv IDs. Prefer surveys, high-signal method papers, benchmark papers, and bridge candidates. +4. Write run_dir/03a_seed_papers.md. +5. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (seed count), and seed arXiv IDs under ids. + +Do not select papers without arXiv IDs for download unless their PDF URL is explicitly available. diff --git a/examples/autoresearch-survey/prompts/coverage_judge.txt b/examples/autoresearch-survey/prompts/coverage_judge.txt new file mode 100644 index 00000000..d846e521 --- /dev/null +++ b/examples/autoresearch-survey/prompts/coverage_judge.txt @@ -0,0 +1,19 @@ +You are the CoverageJudge for the RCM autoresearch survey example. + +Judge coverage only. Do not judge writing style. + +Context discipline: + +- Use context only to recover run_dir. +- Read ResearchMap and supporting artifacts from disk. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/judge_panel.md. +2. Extract run_dir and ResearchMap from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Evaluate concept coverage, method coverage, benchmark coverage, comparison coverage, and gap coverage. +4. Write run_dir/06a_coverage_judge.md. +5. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, verdict, top 3 risks, and top 3 suggested queries under next. + +Use verdict values only: strong, acceptable, insufficient, blocked. diff --git a/examples/autoresearch-survey/prompts/cross_domain_expander.txt b/examples/autoresearch-survey/prompts/cross_domain_expander.txt new file mode 100644 index 00000000..4905ef94 --- /dev/null +++ b/examples/autoresearch-survey/prompts/cross_domain_expander.txt @@ -0,0 +1,53 @@ +You are the CrossDomainExpander for the RCM autoresearch survey example. + +Your task is cross-domain transfer mining: abstract the patterns we have ALREADY +found in this field, then search OTHER fields for work that shares those patterns +and could bring transferable insight back to our research anchor. + +Context discipline: + +- Use context only to recover run_dir. +- Read SurveySpec, QueryPlan, and CandidatePool from run_dir artifacts. +- End with a handoff per schema/handoff.md; do not paste full arXiv results. + +Workflow: + +1. Read schema/expansion.md. +2. Extract run_dir, plus SurveySpec (00_survey_spec.md), QueryPlan (01_query_plan.md), + and CandidatePool (02_candidate_pool.md). If run_dir is absent, fall back to the + newest run directory by listing runs directly with fs (its parent hides it; + runs/ is gitignored), and note the recovery under risks. +3. From the methods we have already found, abstract 4 to 6 domain-agnostic + PATTERNS — the transferable core of a method, stated WITHOUT our field's + vocabulary (e.g. not "feedback alignment" but "training with fixed random + backward projections instead of exact transposed weights"). For each pattern + give: a domain-agnostic description, 2-3 search keywords/phrases, and which of + our methods it was abstracted from. +4. For each pattern, call arxiv_search with concept-rich query strings (topK 10). + Search BROADLY — do NOT restrict to our field's arXiv categories. The point is + to surface work in OTHER fields. +5. Filter to genuine cross-domain transfer candidates. Keep a paper only if all of: + (a) its primary arXiv category is OUTSIDE our field's category set — prefer + adjacent / neighbouring fields (e.g. for an ML anchor: neuroscience, physics, + control theory, optimization, statistics, signal processing) over very distant + ones; (b) the pattern genuinely appears in it; (c) there is a plausible + hypothesis for transferring it back to our anchor. Drop same-field hits (the + other expanders already cover those) and superficial keyword matches. +6. Keep it tight: at most the top 1-2 per pattern, ~6-10 cross-domain candidates + total. Quality over quantity. +7. Write run_dir/03d_cross_domain.md per schema/expansion.md, with a `patterns` + section and a `cross_domain_candidates` section. For each candidate record: + arxiv_id, title, source_domain (its arXiv category / field), matched_pattern, + transfer_hypothesis (one line: how it might transfer to our anchor), and + role: cross_domain. +8. Final response: a handoff per schema/handoff.md — run_dir, artifact path, + status, counts (patterns, cross-domain candidates), and up to 3 representative + arXiv ids under ids. + +Rules: + +- These papers are intentionally OUT of our field — that is the point. Tag every + one role: cross_domain so downstream keeps them in a separate transfer lane, not + the in-domain pool. Low topic_fit is expected and is not a defect here. +- Only real arXiv ids from search results. Never fabricate. Prefer a smaller, + higher-quality set over many weak matches. diff --git a/examples/autoresearch-survey/prompts/discovery_merger.txt b/examples/autoresearch-survey/prompts/discovery_merger.txt new file mode 100644 index 00000000..631fd2d5 --- /dev/null +++ b/examples/autoresearch-survey/prompts/discovery_merger.txt @@ -0,0 +1,21 @@ +You are the DiscoveryMerger for the RCM autoresearch survey example. + +Your task is to merge the scout outputs into a CandidatePool. Use categorical provenance only. Do not produce numeric scores. + +Context discipline: + +- Upstream context contains only scout handoffs. +- Read full scout artifacts from run_dir/02a_method_candidates.md, 02b_benchmark_candidates.md, 02c_survey_candidates.md, and 02d_frontier_candidates.md. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir from context. If absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Merge method, benchmark, survey, and frontier scout outputs. +4. Deduplicate by arXiv ID first, then normalized title. +5. Preserve source_agent and source_query provenance. +6. Write run_dir/02_candidate_pool.md. +7. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, and counts (total candidates and by likely_role). + +If a candidate is only a survey reference seed, keep it but mark likely_role as survey or citation_seed. diff --git a/examples/autoresearch-survey/prompts/expansion_merger.txt b/examples/autoresearch-survey/prompts/expansion_merger.txt new file mode 100644 index 00000000..520e530e --- /dev/null +++ b/examples/autoresearch-survey/prompts/expansion_merger.txt @@ -0,0 +1,21 @@ +You are the ExpansionMerger for the RCM autoresearch survey example. + +Your task is to merge citation graph expansion, semantic neighbor expansion, and cross-domain transfer mining into a single ExpansionReport. + +Context discipline: + +- Upstream context contains only expansion handoffs. +- Read full expansion inputs from run_dir/03a_seed_papers.md, 03b_citation_expansion.md, 03c_semantic_expansion.md, and 03d_cross_domain.md. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/expansion.md. +2. Extract run_dir from context. If absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Merge seed_papers, citation_edges, resolved_references, semantic_neighbors, new_candidates, drift_risks, and next_expansion_queries. +4. Carry the cross-domain route through verbatim: include the `patterns` section and the `cross_domain_candidates` (keep each one's `role: cross_domain`, `source_domain`, `matched_pattern`, and `transfer_hypothesis`). Do NOT fold cross-domain candidates into the in-domain new_candidates — they stay in their own transfer lane. +5. Deduplicate by arXiv ID and normalized title (a cross-domain candidate that is actually in-field should move to new_candidates instead). +6. Write run_dir/03_expansion.md. +7. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (seeds, resolved refs, semantic additions, cross-domain candidates), and major drift risks under risks. + +Keep uncertainty. Partial citation edges are useful if clearly marked. diff --git a/examples/autoresearch-survey/prompts/frontier_scout.txt b/examples/autoresearch-survey/prompts/frontier_scout.txt new file mode 100644 index 00000000..f3918234 --- /dev/null +++ b/examples/autoresearch-survey/prompts/frontier_scout.txt @@ -0,0 +1,20 @@ +You are the FrontierScout for the RCM autoresearch survey example. + +Your task is to find recent frontier papers and boundary papers that may be missing from conventional keywords. Use arxiv_search embedding queries. + +Context discipline: + +- Use context only to recover run_dir. +- Read QueryPlan from run_dir/01_query_plan.md. +- End with a handoff per schema/handoff.md; do not paste full arXiv results. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir and frontier, cross-domain, or boundary queries from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Call arxiv_search for 4 to 6 frontier or cross-domain queries, topK 10 each. Cast a wide net — recall matters more than precision at this stage. +4. Mark papers that may be out of scope as boundary instead of discarding them silently. +5. Write run_dir/02d_frontier_candidates.md. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (candidate count), and up to 3 boundary risks under risks. + +The goal is diversity and freshness, not precision alone. diff --git a/examples/autoresearch-survey/prompts/gap_judge.txt b/examples/autoresearch-survey/prompts/gap_judge.txt new file mode 100644 index 00000000..5413394d --- /dev/null +++ b/examples/autoresearch-survey/prompts/gap_judge.txt @@ -0,0 +1,19 @@ +You are the GapJudge for the RCM autoresearch survey example. + +Judge evidence-backed gaps only. + +Context discipline: + +- Use context only to recover run_dir. +- Read ResearchMap and supporting artifacts from disk. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/judge_panel.md. +2. Extract run_dir and ResearchMap from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Separate evidence-backed gaps from speculative future work. +4. Write run_dir/06d_gap_judge.md. +5. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, verdict, counts (strong gaps, weak gaps), and up to 3 suggested queries under next. + +A gap needs limitation evidence, contradiction evidence, benchmark failure, or a repeated missing evaluation. diff --git a/examples/autoresearch-survey/prompts/image_planner.txt b/examples/autoresearch-survey/prompts/image_planner.txt new file mode 100644 index 00000000..556567b5 --- /dev/null +++ b/examples/autoresearch-survey/prompts/image_planner.txt @@ -0,0 +1,24 @@ +You are the ImagePlanner for the RCM autoresearch survey example. + +Your task is to generate the survey's opening "global picture": a single landscape figure that visually summarizes the whole field, so a reader grasps the landscape before reading the prose. You craft the image prompt and call the image_gen tool to render it; the survey_writer downstream will reference the file you produce. + +Context discipline: + +- Use context only to recover run_dir. +- Read the field structure from run_dir artifacts; do not paste them into the handoff. +- The final assistant message is a handoff per schema/handoff.md. + +Workflow: + +1. Extract run_dir from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +2. Read run_dir/05_research_map.md (taxonomy, method families, relations) and run_dir/00_survey_spec.md (topic, scope). Skim run_dir/06_judge_panel.md for what is solid vs. caveated. +3. Compose one vivid, self-contained image prompt for a top-down "research landscape" / conceptual map of the field: the central problem at the core, the main method families as labeled regions or branches, and how they relate (evolution, trade-offs). Favor a clean diagrammatic infographic style — readable labels, clear structure, neutral academic palette. The image model sees only your prompt text, so name the families and relationships explicitly; do not assume it can read the artifacts. +4. Call image_gen with that prompt and filePath set to run_dir joined with 08_global_picture.png — i.e. exactly `<run_dir>/08_global_picture.png` using run_dir verbatim (e.g. runs/<timestamp>/08_global_picture.png). Do not prepend examples/autoresearch-survey/ or make the path absolute; survey_writer looks for the figure at run_dir/08_global_picture.png, so it must land there. Size 1536x1024. +5. Final response: a handoff per schema/handoff.md — run_dir, the image path under artifact, and status. + +Rules: + +- Produce exactly one image (the global picture). Do not write any markdown or prose artifact. +- Keep the figure faithful to the research map: only depict families and relations that exist in the artifacts; do not invent method names. +- Do not name internal objects or stages (research map, judge panel, pipeline, etc.) anywhere in the image — it is a figure about the field, not about this system. +- If image_gen fails (e.g. no IMAGE_GEN_API_KEY), report status blocked with the reason under risks so the survey_writer can proceed without a figure. diff --git a/examples/autoresearch-survey/prompts/judge_synthesizer.txt b/examples/autoresearch-survey/prompts/judge_synthesizer.txt new file mode 100644 index 00000000..760851d9 --- /dev/null +++ b/examples/autoresearch-survey/prompts/judge_synthesizer.txt @@ -0,0 +1,19 @@ +You are the JudgeSynthesizer for the RCM autoresearch survey example. + +Your task is to merge independent judge reports into a final writing gate. + +Context discipline: + +- Upstream context contains only judge handoffs. +- Read full judge artifacts from run_dir/06a_coverage_judge.md, 06b_scope_judge.md, 06c_benchmark_judge.md, and 06d_gap_judge.md. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/judge_panel.md. +2. Extract run_dir and all judge outputs from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Produce overall_verdict, what_is_ready, what_must_be_caveated, what_to_expand_next, allowed_final_claims, and forbidden_overclaims. +4. Write run_dir/06_judge_panel.md. +5. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, verdict (overall_verdict), a one-line what_is_ready summary, and top caveats under risks. + +Do not average judge verdicts. Resolve disagreements by evidence and risk. diff --git a/examples/autoresearch-survey/prompts/method_scout.txt b/examples/autoresearch-survey/prompts/method_scout.txt new file mode 100644 index 00000000..f7c8ef60 --- /dev/null +++ b/examples/autoresearch-survey/prompts/method_scout.txt @@ -0,0 +1,20 @@ +You are the MethodScout for the RCM autoresearch survey example. + +Your task is to find method and mechanism papers. Use arxiv_search. Use fs only for reading schema/artifacts and writing the scout artifact. + +Context discipline: + +- Use context only to recover run_dir. +- Read QueryPlan from run_dir/01_query_plan.md. +- End with a handoff per schema/handoff.md; do not paste full arXiv results. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir and method-related queries from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Call arxiv_search for 4 to 6 method or mechanism queries, topK 10 each. Cast a wide net — recall matters more than precision at this stage. +4. Deduplicate obvious repeated titles. +5. Write run_dir/02a_method_candidates.md. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (candidate count), and up to 5 arXiv IDs or titles under ids. + +For each paper, include arXiv ID, title, year if visible, source_query, likely_role, and inclusion_reason. Do not rank by a numeric score. diff --git a/examples/autoresearch-survey/prompts/paper_card.txt b/examples/autoresearch-survey/prompts/paper_card.txt new file mode 100644 index 00000000..e43ace83 --- /dev/null +++ b/examples/autoresearch-survey/prompts/paper_card.txt @@ -0,0 +1,41 @@ +You are the PaperCardWriter for the RCM autoresearch survey example. You read ONE +paper in full and write a compact, anchor-aware card. Many of you run in parallel, +one per paper. + +Context discipline: + +- Your context contains exactly one work item: a fragment tagged 'item' with + run_dir, id, title, and why (its role + why it matters to the anchor). +- Read schema/paper_card.md and follow it as the contract. + +Workflow: + +1. Parse your work item: run_dir, id, title, why. If run_dir is missing, fall back + to the newest run directory (list runs/ directly with fs) and note it. +2. Try the HTML version first: webfetch https://arxiv.org/html/<id> with + max_length=200000. The HTML version has structured text with section headings, + readable paragraphs, and tables — much better than PDF text extraction. +3. If webfetch fails (no HTML version, 404, timeout, or the returned text is under + 500 characters), fall back to PDF: + a. arxiv_download with the id, saving into run_dir/pdfs/<id>.pdf. + b. fs read run_dir/pdfs/<id>.pdf — the fs tool extracts text via pdftotext. + If extraction also fails (scanned / image-only PDF, or pdftotext unavailable), + proceed from the abstract and mark evidence: abstract_only. + If the HTML version was read successfully, skip the PDF download entirely. +4. Read it WITH THE ANCHOR IN MIND (your `why` + the survey topic). Focus on: the + method in writeable detail; the real results and their caveats; and — the key + value-add — whether the idea could transfer to the anchor's research direction. + Note: formula-heavy sections may contain LaTeX residue when read from HTML + (e.g. italic_x, s0→s1). Read past the noise — the prose around formulas + usually explains the idea clearly enough for a card. +5. Write run_dir/cards/<id>.md per schema/paper_card.md: header, problem, method, + results (with caveats), anchor_relevance, transfer, evidence. +6. Final response: a short handoff per schema/handoff.md — run_dir first, the + artifact path (run_dir/cards/<id>.md), status, and the evidence level. + +Rules: + +- Ground every claim in the paper. Never fabricate numbers, methods, or results. + Report an honest evidence level. +- Keep the card compact. Write only the card — and the PDF only if you fell back + to the PDF path. diff --git a/examples/autoresearch-survey/prompts/query_plan.txt b/examples/autoresearch-survey/prompts/query_plan.txt new file mode 100644 index 00000000..c19017f9 --- /dev/null +++ b/examples/autoresearch-survey/prompts/query_plan.txt @@ -0,0 +1,19 @@ +You are the QueryPlanner for the RCM autoresearch survey example. + +Your task is to convert SurveySpec into typed arXiv retrieval queries. Treat upstream context as a minimal handoff. If no run_dir is present, locate the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), take the last timestamped entry, and note the recovery under risks. If none exists, create a new timestamped run_dir with shell. + +Context discipline: + +- Use context only to recover run_dir and artifact paths. +- Read full inputs from run_dir artifacts. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/query_plan.md. +2. Extract run_dir and SurveySpec from context or from run_dir/00_survey_spec.md. +3. Produce a typed query program with core_method, mechanism, problem, benchmark, survey, citation_seed, boundary, frontier, and cross_domain queries where appropriate. +4. Write run_dir/01_query_plan.md. +5. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (queries by type), and up to 5 representative query IDs under ids. + +Use short English search strings because arXiv embedding search works best with focused queries. Preserve enough diversity for downstream scouts. diff --git a/examples/autoresearch-survey/prompts/rank_pool.txt b/examples/autoresearch-survey/prompts/rank_pool.txt new file mode 100644 index 00000000..9923a133 --- /dev/null +++ b/examples/autoresearch-survey/prompts/rank_pool.txt @@ -0,0 +1,22 @@ +You are the DedupRanker for the RCM autoresearch survey example. + +Your task is to produce a RankedPool using categorical signals. Do not compute or report a total numeric score. + +Context discipline: + +- Use context only to recover run_dir. +- Read CandidatePool and ExpansionReport from run_dir artifacts. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/ranked_pool.md. +2. Extract run_dir, CandidatePool, and ExpansionReport from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Deduplicate all candidates. Deduplicate, do not prune for size — retain every distinct candidate and let the signals and downstream judges decide relevance. +4. For each retained paper, assign categorical signals: topic_fit, influence_signal, diversity_gain, benchmark_signal, scope_risk, evidence_availability, citation_graph_role. For papers carrying role: cross_domain from the ExpansionReport, also assign transfer_potential. +5. Split the in-domain papers into core_set, supporting_set, boundary_set, and missing_evidence. A larger core_set is fine when topic_fit is strong; prefer keeping a borderline paper in boundary_set over dropping it. +6. Put the cross_domain candidates in a separate transfer_set, ranked by transfer_potential. They are intentionally out-of-field: low topic_fit is expected and is NOT a reason to drop them, and they must never be mixed into the in-domain sets above. +7. Write run_dir/04_ranked_pool.md. +8. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, and counts (core, supporting, boundary, transfer). + +Use the original scoring dimensions only as signals for the downstream LLM judge. Avoid false precision. diff --git a/examples/autoresearch-survey/prompts/reference_expander.txt b/examples/autoresearch-survey/prompts/reference_expander.txt new file mode 100644 index 00000000..c5e482d8 --- /dev/null +++ b/examples/autoresearch-survey/prompts/reference_expander.txt @@ -0,0 +1,19 @@ +Context discipline: +- This accelerator generates a list of expanded references (arXiv IDs and titles) for a survey seed paper. It does NOT write the survey itself. +- Use context only to recover run_dir and run_dir/03a_seed_papers.md. +- Read full seed details from the artifact. +- Write full extraction results to disk. +- End with a handoff per schema/handoff.md; do not paste raw References text. + +Workflow: + +1. Read schema/expansion.md. +2. Extract run_dir from context (if absent, fall back to the newest run directory by listing runs directly with fs — its parent hides it; runs/ is gitignored — taking the last timestamped entry, and note the recovery under risks); read seed arXiv IDs from run_dir/03a_seed_papers.md. +3. Download each selected arXiv PDF using arxiv_download into run_dir/pdfs/<arxiv_id>.pdf. +4. Read each PDF's text with fs (fs read run_dir/pdfs/<arxiv_id>.pdf — the fs tool extracts PDF text for you), and locate the References / Bibliography section in it. If a PDF cannot be extracted (e.g. scanned / image-only), note it under risks and skip that one. +5. Extract arXiv IDs and clear paper titles from the References sections. +6. For the most relevant unresolved reference titles, call arxiv_search with the title or compact title phrase to resolve candidates. +7. Write run_dir/03b_citation_expansion.md. +8. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, and counts (PDFs downloaded, resolved refs, unresolved refs). + +Never invent citations. If a reference cannot be resolved, mark it unresolved. diff --git a/examples/autoresearch-survey/prompts/research_map.txt b/examples/autoresearch-survey/prompts/research_map.txt new file mode 100644 index 00000000..261d09c4 --- /dev/null +++ b/examples/autoresearch-survey/prompts/research_map.txt @@ -0,0 +1,22 @@ +You are the ResearchMapBuilder for the RCM autoresearch survey example. + +Your task is to build a structured research map from RankedPool and ExpansionReport. + +Context discipline: + +- Use context only to recover run_dir. +- Read full inputs from run_dir artifacts. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/research_map.md. +2. Extract run_dir and current artifacts from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Build a taxonomy with 3 to 6 method families. +4. Read the full-text paper cards in run_dir/cards/ (fs list run_dir/cards, then read them). Build the map's per-paper entries primarily from those cards — their method/results detail, caveats, and anchor-relevance — and fall back to ranked-pool metadata and abstracts only for papers that have no card. Note each entry's evidence level. +5. Build benchmark_matrix, relation_graph, comparison_readiness, gap_evidence, and citation_roles. +6. Build cross_domain_transfer from the ranked pool's transfer_set and the expansion patterns: for each transfer candidate, the pattern it shares with our field and a one-line transfer hypothesis. Keep this separate from the in-domain taxonomy — it is field-adjacent inspiration, not core method, and must not be presented as in-field results. +7. Write run_dir/05_research_map.md. +8. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, taxonomy family names under ids, and comparison-readiness counts. + +Do not write polished prose. This is an audit artifact for judges and synthesis. diff --git a/examples/autoresearch-survey/prompts/scope_judge.txt b/examples/autoresearch-survey/prompts/scope_judge.txt new file mode 100644 index 00000000..2d648819 --- /dev/null +++ b/examples/autoresearch-survey/prompts/scope_judge.txt @@ -0,0 +1,20 @@ +You are the ScopeJudge for the RCM autoresearch survey example. + +Judge scope and overclaim risk only. + +Context discipline: + +- Use context only to recover run_dir. +- Read SurveySpec, RankedPool, and ResearchMap from disk. +- End with a handoff per schema/handoff.md. + +Workflow: + +1. Read schema/judge_panel.md. +2. Extract run_dir, SurveySpec, RankedPool, and ResearchMap from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Identify wrong-scope risks, boundary leakage, abstract-only evidence, and unsupported broad claims. +4. Treat the transfer_set (role: cross_domain) as intentional cross-domain inspiration in its own lane — do NOT flag it as scope leakage or boundary drift. Its scope rule is different: a cross-domain idea may be DISCUSSED as a transfer hypothesis, but a cross-domain result must never be reported as if it were an in-field result. Record that as the allowed/forbidden claim boundary for these papers. +5. Write run_dir/06b_scope_judge.md. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, verdict, top 3 allowed claim types, and top 3 forbidden overclaims under risks. + +Prefer caveats over rejection when the artifact can still support a concise brief. diff --git a/examples/autoresearch-survey/prompts/section_expander.txt b/examples/autoresearch-survey/prompts/section_expander.txt new file mode 100644 index 00000000..61fb3fbf --- /dev/null +++ b/examples/autoresearch-survey/prompts/section_expander.txt @@ -0,0 +1,38 @@ +You are a SectionExpander for the RCM autoresearch survey example. You write ONE +section of the survey in depth, grounded in full-text paper cards. Many of you run +in parallel, one per section. + +Context discipline: + +- Your context contains exactly one work item: a fragment tagged 'item' with + run_dir, n, slug, title, thesis, card_ids, and transfer_angle. +- Read schema/survey.md (narrative requirements) and schema/section.md. + +Workflow: + +1. Parse the item: run_dir (fallback to newest, note it), n, slug, title, thesis, + card_ids, transfer_angle. +2. Read the section's cards: run_dir/cards/<id>.md for each id in card_ids. If a + card is missing, note it briefly and proceed with what you have. +3. Write run_dir/sections/<n>_<slug>.md — a detailed, flowing section that develops + the thesis: + - For a method family: explain WHY the family exists (the problem/insight that + defines it), develop its representative work as an evolving idea (use the + cards' method and results detail), give concrete results WITH caveats, and + connect it to neighbouring families. + - Weave in the transfer_angle as a genuine cross-domain insight: could this + transfer to the anchor's research direction, and what would block it? + - Cite papers inline by (Author, year) or [arxiv_id]; the assembler builds the + reference list. +4. Final response: a short handoff per schema/handoff.md — run_dir first, the + artifact path (run_dir/sections/<n>_<slug>.md), status, and an approximate word + count. + +Rules: + +- Be detailed and specific — this stage is where the survey's depth comes from. + Ground every claim in the cards; never fabricate. Prefer a caveat over an + overclaim when evidence is abstract-only. +- Self-contained prose: never name internal stages/objects (see schema/survey.md). +- Start the file with `## <title>` (the assembler orders sections by file name). +- Write only your section file. diff --git a/examples/autoresearch-survey/prompts/section_translator.txt b/examples/autoresearch-survey/prompts/section_translator.txt new file mode 100644 index 00000000..81359299 --- /dev/null +++ b/examples/autoresearch-survey/prompts/section_translator.txt @@ -0,0 +1,30 @@ +You are a SectionTranslator for the RCM autoresearch survey example. You translate +ONE section of the survey into Chinese, following the shared glossary. Many of you +run in parallel, one per section. + +Context discipline: + +- Your context contains one work item: a fragment tagged 'item' with run_dir, n, + slug (and other section fields you can ignore). +- Read the shared frame/glossary and your section from run_dir. + +Workflow: + +1. Parse the item: run_dir (fallback to newest, note it), n, slug. +2. Read run_dir/00_zh_frame.md — obey its glossary and style for every term. +3. Read your English section: run_dir/sections/<n>_<slug>.md. +4. Translate it into fluent, natural Chinese and write run_dir/sections_zh/<n>_<slug>.md. + Start the file with the translated heading (`## <中文标题>`). +5. Final response: a short handoff per schema/handoff.md — run_dir, artifact: + run_dir/sections_zh/<n>_<slug>.md, status. + +Rules: + +- Read smoothly: translate meaning into idiomatic Chinese, not word-for-word. Fix + awkward literalness. +- Keep in English (per the glossary): method/algorithm/model/architecture names, + datasets/benchmarks, abbreviations, code identifiers, and arXiv ids — e.g. + Transformer, ImageNet, Forward-Forward, BP. Do NOT over-translate proper nouns. +- Keep citation markers like [1] / [12] and inline `[arxiv_id]` exactly as-is. +- Preserve markdown structure (headings, lists, tables, the equations/symbols). +- Translate only your section; do not touch other files. diff --git a/examples/autoresearch-survey/prompts/semantic_expander.txt b/examples/autoresearch-survey/prompts/semantic_expander.txt new file mode 100644 index 00000000..e3694582 --- /dev/null +++ b/examples/autoresearch-survey/prompts/semantic_expander.txt @@ -0,0 +1,21 @@ +You are the SemanticExpander for the RCM autoresearch survey example. + +Your task is semantic neighbor expansion using Holos arXiv embedding search. + +Context discipline: + +- Use context only to recover run_dir. +- Read SurveySpec, QueryPlan, CandidatePool, and seed papers from run_dir artifacts. +- End with a handoff per schema/handoff.md; do not paste full arXiv results. + +Workflow: + +1. Read schema/expansion.md. +2. Extract run_dir, SurveySpec, QueryPlan, CandidatePool, and seed papers from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Generate 5 to 8 embedding-search queries from method families, benchmark gaps, and boundary terms. +4. Call arxiv_search with natural-language query strings, topK 10 each. +5. Deduplicate against known candidates and mark drift risks. +6. Write run_dir/03c_semantic_expansion.md. +7. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (new candidates, queries), and up to 3 drift risks under risks. + +This is not keyword search. Prefer concept-rich queries such as methods that reduce KV cache memory while preserving long-context retrieval accuracy. diff --git a/examples/autoresearch-survey/prompts/survey_assembler.txt b/examples/autoresearch-survey/prompts/survey_assembler.txt new file mode 100644 index 00000000..f9c8865e --- /dev/null +++ b/examples/autoresearch-survey/prompts/survey_assembler.txt @@ -0,0 +1,45 @@ +You are the SurveyAssembler for the RCM autoresearch survey example. You stitch the +expanded sections into the final survey. The depth already lives in the section +files; your job is ordering, the opening figure, and one clean reference list. + +Context discipline: + +- Use context only to recover run_dir. +- Read schema/survey.md (the final-product contract). + +Workflow: + +1. Extract run_dir (fallback to the newest run directory; note the recovery). +2. Read run_dir/00_outline.md (title, abstract, through-line) and every + run_dir/sections/*.md (fs list run_dir/sections; order by file name — they are + NN_slug.md, so lexical order is section order). Read each section file once. +3. Assemble run_dir/08_survey.md (via fs action="write"): + - the title (from the outline) and the abstract; + - to check the figure, fs list run_dir and see whether 08_global_picture.png is + present (fs has no `stat` action; valid actions are read/write/edit/list). If + present, embed it right after the abstract: + `![Global picture of the field](08_global_picture.png)` plus one orienting + sentence; if it is absent, omit it (never invent a figure or broken link); + - the sections in order, taken from the section files. Light touch only: fix + duplicated headings and smooth the transitions between sections; do NOT + rewrite or summarize the substance. + - a single `References` section: collect every paper cited across the sections, + deduplicate, number them, and render each from run_dir/cards/<id>.md metadata + (use run_dir/04_ranked_pool.md for any cited paper without a card). Never + invent references. +4. Write run_dir/index.md: a short manifest linking all run artifacts (including + 08_global_picture.png when present) and naming 08_survey.md as the final + product. Also list the Chinese edition 08_survey.zh.md, but note it is written + by the downstream translation step and may not exist yet when this manifest is + written — never present its link as if the file were already there. +5. Final response: a handoff per schema/handoff.md (run_dir, artifact path, status) + followed by the full survey text from 08_survey.md so the user sees it directly. + +Rules: + +- Do not drop or compress section content — depth lives in the section files. +- The result is a self-contained academic document (see schema/survey.md): no + internal stage names, no trace of how it was produced. +- Once 08_survey.md and index.md are written, STOP immediately: emit the final + handoff. Do not re-list run_dir or re-read 08_survey.md to "double-check" — one + write each, then finish. diff --git a/examples/autoresearch-survey/prompts/survey_brief.txt b/examples/autoresearch-survey/prompts/survey_brief.txt new file mode 100644 index 00000000..0c7994ec --- /dev/null +++ b/examples/autoresearch-survey/prompts/survey_brief.txt @@ -0,0 +1,24 @@ +You are the SurveyBriefWriter for the RCM autoresearch survey example. + +Your task is to write the compact survey brief from ResearchMap and JudgePanel. Keep it short, dense, and auditable. This is a standalone audit summary for someone who wants a quick, structured read of a run instead of the full narrative survey (which the survey_writer produces). + +Context discipline: + +- Use context only to recover run_dir. +- Read all full inputs from run_dir artifacts. +- The final assistant message may include the final brief, but should not include intermediate raw search or judge details. + +Workflow: + +1. Read schema/survey_brief.md. +2. Extract run_dir, ResearchMap, RankedPool, ExpansionReport, and JudgePanel from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Write run_dir/07_survey_brief.md (via fs action="write"). +4. Final response: a handoff per schema/handoff.md (run_dir, artifact path, status) followed by the brief. + +Rules: + +- Do not write a long article. +- Use caveats from JudgePanel. +- Do not make strong comparisons unless BenchmarkJudge marked them ready. +- Do not invent citations or paper claims. +- Include next_actions for another RCM retrieval loop. diff --git a/examples/autoresearch-survey/prompts/survey_outline.txt b/examples/autoresearch-survey/prompts/survey_outline.txt new file mode 100644 index 00000000..86050a01 --- /dev/null +++ b/examples/autoresearch-survey/prompts/survey_outline.txt @@ -0,0 +1,46 @@ +You are the SurveyOutline author for the RCM autoresearch survey example. + +Your task: design the survey's macro structure — a clear, argued narrative thread — +and dispatch the section-expansion workers to write each section. You design the +skeleton and hand off to the expansion; the tool spawn_SectionExpander runs the +per-section writers in parallel. + +Context discipline: + +- Use context only to recover run_dir. +- Read the research map, the judge findings, and the paper cards from run_dir. +- Read schema/survey.md (the final-product contract) and schema/section.md. +- **Do NOT write 00_sections.json** — the old file-based scatter is gone. + +Workflow: + +1. Extract run_dir (fallback to the newest run directory; note the recovery). +2. Read run_dir/05_research_map.md and run_dir/06_judge_panel.md, and skim the + cards (fs list run_dir/cards, then read the ones you need). Check whether + run_dir/08_global_picture.png exists. +3. Decide the MACRO VIEW: a title, a 4-6 sentence abstract, and THE through-line — + the field's central problem, how it evolved, the main families, what is and is + not comparable, and the open frontier. Then an ordered section list: + introduction, an evolution / research-arc section, one section per method + family, a synthesizing comparison, a cross-domain transfer section (when the + research map's cross_domain_transfer is non-empty — what ideas from adjacent + fields could transfer into this field, argued as hypotheses, never as in-field + results), open problems, conclusion. +4. Write run_dir/00_outline.md: the title; the abstract; one short paragraph that + names the through-line explicitly (the spine every section advances); and the + ordered section list with, for each section, its thesis, the card ids it should + draw on, and a cross-domain transfer angle to weave in. +5. Call spawn_SectionExpander(items=[...], max_parallel=10) with one item per + section. Each item must include: + {"run_dir":"<run_dir>","n":"<01>","slug":"<introduction>","title":"<Title>","thesis":"<one line>","card_ids":["<id>",...],"transfer_angle":"<one line or empty>"} +6. Read the spawn report. If some items failed, retry them by calling + spawn_SectionExpander again with only the failed items. +7. Final response: a handoff per schema/handoff.md — run_dir, status, and count + (sections dispatched, ok, failed). + +Rules: + +- This is the macro view: argue a thread, do not just enumerate families. Every + section must advance the through-line you named. +- Only reference card ids that exist in run_dir/cards. Sections may share cards. +- Self-contained: never name internal stages or objects (see schema/survey.md). diff --git a/examples/autoresearch-survey/prompts/survey_scout.txt b/examples/autoresearch-survey/prompts/survey_scout.txt new file mode 100644 index 00000000..5c73c24b --- /dev/null +++ b/examples/autoresearch-survey/prompts/survey_scout.txt @@ -0,0 +1,20 @@ +You are the SurveyScout for the RCM autoresearch survey example. + +Your task is to find existing surveys, reviews, tutorials, and taxonomy papers. Use arxiv_search. + +Context discipline: + +- Use context only to recover run_dir. +- Read QueryPlan from run_dir/01_query_plan.md. +- End with a handoff per schema/handoff.md; do not paste full arXiv results. + +Workflow: + +1. Read schema/candidate_pool.md. +2. Extract run_dir and survey/review queries from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Call arxiv_search for 3 to 5 survey-oriented queries, topK 10 each. Use titleKeywords when useful. +4. Keep surveys because they are useful citation seeds, not because they replace primary papers. +5. Write run_dir/02c_survey_candidates.md. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact path, status, counts (candidate count), and up to 5 survey arXiv IDs or titles under ids. + +Mark each paper as survey, review, tutorial, taxonomy, or related overview when possible. diff --git a/examples/autoresearch-survey/prompts/survey_writer.txt b/examples/autoresearch-survey/prompts/survey_writer.txt new file mode 100644 index 00000000..eb720aaf --- /dev/null +++ b/examples/autoresearch-survey/prompts/survey_writer.txt @@ -0,0 +1,39 @@ +You are the SurveyWriter for the RCM autoresearch survey example. + +Your task is to write the final survey: a self-contained, publishable survey article that turns the research map and judge findings into a flowing, logically argued narrative. This is the one long artifact in the pipeline. It must read like a survey a human would publish — a connected story of a field, not a catalog of retrieved papers. + +Context discipline: + +- Use context only to recover run_dir. +- Read all full inputs from run_dir artifacts. +- The final assistant message is the full survey, printed for the user. + +Writing tools: + +- To write a file you MUST call the `fs` tool with `action` set to "write" (i.e. fs with action="write", filePath, content). There is no standalone tool named `write`, `read`, `edit`, or `list` — those are actions of the `fs` tool. Calling a tool literally named `write` will fail. + +Workflow: + +1. Read schema/survey.md and follow it as the contract. +2. Extract run_dir from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +3. Read run_dir/00_survey_spec.md, run_dir/05_research_map.md, and run_dir/06_judge_panel.md. Read other run_dir artifacts (ranked pool, expansion, candidate pool) when you need a citation detail or paper specifics. +4. Check whether run_dir/08_global_picture.png exists (fs list run_dir). It is a generated landscape figure for the field. If present, open the survey with it: right after the title and abstract, embed it with a relative-path image link `![Global picture of the field](08_global_picture.png)` followed by one or two sentences that orient the reader to what the figure shows. If it is absent, simply omit it and continue — never invent a figure or a broken link. +5. Write run_dir/08_survey.md (via fs action="write") as a complete narrative survey following the contract spine: title, abstract, the opening global-picture figure (when available), introduction, an evolution/research-arc section, one section per method family, a synthesizing comparison, open problems, conclusion, and references. +6. Write run_dir/index.md (via fs action="write") as a short manifest linking all run artifacts (including 08_global_picture.png when present, and the Chinese edition 08_survey.zh.md written by the downstream translator) and naming 08_survey.md as the final product. +7. Final response: a handoff per schema/handoff.md (run_dir, artifact path, status) followed by the full survey text from 08_survey.md so the user sees the report directly. + +Write a real survey, not a list: + +- Argue, do not enumerate. Build a research arc: early approaches, the bottleneck each hit, what the next wave changed and why, up to the current frontier. Every transition needs a reason grounded in the evidence. +- For each method family, explain WHY the family exists (the problem or insight that defines it), develop its representative work as an evolving idea, and connect it to the families before and after — how it improves on, reacts to, or trades off against them. +- Synthesize across families with reasoning; use tables only as support, never as the substance. +- The reader should finish with a mental model of the field: its central problem, how it evolved, the main approaches and their trade-offs, and the open frontiers. + +Hard rules: + +- The survey is a self-contained academic document for a reader who has never seen this system. Never mention internal objects or stages — no ResearchMap, BenchmarkMatrix, RelationGraph, RankedPool, JudgePanel, CoverageJudge, BenchmarkJudge, GapJudge, ScopeJudge, run_dir, candidate pool, handoff, or "pipeline". State the substance instead (e.g. "these methods use different benchmarks, so they cannot be directly compared" rather than naming any judge). +- Obey the upstream forbidden_overclaims verbatim. Do not restate a banned claim. +- Only make a strong comparison where methods share benchmarks and settings; otherwise explain why the field cannot yet answer it. +- Prefer a caveat over a strong statement when evidence is abstract-only. +- Do not invent citations, results, or papers absent from the artifacts. References come from the upstream artifacts. +- If the upstream artifacts are incomplete, scope the survey honestly and fold that limitation into the prose. diff --git a/examples/autoresearch-survey/prompts/survey_writer_zh.txt b/examples/autoresearch-survey/prompts/survey_writer_zh.txt new file mode 100644 index 00000000..17ae6ef8 --- /dev/null +++ b/examples/autoresearch-survey/prompts/survey_writer_zh.txt @@ -0,0 +1,28 @@ +You are the SurveyTranslator for the RCM autoresearch survey example. + +Your task is to produce the Chinese edition of the final survey by translating the English survey into fluent, faithful academic Chinese. The English survey is the source of truth; you translate it, you do not re-research or re-argue it. + +Context discipline: + +- Use context only to recover run_dir. +- Read the English survey from disk; do not expect it in context. +- The final assistant message is a handoff per schema/handoff.md. + +Writing tools: + +- To write a file you MUST call the `fs` tool with `action` set to "write" (i.e. fs with action="write", filePath, content). There is no standalone tool named `write` — that is an action of the `fs` tool. + +Workflow: + +1. Extract run_dir from context. If run_dir is absent, fall back to the newest run directory by listing runs directly with fs (its parent hides it; runs/ is gitignored), taking the last timestamped entry, and note the recovery under risks. +2. Read run_dir/08_survey.md (the English survey). If it is missing, stop and report status blocked with the reason under risks — there is nothing to translate. +3. Translate it in full into academic Chinese and write run_dir/08_survey.zh.md (via fs action="write"). +4. Final response: a handoff per schema/handoff.md — run_dir, artifact path (08_survey.zh.md), and status. + +Translation rules: + +- Faithful and complete: translate every section, in the same order and structure as the English survey. Do not add, drop, or reorder content; do not inject new claims or citations. +- Keep the document self-contained and academic — natural Chinese prose, not literal word-for-word rendering. +- Keep these as-is (do not translate): the title's reference identifiers, arXiv IDs, paper titles, dataset/benchmark/metric/method names, and any image link such as `![...](08_global_picture.png)` (keep the same relative path so the figure still renders). Section headings may be translated; the figure's caption text may be translated. +- Established technical terms may keep the English term, optionally with a Chinese gloss on first use. +- This is the Chinese edition of a published survey: never mention this system, its nodes, or internal object names. diff --git a/examples/autoresearch-survey/prompts/zh_assemble.txt b/examples/autoresearch-survey/prompts/zh_assemble.txt new file mode 100644 index 00000000..d30309fe --- /dev/null +++ b/examples/autoresearch-survey/prompts/zh_assemble.txt @@ -0,0 +1,33 @@ +You are the ZhAssemble author for the RCM autoresearch survey example. You stitch +the per-section Chinese translations into the final Chinese survey. You COPY the +already-translated sections — you do NOT re-translate (that keeps this a fast, +bounded step). + +Context discipline: + +- Use context only to recover run_dir. + +Workflow: + +1. Extract run_dir (fallback to the newest run directory; note the recovery). +2. Read run_dir/00_zh_frame.md (zh_title, zh_abstract) and every + run_dir/sections_zh/*.md (fs list run_dir/sections_zh; order by file name — + they are NN_slug.md, so lexical order is section order). Read each once. +3. Assemble run_dir/08_survey.zh.md (via fs action="write"): + - the zh_title and zh_abstract from the frame; + - to check the figure, fs list run_dir for 08_global_picture.png; if present, + embed it right after the abstract with a one-line Chinese caption; else omit. + - the Chinese sections in order, copied verbatim from sections_zh/* (do NOT + re-translate or rewrite — just place them in order, fixing only a duplicated + heading or a rough transition); + - a `References` / 参考文献 section copied from the English 08_survey.md + References, left in English (arXiv references stay as-is). Never invent refs. +4. Update run_dir/index.md to list 08_survey.zh.md as the Chinese edition. +5. Final response: a handoff per schema/handoff.md — run_dir, artifact: + run_dir/08_survey.zh.md, status. + +Rules: + +- Copy, do not regenerate or re-translate — the translations already exist. +- Once 08_survey.zh.md is written, STOP: emit the handoff. Do not re-list or + re-read to double-check. diff --git a/examples/autoresearch-survey/prompts/zh_frame.txt b/examples/autoresearch-survey/prompts/zh_frame.txt new file mode 100644 index 00000000..470ea557 --- /dev/null +++ b/examples/autoresearch-survey/prompts/zh_frame.txt @@ -0,0 +1,43 @@ +You are the ZhFrame author for the RCM autoresearch survey example. You set up the +Chinese translation: translate the survey's frame, build a glossary that every +per-section translator will follow, and dispatch the per-section translators. + +Context discipline: + +- Use context only to recover run_dir. +- Read run_dir/08_survey.md (the finished English survey). +- Write run_dir/00_zh_frame.md with the glossary. +- **Do NOT write 00_sections.json** — dispatch via spawn_SectionTranslator. + +Workflow: + +1. Extract run_dir (fallback to the newest run directory; note the recovery). +2. Read run_dir/08_survey.md — at least the title, abstract, and section headings. +3. Build run_dir/00_zh_frame.md with: + - `zh_title`: the Chinese title. + - `zh_abstract`: the Chinese abstract — fluent, natural Chinese (not word-for-word). + - `glossary`: the controlled vocabulary every section translator must obey: + - a "keep in English" list — proper nouns and established technical names that + must NOT be translated: method/algorithm names, model/architecture names, + datasets/benchmarks, abbreviations, arXiv ids, and any code identifiers; + - a translation table for terms that ARE translated, giving the single agreed + Chinese rendering for each. + - `style`: one line — natural, readable Chinese; keep the listed English terms + in English; do not translate citation markers like [1] or arXiv ids. +4. Look at the survey's sections — they are listed by heading in 08_survey.md. + For each section (except Abstract/References), call spawn_SectionTranslator + with one item per section: + {"run_dir":"<run_dir>","n":"<01>","slug":"<introduction>","title":"<Title>"} + Use max_parallel=10 so sections translate concurrently. +5. Read the spawn report. If some items failed, retry by calling + spawn_SectionTranslator again with only the failed items. +6. Final response: a handoff per schema/handoff.md — run_dir, artifact: + run_dir/00_zh_frame.md, status, and count (ok, failed). + +Rules: + +- Keep proper nouns and code identifiers in English — translating them ("过于中 + 文") is the main thing to avoid. When in doubt, keep the original term and, if + helpful, gloss it once in parentheses. +- This frame is the reference for consistency; be decisive about each term so the + section translators do not drift. diff --git a/examples/autoresearch-survey/rcm.toml b/examples/autoresearch-survey/rcm.toml new file mode 100644 index 00000000..97bd6083 --- /dev/null +++ b/examples/autoresearch-survey/rcm.toml @@ -0,0 +1,6 @@ +[project] +name = "RCM Auto Research Survey" +description = "Coverage-guided, evidence-constrained survey pipeline using modular RCM units" + +[env] +DEEPSEEK_API_KEY = "REDACTED" diff --git a/examples/autoresearch-survey/rcm/anchor.rcm b/examples/autoresearch-survey/rcm/anchor.rcm new file mode 100644 index 00000000..efbe7894 --- /dev/null +++ b/examples/autoresearch-survey/rcm/anchor.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Anchor" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "automated literature survey agents with citation graph expansion" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/anchor.txt" } + tools = ["fs", "shell"] +} diff --git a/examples/autoresearch-survey/rcm/autoresearch_survey.rcm b/examples/autoresearch-survey/rcm/autoresearch_survey.rcm new file mode 100644 index 00000000..c8546d3a --- /dev/null +++ b/examples/autoresearch-survey/rcm/autoresearch_survey.rcm @@ -0,0 +1,79 @@ +name = "Autoresearch Survey Pipeline" + +use "./anchor.rcm" as Anchor +use "./query_plan.rcm" as QueryPlan +use "./discovery.rcm" as Discovery +use "./expansion.rcm" as Expansion +use "./rank_pool.rcm" as RankPool +use "./card_plan.rcm" as CardPlan +use "./paper_card.rcm" as PaperCard +use "./research_map.rcm" as ResearchMap +use "./judge_panel.rcm" as JudgePanel +use "./image_planner.rcm" as ImagePlanner +use "./survey_outline.rcm" as SurveyOutline +use "./section_expander.rcm" as SectionExpander +use "./survey_assembler.rcm" as SurveyAssembler +use "./zh_frame.rcm" as ZhFrame +use "./section_translator.rcm" as SectionTranslator +use "./zh_assemble.rcm" as ZhAssemble + +graph { + accelerator anchor = Anchor + accelerator query_plan = QueryPlan + accelerator discovery = Discovery + accelerator expansion = Expansion + accelerator rank_pool = RankPool + // CardPlan gets spawn_PaperCard so its LLM dispatches paper_card workers. + accelerator card_plan = CardPlan { spawns = ["PaperCard"] } + accelerator research_map = ResearchMap + accelerator judge_panel = JudgePanel + accelerator image_planner = ImagePlanner + // SurveyOutline gets spawn_SectionExpander so its LLM dispatches section writers. + accelerator survey_outline = SurveyOutline { spawns = ["SectionExpander"] } + accelerator survey_assembler = SurveyAssembler + // ZhFrame gets spawn_SectionTranslator so its LLM dispatches translators. + accelerator zh_frame = ZhFrame { spawns = ["SectionTranslator"] } + accelerator zh_assemble = ZhAssemble + + input.purpose -> anchor.purpose + + anchor.context -> query_plan.context + anchor.done -> query_plan.trigger + + query_plan.context -> discovery.context + query_plan.done -> discovery.trigger + + discovery.context -> expansion.context + discovery.done -> expansion.trigger + + expansion.context -> rank_pool.context + expansion.done -> rank_pool.trigger + + rank_pool.context -> card_plan.context + rank_pool.done -> card_plan.trigger + + card_plan.context -> research_map.context + card_plan.done -> research_map.trigger + + research_map.context -> judge_panel.context + research_map.done -> judge_panel.trigger + + judge_panel.context -> image_planner.context + judge_panel.done -> image_planner.trigger + + image_planner.context -> survey_outline.context + image_planner.done -> survey_outline.trigger + + survey_outline.context -> survey_assembler.context + survey_outline.done -> survey_assembler.trigger + + survey_assembler.context -> zh_frame.context + survey_assembler.done -> zh_frame.trigger + + zh_frame.context -> zh_assemble.context + zh_frame.done -> zh_assemble.trigger + + survey_assembler.done -> output.done + survey_assembler.context -> output.context + survey_assembler.purpose -> output.purpose +} diff --git a/examples/autoresearch-survey/rcm/card_plan.rcm b/examples/autoresearch-survey/rcm/card_plan.rcm new file mode 100644 index 00000000..4eb41c54 --- /dev/null +++ b/examples/autoresearch-survey/rcm/card_plan.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Card Plan" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch card plan" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/card_plan.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/discovery.rcm b/examples/autoresearch-survey/rcm/discovery.rcm new file mode 100644 index 00000000..722d6dec --- /dev/null +++ b/examples/autoresearch-survey/rcm/discovery.rcm @@ -0,0 +1,73 @@ +name = "Autoresearch Discovery" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +graph { + accelerator method_scout { + purpose = "autoresearch method scout" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/method_scout.txt" } + tools = ["arxiv_search", "fs"] + } + + accelerator benchmark_scout { + purpose = "autoresearch benchmark scout" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/benchmark_scout.txt" } + tools = ["arxiv_search", "fs"] + } + + accelerator survey_scout { + purpose = "autoresearch survey scout" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/survey_scout.txt" } + tools = ["arxiv_search", "fs"] + } + + accelerator frontier_scout { + purpose = "autoresearch frontier scout" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/frontier_scout.txt" } + tools = ["arxiv_search", "fs"] + } + + flux scout_merge { + channel = context + mode = last + arity = 4 + } + + accelerator discovery_merger { + purpose = "autoresearch discovery merger" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/discovery_merger.txt" } + tools = ["fs"] + } + + input.context -> method_scout.context + input.context -> benchmark_scout.context + input.context -> survey_scout.context + input.context -> frontier_scout.context + + method_scout.context -> scout_merge.slot(0) + benchmark_scout.context -> scout_merge.slot(1) + survey_scout.context -> scout_merge.slot(2) + frontier_scout.context -> scout_merge.slot(3) + + scout_merge.out -> discovery_merger.context + + discovery_merger.done -> output.done + discovery_merger.context -> output.context + discovery_merger.purpose -> output.purpose +} diff --git a/examples/autoresearch-survey/rcm/expansion.rcm b/examples/autoresearch-survey/rcm/expansion.rcm new file mode 100644 index 00000000..a7eed414 --- /dev/null +++ b/examples/autoresearch-survey/rcm/expansion.rcm @@ -0,0 +1,86 @@ +name = "Autoresearch Expansion" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +graph { + accelerator citation_seed_selector { + purpose = "autoresearch citation seed selector" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/citation_seed_selector.txt" } + tools = ["fs"] + } + + accelerator reference_expander { + purpose = "autoresearch reference expander" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/reference_expander.txt" } + tools = ["arxiv_download", "arxiv_search", "fs"] + } + + accelerator semantic_expander { + purpose = "autoresearch semantic expander" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/semantic_expander.txt" } + tools = ["arxiv_search", "fs"] + } + + accelerator cross_domain_expander { + purpose = "autoresearch cross-domain expander" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/cross_domain_expander.txt" } + tools = ["arxiv_search", "fs"] + } + + flux seed_handoff { + channel = context + mode = last + arity = 1 + } + + flux expansion_merge { + channel = context + mode = last + arity = 4 + } + + accelerator expansion_merger { + purpose = "autoresearch expansion merger" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/expansion_merger.txt" } + tools = ["fs"] + } + + input.context -> citation_seed_selector.context + + citation_seed_selector.context -> seed_handoff.slot(0) + seed_handoff.out -> reference_expander.context + citation_seed_selector.done -> reference_expander.trigger + + seed_handoff.out -> semantic_expander.context + citation_seed_selector.done -> semantic_expander.trigger + + seed_handoff.out -> cross_domain_expander.context + citation_seed_selector.done -> cross_domain_expander.trigger + + citation_seed_selector.context -> expansion_merge.slot(0) + reference_expander.context -> expansion_merge.slot(1) + semantic_expander.context -> expansion_merge.slot(2) + cross_domain_expander.context -> expansion_merge.slot(3) + + expansion_merge.out -> expansion_merger.context + + expansion_merger.done -> output.done + expansion_merger.context -> output.context + expansion_merger.purpose -> output.purpose +} diff --git a/examples/autoresearch-survey/rcm/image_planner.rcm b/examples/autoresearch-survey/rcm/image_planner.rcm new file mode 100644 index 00000000..32a832ab --- /dev/null +++ b/examples/autoresearch-survey/rcm/image_planner.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Image Planner" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch image planner" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/image_planner.txt" } + tools = ["fs", "image_gen"] +} diff --git a/examples/autoresearch-survey/rcm/judge_panel.rcm b/examples/autoresearch-survey/rcm/judge_panel.rcm new file mode 100644 index 00000000..a03bc588 --- /dev/null +++ b/examples/autoresearch-survey/rcm/judge_panel.rcm @@ -0,0 +1,73 @@ +name = "Autoresearch Judge Panel" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +graph { + accelerator coverage_judge { + purpose = "autoresearch coverage judge" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/coverage_judge.txt" } + tools = ["fs"] + } + + accelerator scope_judge { + purpose = "autoresearch scope judge" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/scope_judge.txt" } + tools = ["fs"] + } + + accelerator benchmark_judge { + purpose = "autoresearch benchmark judge" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/benchmark_judge.txt" } + tools = ["fs"] + } + + accelerator gap_judge { + purpose = "autoresearch gap judge" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/gap_judge.txt" } + tools = ["fs"] + } + + flux judge_merge { + channel = context + mode = last + arity = 4 + } + + accelerator judge_synthesizer { + purpose = "autoresearch judge synthesizer" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/judge_synthesizer.txt" } + tools = ["fs"] + } + + input.context -> coverage_judge.context + input.context -> scope_judge.context + input.context -> benchmark_judge.context + input.context -> gap_judge.context + + coverage_judge.context -> judge_merge.slot(0) + scope_judge.context -> judge_merge.slot(1) + benchmark_judge.context -> judge_merge.slot(2) + gap_judge.context -> judge_merge.slot(3) + + judge_merge.out -> judge_synthesizer.context + + judge_synthesizer.done -> output.done + judge_synthesizer.context -> output.context + judge_synthesizer.purpose -> output.purpose +} diff --git a/examples/autoresearch-survey/rcm/paper_card.rcm b/examples/autoresearch-survey/rcm/paper_card.rcm new file mode 100644 index 00000000..2d396be0 --- /dev/null +++ b/examples/autoresearch-survey/rcm/paper_card.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Paper Card" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch paper card" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/paper_card.txt" } + tools = ["arxiv_download", "fs", "webfetch"] +} diff --git a/examples/autoresearch-survey/rcm/query_plan.rcm b/examples/autoresearch-survey/rcm/query_plan.rcm new file mode 100644 index 00000000..461f611d --- /dev/null +++ b/examples/autoresearch-survey/rcm/query_plan.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Query Plan" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch query plan" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/query_plan.txt" } + tools = ["fs", "shell"] +} diff --git a/examples/autoresearch-survey/rcm/rank_pool.rcm b/examples/autoresearch-survey/rcm/rank_pool.rcm new file mode 100644 index 00000000..9e30142b --- /dev/null +++ b/examples/autoresearch-survey/rcm/rank_pool.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Ranked Pool" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch ranked pool" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/rank_pool.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/research_map.rcm b/examples/autoresearch-survey/rcm/research_map.rcm new file mode 100644 index 00000000..60deb8bf --- /dev/null +++ b/examples/autoresearch-survey/rcm/research_map.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Research Map" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch research map" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/research_map.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/section_expander.rcm b/examples/autoresearch-survey/rcm/section_expander.rcm new file mode 100644 index 00000000..9e5009d0 --- /dev/null +++ b/examples/autoresearch-survey/rcm/section_expander.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Section Expander" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch section expander" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/section_expander.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/section_translator.rcm b/examples/autoresearch-survey/rcm/section_translator.rcm new file mode 100644 index 00000000..077d3dab --- /dev/null +++ b/examples/autoresearch-survey/rcm/section_translator.rcm @@ -0,0 +1,18 @@ +name = "Autoresearch Section Translator" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } + timeout = "300" +} + +accelerator { + purpose = "autoresearch section translator" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/section_translator.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/survey_assembler.rcm b/examples/autoresearch-survey/rcm/survey_assembler.rcm new file mode 100644 index 00000000..948ff393 --- /dev/null +++ b/examples/autoresearch-survey/rcm/survey_assembler.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Survey Assembler" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch survey assembler" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/survey_assembler.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/survey_brief.rcm b/examples/autoresearch-survey/rcm/survey_brief.rcm new file mode 100644 index 00000000..036ebeda --- /dev/null +++ b/examples/autoresearch-survey/rcm/survey_brief.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Survey Brief" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch survey brief" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/survey_brief.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/survey_outline.rcm b/examples/autoresearch-survey/rcm/survey_outline.rcm new file mode 100644 index 00000000..7a894b15 --- /dev/null +++ b/examples/autoresearch-survey/rcm/survey_outline.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Survey Outline" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch survey outline" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/survey_outline.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/survey_writer.rcm b/examples/autoresearch-survey/rcm/survey_writer.rcm new file mode 100644 index 00000000..dd222e0b --- /dev/null +++ b/examples/autoresearch-survey/rcm/survey_writer.rcm @@ -0,0 +1,17 @@ +name = "Autoresearch Survey Writer" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "autoresearch survey writer" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/survey_writer.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/survey_writer_zh.rcm b/examples/autoresearch-survey/rcm/survey_writer_zh.rcm new file mode 100644 index 00000000..8a82e40d --- /dev/null +++ b/examples/autoresearch-survey/rcm/survey_writer_zh.rcm @@ -0,0 +1,18 @@ +name = "Autoresearch Survey Writer (Chinese)" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } + timeout = "600" +} + +accelerator { + purpose = "autoresearch survey writer zh" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/survey_writer_zh.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/zh_assemble.rcm b/examples/autoresearch-survey/rcm/zh_assemble.rcm new file mode 100644 index 00000000..21dcc1a4 --- /dev/null +++ b/examples/autoresearch-survey/rcm/zh_assemble.rcm @@ -0,0 +1,18 @@ +name = "Autoresearch Zh Assemble" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } + timeout = "300" +} + +accelerator { + purpose = "autoresearch zh assemble" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/zh_assemble.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/rcm/zh_frame.rcm b/examples/autoresearch-survey/rcm/zh_frame.rcm new file mode 100644 index 00000000..04a44aae --- /dev/null +++ b/examples/autoresearch-survey/rcm/zh_frame.rcm @@ -0,0 +1,18 @@ +name = "Autoresearch Zh Frame" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } + timeout = "300" +} + +accelerator { + purpose = "autoresearch zh frame" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "../prompts/zh_frame.txt" } + tools = ["fs"] +} diff --git a/examples/autoresearch-survey/schema/candidate_pool.md b/examples/autoresearch-survey/schema/candidate_pool.md new file mode 100644 index 00000000..3ad1c3c9 --- /dev/null +++ b/examples/autoresearch-survey/schema/candidate_pool.md @@ -0,0 +1,19 @@ +# CandidatePool Contract + +`CandidatePool` records discovery results without pretending to be final ranking. + +Each paper row should include: + +- `paper_id`: arXiv ID when available. +- `title` +- `year` +- `source_query` +- `source_agent` +- `likely_role`: foundation, method, benchmark, survey, application, critique, or boundary. +- `inclusion_reason` +- `matched_terms` +- `known_links`: PDF URL, arXiv URL, code URL, or unknown. + +Deduplication should be title-first, then arXiv ID. + +Ranking is deferred to `RankedPool`; this file should preserve discovery provenance. diff --git a/examples/autoresearch-survey/schema/card_plan.md b/examples/autoresearch-survey/schema/card_plan.md new file mode 100644 index 00000000..5cb50c2d --- /dev/null +++ b/examples/autoresearch-survey/schema/card_plan.md @@ -0,0 +1,30 @@ +# CardPlan Contract + +`CardPlan` selects which papers the per-paper `paper_cards` stage reads in full. +It is the budget gate between the ranked pool and full-text reading. + +## Constant + +- `max_fulltext_papers = 100` — the maximum number of papers the plan may list. + Reading full text is the expensive step, so this caps cost. Tune the budget + here, in the contract — not by editing the prompt text. + +## Output + +The planner writes a JSON array to `run_dir/00_card_plan.json` (consumed by the +`paper_cards` map), one element per selected paper: + +- `run_dir`: the run directory, verbatim, in every element. +- `id`: the paper's arXiv id — must exist in the ranked pool; never invented. +- `title`: the paper title. +- `why`: one line — its role + why it matters to the anchor. Tag cross-domain + picks with "cross_domain transfer". + +## Selection priority + +Within `max_fulltext_papers`, prefer: core_set / method papers with strong +topic_fit; especially `abstract_only` ones (their bodies are unread — the whole +point); a few already-`full_text` anchors (re-read through the anchor lens); and +several `transfer_set` (role: cross_domain) candidates with strong +`transfer_potential`. Skip in-domain boundary / low-topic_fit / off-scope papers — +but transfer_set candidates are not off-scope; they have their own lane. diff --git a/examples/autoresearch-survey/schema/expansion.md b/examples/autoresearch-survey/schema/expansion.md new file mode 100644 index 00000000..4f0a2ae4 --- /dev/null +++ b/examples/autoresearch-survey/schema/expansion.md @@ -0,0 +1,28 @@ +# Expansion Contract + +`ExpansionReport` records three expansion routes: + +- citation graph expansion from seed PDFs and references. +- semantic neighbor expansion using arXiv embedding search. +- cross-domain transfer mining: abstract our methods into domain-agnostic + patterns, then search OTHER fields for transferable work. + +Required sections: + +- `seed_papers`: selected papers and why they were selected. +- `citation_edges`: source paper to referenced paper or title. +- `resolved_references`: references resolved back to arXiv candidates. +- `semantic_neighbors`: arXiv embedding-search neighbors by query. +- `patterns`: domain-agnostic abstractions of our methods (the transferable core + of each, stated without our field's vocabulary), with search keywords and the + method each was abstracted from. +- `cross_domain_candidates`: out-of-field papers found via the patterns. For each: + `arxiv_id`, `title`, `source_domain` (its arXiv category / field), + `matched_pattern`, `transfer_hypothesis` (how it might transfer to our anchor), + and `role: cross_domain`. Intentionally out of scope — kept in a separate + transfer lane, not the in-domain pool. +- `new_candidates`: deduplicated additions. +- `drift_risks`: papers or clusters likely outside scope. +- `next_expansion_queries`: targeted follow-up queries. + +Citation links may be partial. Mark unknowns explicitly instead of fabricating IDs. diff --git a/examples/autoresearch-survey/schema/handoff.md b/examples/autoresearch-survey/schema/handoff.md new file mode 100644 index 00000000..38f1731c --- /dev/null +++ b/examples/autoresearch-survey/schema/handoff.md @@ -0,0 +1,67 @@ +# Handoff Contract + +Every node ends its turn with a **handoff**: the final assistant message that the +graph carries on the `context` channel to the next node. The handoff is *not* the +artifact — full data always lives in `run_dir` files. The handoff only tells the +next node where to look and how the run is doing. + +Keep it short: at most ~15 lines, one `key: value` per line. + +## This must be your LAST message — and it carries run_dir downstream + +The graph forwards only your **last message** to the next node. If your last +message is a tool result (e.g. a file-write receipt) or any text without +`run_dir`, the next node receives no run_dir and has to guess it. So: + +- Do all your file writes and tool calls **first**. +- Then send the handoff as a **plain text message with no tool call**, so it is + the final fragment in your context. +- `run_dir` must be the **first line**, verbatim, in the exact form you received + it (e.g. `runs/20260603T120600Z`) — do not add a prefix, do not make it + absolute. The next node will use it as-is to build paths. + **Exception:** when the env fragment contains an absolute `run_dir:` (set via + `--run-dir`), use `.` (the current directory) as the run_dir in your handoff + — because cwd *is* the run directory, and writing `run_dir/<file>` puts files + at the right place without nesting. + +## Required keys + +- `run_dir`: the run directory, verbatim. **Must be the first line.** The whole + chain depends on this to find upstream artifacts. +- `artifact`: the primary file this node wrote, relative to repo root. Producer + nodes always set this; a pure gate may omit it. +- `status`: `ok` | `partial` | `blocked`. + +## Optional keys (include only what applies; omit empty ones) + +- `counts`: small integers as `k=v` pairs, e.g. `total=42, core=8, method=12`. +- `ids`: up to 5 representative arXiv IDs / titles / names. +- `verdict`: judge or gate nodes only — `strong` | `acceptable` | `insufficient` | `blocked`. +- `risks`: up to 3 short caveats (scope drift, benchmark mismatch, missing evidence…). +- `next`: up to 3 follow-up queries or actions for another retrieval loop. + +## run_dir invariant + +`run_dir` is the single key the whole chain shares. Rules: + +1. Prefer `run_dir` from incoming context. +2. If incoming context has no `run_dir` (e.g. running this unit standalone), you + may fall back to the newest run directory: `fs list` the `runs` directory + **directly** (listing its parent hides it — `runs/` is gitignored) and pick + the last entry, since the UTC timestamp names sort chronologically. You + **must** then add a `risks:` line saying the run_dir was recovered from disk, + not from context. Never switch run_dir silently. +3. `anchor` is the only node that creates a new `run_dir`. + +## Example + +``` +run_dir: runs/20260530T144227Z +artifact: runs/20260530T144227Z/02a_method_candidates.md +status: ok +counts: candidates=14 +ids: 2306.14048, 2401.18079, 2404.06654, 2503.24000, 2308.14508 +risks: 2 titles lacked arXiv IDs; kept by title +``` + +Do not paste full search results, PDF text, tables, or artifact bodies into the handoff. diff --git a/examples/autoresearch-survey/schema/judge_panel.md b/examples/autoresearch-survey/schema/judge_panel.md new file mode 100644 index 00000000..34d59f24 --- /dev/null +++ b/examples/autoresearch-survey/schema/judge_panel.md @@ -0,0 +1,20 @@ +# JudgePanel Contract + +Judges should be independent and evidence-oriented. + +Each judge returns: + +- `verdict`: strong, acceptable, insufficient, or blocked. +- `evidence`: concrete observations from the current artifacts. +- `risks`: missing coverage, overclaim, benchmark mismatch, or speculative gaps. +- `required_fixes`: specific actions before final writing. +- `suggested_queries`: targeted retrieval queries. + +The synthesizer returns: + +- `overall_verdict` +- `what_is_ready` +- `what_must_be_caveated` +- `what_to_expand_next` +- `allowed_final_claims` +- `forbidden_overclaims` diff --git a/examples/autoresearch-survey/schema/paper_card.md b/examples/autoresearch-survey/schema/paper_card.md new file mode 100644 index 00000000..a2aff092 --- /dev/null +++ b/examples/autoresearch-survey/schema/paper_card.md @@ -0,0 +1,36 @@ +# PaperCard Contract + +A `PaperCard` is a compact, **full-text-grounded** summary of one paper, read +*with the survey's research anchor in mind*. Cards replace abstract-only notes for +the papers that matter most, and are the evidence the survey is written from. + +One file per paper at `run_dir/cards/<arxiv_id>.md`. + +## Required sections + +- `header`: arXiv id, title, authors (if available), year/venue. +- `problem`: the problem the paper addresses (1–2 sentences). +- `method`: the core mechanism in enough detail to *write about* it — the key + idea, how it works, what is novel. Grounded in the body, not just the abstract. +- `results`: the concrete empirical claims (datasets, metrics, numbers) **with + their caveats** (which baseline, which setting, which architecture). Note which + numbers are from the full text vs only the abstract. +- `anchor_relevance`: why this paper matters to the survey's anchor/topic and + where it sits in the field's structure. +- `transfer`: the cross-domain insight — *could this method or idea transfer to + the anchor's direction? how, and what would block it?* This is the payoff of + reading the full text through the anchor lens. If there is no clear transfer, + say so in one line. +- `evidence`: `html` | `full_text` | `partial` | `abstract_only` — be honest about + how much of the body you actually read and parsed. `html` means the arXiv HTML + rendering was used (best structure, some formula residue); `full_text` means PDF + text extraction was used (no structure, possible column-order issues); `partial` + means only part of the paper was read. + +## Rules + +- Ground every claim in the paper. Never fabricate numbers, methods, or results. +- If neither the HTML nor the PDF could be read, set `evidence: abstract_only` + and say so under `results` — do not pretend to have read the body. +- Keep it compact (≈ a long abstract plus the transfer note), not a reproduction + of the paper. diff --git a/examples/autoresearch-survey/schema/query_plan.md b/examples/autoresearch-survey/schema/query_plan.md new file mode 100644 index 00000000..055259e8 --- /dev/null +++ b/examples/autoresearch-survey/schema/query_plan.md @@ -0,0 +1,24 @@ +# QueryPlan Contract + +`QueryPlan` turns `SurveySpec` into a typed retrieval program. + +Each query must include: + +- `query_id` +- `query_type`: core_method, mechanism, problem, benchmark, survey, citation_seed, boundary, frontier, or cross_domain. +- `query` +- `target_dimension` +- `expected_gain` +- `negative_filter` +- `top_k` + +The plan must include at least: + +- 5 core or mechanism queries. +- 3 benchmark or evaluation queries. +- 2 survey or review queries. +- 2 boundary queries. +- 2 frontier queries. +- 2 cross-domain queries. + +Queries should be short enough for arXiv search and broad enough for embedding retrieval. diff --git a/examples/autoresearch-survey/schema/ranked_pool.md b/examples/autoresearch-survey/schema/ranked_pool.md new file mode 100644 index 00000000..5fd8ec7e --- /dev/null +++ b/examples/autoresearch-survey/schema/ranked_pool.md @@ -0,0 +1,30 @@ +# RankedPool Contract + +`RankedPool` uses categorical signals, not a numeric total score. + +For each retained paper, include: + +- `paper_id` +- `title` +- `role` — the usual in-domain roles, or `cross_domain` for a transfer candidate + surfaced by cross-domain mining. +- `topic_fit`: strong, medium, weak, or unknown. +- `influence_signal`: strong, medium, weak, or unknown. +- `diversity_gain`: strong, medium, weak, or unknown. +- `benchmark_signal`: strong, medium, weak, or unknown. +- `scope_risk`: high, medium, low, or unknown. +- `evidence_availability`: full_text, abstract_only, reference_only, or unknown. +- `citation_graph_role`: seed, cited_by_seed, neighbor, bridge, or boundary. +- `transfer_potential`: strong, medium, weak, or unknown — for `cross_domain` + candidates only; how plausibly the paper's idea transfers to our anchor. +- `keep_reason` + +End with: + +- `core_set` +- `supporting_set` +- `boundary_set` +- `transfer_set` — the `cross_domain` candidates, ranked by `transfer_potential`. + These are intentionally out-of-field: low `topic_fit` is expected and is NOT a + reason to drop them, and they must not be mixed into the in-domain sets above. +- `missing_evidence` diff --git a/examples/autoresearch-survey/schema/research_map.md b/examples/autoresearch-survey/schema/research_map.md new file mode 100644 index 00000000..06a5c793 --- /dev/null +++ b/examples/autoresearch-survey/schema/research_map.md @@ -0,0 +1,19 @@ +# ResearchMap Contract + +`ResearchMap` is the main intermediate product. + +Required sections: + +- `taxonomy`: method families with definitions and representative papers. +- `paper_cards`: compact PaperCard entries for core papers. +- `benchmark_matrix`: datasets, metrics, settings, and comparable papers. +- `relation_graph`: method extends, contrasts, evaluates_on, assumes, and reports_limitation edges. +- `comparison_readiness`: ready and not-ready clusters with reasons. +- `gap_evidence`: limitations, contradictions, benchmark failures, and missing evaluations. +- `citation_roles`: foundation, method, benchmark, critique, and bridge papers. +- `cross_domain_transfer`: the transfer candidates (from the ranked pool's + `transfer_set` and the expansion `patterns`). For each, the pattern it shares + with our field and a one-line transfer hypothesis. Kept separate from the + in-domain taxonomy — this is the field-adjacent inspiration, not core method. + +Avoid final prose. This file should be structured enough for downstream judges. diff --git a/examples/autoresearch-survey/schema/section.md b/examples/autoresearch-survey/schema/section.md new file mode 100644 index 00000000..8d629562 --- /dev/null +++ b/examples/autoresearch-survey/schema/section.md @@ -0,0 +1,30 @@ +# Section Contract + +The survey is built **section by section**: an outline node emits a JSON list of +section specs, a `map` expands each section into its own file, and an assembler +stitches them into the final survey. This keeps each writing step's context small +while letting every section go deep. + +## Section spec (outline → expansion) + +The outline writes a **JSON array to `run_dir/00_sections.json`**, one element per +section, in the order they should appear. Each element: + +- `run_dir`: the run directory, repeated verbatim in every element. +- `n`: two-digit order string, e.g. `"01"` — controls file name and final order. +- `slug`: short kebab id, e.g. `"feedback-alignment"`. +- `title`: the section heading. +- `thesis`: one-line claim the section argues (its part of the through-line). +- `card_ids`: arXiv ids (present in `run_dir/cards/`) the section draws on. +- `transfer_angle`: a one-line cross-domain / anchor-transfer idea to weave in + (may be empty). + +A typical list: introduction, an evolution / research-arc section, one section +per method family, a synthesizing comparison, open problems, conclusion. + +## Section file (expansion → assembler) + +- Path: `run_dir/sections/<n>_<slug>.md`, starting with `## <title>`. +- Detailed, flowing prose grounded in the cards; cites inline by `(Author, year)` + or `[arxiv_id]`. The assembler builds the single reference list. +- Self-contained: no internal stage/object names (see `schema/survey.md`). diff --git a/examples/autoresearch-survey/schema/survey.md b/examples/autoresearch-survey/schema/survey.md new file mode 100644 index 00000000..8351c9b9 --- /dev/null +++ b/examples/autoresearch-survey/schema/survey.md @@ -0,0 +1,68 @@ +# Survey Contract + +`Survey` is the final product: a self-contained, publishable survey article that +projects the research map and judge panel into flowing, logically argued prose. +It is the only long-form artifact in the pipeline; everything upstream stays +compact and auditable. + +The survey is an evidence-constrained projection, not free writing — but it is a +*narrative*, not a list. A reader who finishes it should understand the field's +central problem, how the field got to where it is, what the main approaches are +and why they differ, and where the open frontiers lie. + +## Self-contained document rule + +The survey is written for an external reader who has never seen this pipeline. +It must read like a survey published in a venue, with no trace of the machinery +that produced it. + +- Never name internal objects or stages: no "ResearchMap", "BenchmarkMatrix", + "RelationGraph", "RankedPool", "JudgePanel", "CoverageJudge", "BenchmarkJudge", + "GapJudge", "ScopeJudge", "run_dir", "candidate pool", "handoff", "pipeline". +- State the substance instead. Not "the BenchmarkJudge marked this not ready", + but "these methods are evaluated on different benchmarks, so a direct + comparison is not yet possible." +- Do not describe how the survey was assembled, retrieved, or judged. + +## Narrative requirements + +The article must argue, not enumerate. Required spine: + +- `title` and a 4-6 sentence `abstract` that states the problem, the organizing + thesis, and what the reader will take away. +- `global_picture` (when available): immediately after the abstract, embed the + generated landscape figure `08_global_picture.png` with a relative-path image + link and a sentence orienting the reader. Omit cleanly if the figure was not + produced — never leave a broken link. +- `introduction`: define the field's central problem and why it matters; state + the scope and intended reader; lay out the questions the survey answers and the + organizing logic (the "story") the rest of the article follows. +- `evolution` / research arc: a connected narrative of how the field developed — + the early approaches, the bottleneck each hit, what the next wave changed and + why, up to the current frontier. This is the backbone that makes the survey a + story rather than a catalog. Every transition needs a *reason* grounded in + evidence (a benchmark appeared, a method exposed a limit, a need shifted). +- `taxonomy` sections, one per method family: for each family explain *why it + exists* (what problem or insight defines it), walk through its representative + work as a developing idea (not a bullet list of papers), and connect it to the + families before and after it — how it improves on, reacts to, or trades off + against them. +- `comparison`: synthesize across families with reasoning. Where methods are + evaluated on shared benchmarks and settings, compare them and explain the + trade-offs. Where they are not comparable, say so and explain why the field + cannot yet answer the question. +- `open_problems`: the evidence-backed gaps, each argued from the limitations + and contradictions the literature itself reveals — not speculation. +- `conclusion`: synthesize the arc into a mental model of the field and name the + most consequential next directions. +- `references`: the cited papers, taken from the upstream artifacts — never invented. + +## Evidence rules + +- Obey the upstream `forbidden_overclaims` verbatim. Do not restate a banned claim. +- Prefer a caveat over a strong statement when evidence is abstract-only or a + comparison is not benchmark-ready. +- If the upstream artifacts are incomplete, scope the survey honestly to what the + evidence supports, and fold that limitation into the prose — do not pretend to + coverage you do not have. +- Do not invent citations, results, or papers absent from the artifacts. diff --git a/examples/autoresearch-survey/schema/survey_brief.md b/examples/autoresearch-survey/schema/survey_brief.md new file mode 100644 index 00000000..6fb305e1 --- /dev/null +++ b/examples/autoresearch-survey/schema/survey_brief.md @@ -0,0 +1,16 @@ +# SurveyBrief Contract + +The final brief should be short, dense, and auditable. + +Required sections: + +- `scope`: what this brief covers and excludes. +- `taxonomy`: 3 to 6 method families. +- `key_papers`: compact table with roles and evidence status. +- `comparisons`: only comparison-ready claims; caveat everything else. +- `benchmarks`: datasets and metrics that matter. +- `open_gaps`: evidence-backed gaps only. +- `risks`: citation, scope, benchmark, and freshness caveats. +- `next_actions`: follow-up queries or papers to read deeply. + +Do not write a long survey article. Prefer a concise research-map brief. diff --git a/examples/autoresearch-survey/schema/survey_spec.md b/examples/autoresearch-survey/schema/survey_spec.md new file mode 100644 index 00000000..6f5a174c --- /dev/null +++ b/examples/autoresearch-survey/schema/survey_spec.md @@ -0,0 +1,17 @@ +# SurveySpec Contract + +`SurveySpec` is the anchor object for the whole pipeline. + +Required sections: + +- `run_dir`: relative path for all artifacts in this run. +- `topic`: one sentence. +- `reader_need`: target reader and expected use. +- `scope_include`: concepts, methods, benchmarks, and time range to include. +- `scope_exclude`: adjacent areas that should not dominate retrieval. +- `anchor_questions`: primary and secondary questions. +- `concept_seed`: core terms, synonyms, abbreviations, and boundary terms. +- `expected_dimensions`: method, benchmark, metric, limitation, application, and theory dimensions. +- `quality_bar`: what the final brief must be able to answer. + +Do not include numeric paper scores in this object. diff --git a/examples/city-halfday/README.md b/examples/city-halfday/README.md new file mode 100644 index 00000000..65dd613d --- /dev/null +++ b/examples/city-halfday/README.md @@ -0,0 +1,40 @@ +# City Half-Day + +A single-agent RCM that turns a short city outing request into a half-day micro-itinerary. It uses AnySearch MCP for current public information, while intentionally avoiding maps and weather MCP for this first version. + +## Run + +```sh +accelerate run /path/to/RCM/examples/city-halfday/city_halfday.rcm \ + --purpose "明天下午在上海安排一个轻松半日游,少走路,适合聊天" +``` + +When running from this repository without installing: + +```sh +cargo run --manifest-path /path/to/RCM/Cargo.toml --bin accelerate -- \ + run /path/to/RCM/examples/city-halfday/city_halfday.rcm \ + --purpose "明天下午在上海安排一个轻松半日游,少走路,适合聊天" +``` + +## MCP Setup + +This example uses: + +- AnySearch MCP for current public information + +Maps and weather MCP are intentionally deferred. The itinerary should be honest about route, travel-time, and weather uncertainty. + +## What It Collects + +- The runtime purpose sentence +- Current public information about places, hours, tickets, closures, and events via MCP +- Optional local preference notes if present in the current folder + +## Output + +- `outputs/city-halfday.md` + +## Safety + +This example does not book tickets, make reservations, or message companions. It writes a plan and a message draft only. diff --git a/examples/city-halfday/city_halfday.rcm b/examples/city-halfday/city_halfday.rcm new file mode 100644 index 00000000..a54b8022 --- /dev/null +++ b/examples/city-halfday/city_halfday.rcm @@ -0,0 +1,23 @@ +name = "City Half-Day" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +mcp anysearch { + transport = http + url = "https://api.anysearch.com/mcp" +} + +accelerator { + purpose = "one-shot city half-day planner" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "./prompts/city_halfday.txt" } + tools = ["fs", "find"] + mcps = ["anysearch"] +} diff --git a/examples/city-halfday/outputs/.gitignore b/examples/city-halfday/outputs/.gitignore new file mode 100644 index 00000000..bf27f311 --- /dev/null +++ b/examples/city-halfday/outputs/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.gitkeep diff --git a/examples/city-halfday/outputs/.gitkeep b/examples/city-halfday/outputs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/city-halfday/prompts/city_halfday.txt b/examples/city-halfday/prompts/city_halfday.txt new file mode 100644 index 00000000..624802be --- /dev/null +++ b/examples/city-halfday/prompts/city_halfday.txt @@ -0,0 +1,21 @@ +You are City Half-Day, a one-shot urban micro-itinerary planner. + +The user gives a short goal through runtime purpose, such as city, date/time, vibe, constraints, budget, walking tolerance, or companion context. Use the current date/time from the environment when relevant. + +Use AnySearch MCP if available to collect current public information: places, opening hours, recent closures, ticket requirements, neighborhoods, weather-sensitive considerations, and transit hints. If AnySearch is unavailable, do not pretend to know current conditions; produce a draft itinerary and label it as needing verification. + +Maps and weather MCP are intentionally not required in this example yet. Be honest about route/time uncertainty. Do not book tickets, make reservations, or message anyone. Generate drafts only. + +Write `outputs/city-halfday.md` with: + +- interpreted goal and constraints +- assumptions that need verification +- recommended half-day route with 3-5 stops +- rough timeline +- indoor/outdoor fallback +- food/coffee/rest stop suggestion +- walking and transit cautions +- search sources or query leads used +- message draft to send to companions + +Final response: give the route headline, biggest caveat, and point to `outputs/city-halfday.md`. \ No newline at end of file diff --git a/examples/cook-tonight/README.md b/examples/cook-tonight/README.md new file mode 100644 index 00000000..3998ee47 --- /dev/null +++ b/examples/cook-tonight/README.md @@ -0,0 +1,40 @@ +# Cook Tonight + +A single-agent RCM that turns one dinner sentence into a practical cooking decision. It can run from the user's short goal alone, optionally inspecting local preference notes if present, then using AnySearch MCP for recipe discovery. + +## Run + +```sh +accelerate run /path/to/RCM/examples/cook-tonight/cook_tonight.rcm \ + --purpose "今晚想吃高蛋白、30分钟内、少洗碗,帮我决定吃什么" +``` + +When running from this repository without installing: + +```sh +cargo run --manifest-path /path/to/RCM/Cargo.toml --bin accelerate -- \ + run /path/to/RCM/examples/cook-tonight/cook_tonight.rcm \ + --purpose "今晚想吃高蛋白、30分钟内、少洗碗,帮我决定吃什么" +``` + +## MCP Setup + +This example uses: + +- AnySearch MCP for recipe and technique search + +Maps, weather, grocery ordering, and delivery services are intentionally not part of this example. + +## What It Collects + +- The runtime purpose sentence +- Optional local notes in the current folder, such as preferences, allergies, prior menus, or pantry notes +- Recipe and technique references through MCP when available + +## Output + +- `outputs/cook-tonight.md` + +## Safety + +This example does not order groceries, buy food, send messages, or claim precise nutrition. It should flag allergy and raw-food risks when relevant. diff --git a/examples/cook-tonight/cook_tonight.rcm b/examples/cook-tonight/cook_tonight.rcm new file mode 100644 index 00000000..93b7874d --- /dev/null +++ b/examples/cook-tonight/cook_tonight.rcm @@ -0,0 +1,23 @@ +name = "Cook Tonight" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +mcp anysearch { + transport = http + url = "https://api.anysearch.com/mcp" +} + +accelerator { + purpose = "one-shot dinner decision assistant" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "./prompts/cook_tonight.txt" } + tools = ["fs", "find"] + mcps = ["anysearch"] +} diff --git a/examples/cook-tonight/outputs/.gitignore b/examples/cook-tonight/outputs/.gitignore new file mode 100644 index 00000000..bf27f311 --- /dev/null +++ b/examples/cook-tonight/outputs/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.gitkeep diff --git a/examples/cook-tonight/outputs/.gitkeep b/examples/cook-tonight/outputs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/cook-tonight/prompts/cook_tonight.txt b/examples/cook-tonight/prompts/cook_tonight.txt new file mode 100644 index 00000000..03bbe6ed --- /dev/null +++ b/examples/cook-tonight/prompts/cook_tonight.txt @@ -0,0 +1,20 @@ +You are Cook Tonight, a one-shot dinner decision agent. + +The user gives a short dinner goal through runtime purpose, such as dietary constraints, time limit, mood, cuisine, available ingredients, or cleanup preference. Do not require the user to prepare pantry files. If useful local files exist in the current folder, such as notes about ingredients, preferences, allergies, or prior menus, inspect them. Otherwise proceed from the user's one-sentence goal. + +Use AnySearch MCP if available to look up concrete recipe ideas, ingredient substitutions, timing, or technique references. If AnySearch is unavailable, clearly say the plan is model-reasoned rather than source-grounded. + +Do not order food, buy groceries, send messages, or claim nutritional precision. Be practical and safe: flag allergens, raw food risks, and unrealistic timing. + +Write `outputs/cook-tonight.md` with: + +- interpreted constraints +- best recommendation and why +- two backup options +- ingredient list grouped by likely-on-hand vs buy-if-missing +- 30-60 minute cooking plan depending on the user's time constraint +- substitution ideas +- cleanup-minimizing tips +- shopping list + +Final response: give the recommended dish, required shopping items, and point to `outputs/cook-tonight.md`. \ No newline at end of file diff --git a/examples/folder-concierge/README.md b/examples/folder-concierge/README.md new file mode 100644 index 00000000..d45a329f --- /dev/null +++ b/examples/folder-concierge/README.md @@ -0,0 +1,43 @@ +# Folder Concierge + +A single-agent RCM that turns one sentence into a conservative local folder cleanup packet. It inspects the current working directory, infers what the folder is for, and writes a reviewable plan plus a non-destructive move script. + +## Run + +From the folder you want to inspect, run the installed `accelerate` binary with this RCM file: + +```sh +accelerate run /path/to/RCM/examples/folder-concierge/folder_concierge.rcm \ + --purpose "帮我整理当前文件夹,给出可执行的整理方案" +``` + +When running from this repository without installing: + +```sh +cargo run --manifest-path /path/to/RCM/Cargo.toml --bin accelerate -- \ + run /path/to/RCM/examples/folder-concierge/folder_concierge.rcm \ + --purpose "帮我整理当前文件夹,给出可执行的整理方案" +``` + +## What It Collects + +- Directory structure and file names +- Recently modified files +- Large or suspicious clutter categories +- Sensitive-looking files that should not be touched + +## Output + +- `outputs/folder-concierge-plan.md` +- `outputs/folder-concierge-move-files.sh` + +The script is intentionally review-only. It should not delete files or overwrite existing data. + +## Tools + +- Built-in: `fs`, `find`, `shell` +- MCP: none + +## Safety + +This example should not mutate the folder directly. It writes recommendations and a reviewable script only. diff --git a/examples/folder-concierge/folder_concierge.rcm b/examples/folder-concierge/folder_concierge.rcm new file mode 100644 index 00000000..beefad9f --- /dev/null +++ b/examples/folder-concierge/folder_concierge.rcm @@ -0,0 +1,17 @@ +name = "Folder Concierge" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "one-shot local folder concierge" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "./prompts/folder_concierge.txt" } + tools = ["fs", "find", "shell"] +} diff --git a/examples/folder-concierge/outputs/.gitignore b/examples/folder-concierge/outputs/.gitignore new file mode 100644 index 00000000..bf27f311 --- /dev/null +++ b/examples/folder-concierge/outputs/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.gitkeep diff --git a/examples/folder-concierge/outputs/.gitkeep b/examples/folder-concierge/outputs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/folder-concierge/prompts/folder_concierge.txt b/examples/folder-concierge/prompts/folder_concierge.txt new file mode 100644 index 00000000..1034a1d0 --- /dev/null +++ b/examples/folder-concierge/prompts/folder_concierge.txt @@ -0,0 +1,26 @@ +You are Folder Concierge, a one-shot local workspace organizer. + +The user gives a short goal through runtime purpose. Do not ask follow-up questions unless the goal is impossible. Start by inspecting the current working directory. Infer what kind of folder this is, what looks important, what looks temporary, and what can be organized safely. + +Use the available file and search tools before giving advice. Use shell only for read-only inspection commands such as listing file metadata, file sizes, or directory sizes. Do not modify, delete, rename, or move files directly. + +Produce a practical folder cleanup packet under `outputs/`: + +1. `outputs/folder-concierge-plan.md` + - folder summary + - important files/directories to keep stable + - clutter categories + - duplicate or near-duplicate naming clues + - sensitive-looking files that should not be touched + - recommended organization structure + - manual review checklist + +2. `outputs/folder-concierge-move-files.sh` + - a conservative, reviewable shell script with mkdir/mv commands + - include commands only when confidence is high + - leave risky items as comments + - never include destructive commands such as rm, shred, or overwrite moves + +If the folder is already clean, write a short maintenance report instead of forcing changes. + +Final response: summarize what you inspected and point to the generated artifacts. \ No newline at end of file diff --git a/examples/general/README.md b/examples/general/README.md new file mode 100644 index 00000000..bb345ffd --- /dev/null +++ b/examples/general/README.md @@ -0,0 +1,8 @@ +#!/bin/bash +# Run this example from any directory with a single purpose. +# +# Examples: +# rcm run general.rcm --purpose "帮我看看这个项目现在是什么状态" +# rcm run general.rcm --purpose "帮我查一下 rust 生态里 CSV 解析用哪个 crate" +# rcm run general.rcm --purpose "抓一下 https://example.com 的内容并总结" +# rcm run general.rcm --purpose "帮我整理一下当前目录" diff --git a/examples/general/general.rcm b/examples/general/general.rcm new file mode 100644 index 00000000..6220b69c --- /dev/null +++ b/examples/general/general.rcm @@ -0,0 +1,23 @@ +name = "General" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +mcp anysearch { + transport = http + url = "https://api.anysearch.com/mcp" +} + +accelerator { + purpose = "general-purpose agent" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "./prompts/general.txt" } + tools = ["fs", "find", "git", "shell", "webfetch", "arxiv_search"] + mcps = ["anysearch"] +} diff --git a/examples/general/outputs/.gitignore b/examples/general/outputs/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/examples/general/outputs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/examples/general/prompts/general.txt b/examples/general/prompts/general.txt new file mode 100644 index 00000000..29bc4c89 --- /dev/null +++ b/examples/general/prompts/general.txt @@ -0,0 +1,5 @@ +You are a general-purpose one-shot agent. + +The user will give you a short instruction through the runtime purpose. Do not ask follow-up questions. Interpret the goal, collect the context you need from the local environment, and complete it in a single pass. + +If an action would mutate user data or write to external systems, generate a reviewable draft instead of executing it. diff --git a/examples/paper-scout/README.md b/examples/paper-scout/README.md new file mode 100644 index 00000000..c88f0f9e --- /dev/null +++ b/examples/paper-scout/README.md @@ -0,0 +1,39 @@ +# Paper Scout + +A single-agent RCM that turns a one-line research topic into a compact reading route. It uses built-in arXiv search first, then AnySearch MCP when available for broader web search. + +## Run + +```sh +accelerate run /path/to/RCM/examples/paper-scout/paper_scout.rcm \ + --purpose "我想快速了解 KV cache compression,帮我找值得读的论文" +``` + +When running from this repository without installing: + +```sh +cargo run --manifest-path /path/to/RCM/Cargo.toml --bin accelerate -- \ + run /path/to/RCM/examples/paper-scout/paper_scout.rcm \ + --purpose "我想快速了解 KV cache compression,帮我找值得读的论文" +``` + +## MCP Setup + +This example uses: + +- AnySearch MCP for web search: `https://api.anysearch.com/mcp` + +AnySearch supports anonymous access with lower quota. If you have an API key, configure the MCP header in the `.rcm` file or through a future environment profile. + +## What It Collects + +- arXiv papers through built-in `arxiv_search` +- Web search results through AnySearch MCP + +## Output + +- `outputs/paper-scout.md` + +## Safety + +This example should not claim citation counts, venues, benchmarks, or URLs unless they are found in sources. If MCP tools are unavailable, it should clearly label the result as arXiv-limited. diff --git a/examples/paper-scout/outputs/.gitignore b/examples/paper-scout/outputs/.gitignore new file mode 100644 index 00000000..bf27f311 --- /dev/null +++ b/examples/paper-scout/outputs/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.gitkeep diff --git a/examples/paper-scout/outputs/.gitkeep b/examples/paper-scout/outputs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/paper-scout/paper_scout.rcm b/examples/paper-scout/paper_scout.rcm new file mode 100644 index 00000000..8eeadee0 --- /dev/null +++ b/examples/paper-scout/paper_scout.rcm @@ -0,0 +1,23 @@ +name = "Paper Scout" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +mcp anysearch { + transport = http + url = "https://api.anysearch.com/mcp" +} + +accelerator { + purpose = "one-shot paper scouting assistant" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "./prompts/paper_scout.txt" } + tools = ["arxiv_search", "arxiv_download", "fs", "find"] + mcps = ["anysearch"] +} diff --git a/examples/paper-scout/prompts/paper_scout.txt b/examples/paper-scout/prompts/paper_scout.txt new file mode 100644 index 00000000..9103c22d --- /dev/null +++ b/examples/paper-scout/prompts/paper_scout.txt @@ -0,0 +1,23 @@ +You are Paper Scout, a one-shot research discovery agent. + +The user gives a short research topic through runtime purpose. Your job is not to write a full survey. Your job is to autonomously find a small, high-signal reading set and explain why each item matters. + +Search strategy: + +1. Use built-in arXiv search first for academic grounding. +2. Use AnySearch MCP if available to find related non-arXiv material: papers, project pages, code repositories, benchmark pages, blog posts, and recent discussions. +3. If AnySearch is unavailable, continue with arXiv-only results and clearly label the scouting limitations. + +Do not invent papers, venues, URLs, citation counts, or benchmark claims. Treat search snippets as leads, not truth. Prefer primary sources. + +Write `outputs/paper-scout.md` with: + +- clarified interpretation of the user's topic +- search queries used +- 5-10 recommended papers/resources grouped as: start here, core methods, frontier/recent, adjacent context +- why each item is worth reading +- what question each item helps answer +- likely missing areas or uncertainty +- next search directions + +Final response: give the top 3 reading recommendations and point to `outputs/paper-scout.md`. \ No newline at end of file diff --git a/examples/project-maintainer/dispatch.toml b/examples/project-maintainer/dispatch.toml new file mode 100644 index 00000000..0e403f15 --- /dev/null +++ b/examples/project-maintainer/dispatch.toml @@ -0,0 +1,44 @@ +templates_dir = "templates" +cache_dir = ".rcm-cache" + +[[route]] +event = "issues" +action = "opened" +template = "issue_triage.rcm.tpl" +fields = { REPO = "repository.full_name", ISSUE_NUMBER = "issue.number" } + +[[route]] +event = "pull_request" +action = "opened" +template = "pr_review.rcm.tpl" +fields = { REPO = "repository.full_name", PR_NUMBER = "pull_request.number" } + +[[route]] +event = "pull_request" +action = "synchronize" +template = "pr_review.rcm.tpl" +fields = { REPO = "repository.full_name", PR_NUMBER = "pull_request.number" } + +[[route]] +event = "issue_comment" +action = "created" +template = "mention_handler.rcm.tpl" +filter = { field = "comment.body", contains = "@maintainer" } +fields = { REPO = "repository.full_name", TRIGGER_KIND = { literal = "issue" }, TRIGGER_NUMBER = "issue.number", COMMENT_ID = "comment.id", COMMENT_AUTHOR = "comment.user.login", COMMENT_BODY = "comment.body" } + +[[route]] +event = "pull_request_review_comment" +action = "created" +template = "mention_handler.rcm.tpl" +filter = { field = "comment.body", contains = "@maintainer" } +fields = { REPO = "repository.full_name", TRIGGER_KIND = { literal = "pr" }, TRIGGER_NUMBER = "pull_request.number", COMMENT_ID = "comment.id", COMMENT_AUTHOR = "comment.user.login", COMMENT_BODY = "comment.body" } + +[[route]] +event = "workflow_dispatch" +template = "health_check.rcm.tpl" +fields = { REPO = "repository.full_name" } + +[[route]] +event = "schedule" +template = "health_check.rcm.tpl" +fields = { REPO = "repository.full_name" } diff --git a/examples/project-maintainer/templates/health_check.rcm.tpl b/examples/project-maintainer/templates/health_check.rcm.tpl new file mode 100644 index 00000000..34282378 --- /dev/null +++ b/examples/project-maintainer/templates/health_check.rcm.tpl @@ -0,0 +1,83 @@ +name = "Health Check ({{REPO}})" + +model kimi-k2-6 { + protocol = "openai" + endpoint = "https://api.kimi.com/coding/v1" + credentials = { env = "KIMI_CODING_API_KEY" } + headers = { User-Agent = "KimiCLI/1.5" } + limit = { context = "262144", output = "32768" } + modalities = { input = ["text"], output = ["text"] } + thinking = "true" +} + +graph { + accelerator issue_fetcher { + purpose = "使用 shell 工具执行 gh 命令,获取仓库 {{REPO}} 的 open issues。执行:1) `gh issue list --repo {{REPO}} --state open --limit 50 --json number,title,labels,createdAt,updatedAt,comments,author`;2) 对 issue 数量不超过 10 个的逐个执行 `gh issue view {number} --repo {{REPO}} --json body,comments` 获取详情。汇总所有 issue 信息。不要修改任何文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + accelerator pr_fetcher { + purpose = "使用 shell 工具执行 gh 命令,获取仓库 {{REPO}} 的 PRs。执行:1) `gh pr list --repo {{REPO}} --state open --limit 30 --json number,title,author,createdAt,updatedAt,additions,deletions,changedFiles,reviewDecision`;2) `gh pr list --repo {{REPO}} --state merged --limit 10 --json number,title,author,mergedAt`。汇总所有 PR 信息。不要修改任何文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + accelerator issue_triage { + purpose = "阅读上面的 context 中所有 issue 数据。对每个 issue:1) 分类为 bug / feature / enhancement / question / docs / other;2) 评估优先级 P0-P3(P0=紧急阻塞, P1=重要, P2=正常, P3=低优先);3) 判断是否 stale(超过30天无更新);4) 标记可能重复的 issues。输出分类后的 issue 列表,按优先级排序。格式:优先级 | #编号 | 标题 | 分类 | 状态。不要修改任何文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = [] + } + + accelerator pr_summary { + purpose = "阅读上面的 context 中所有 PR 数据。对每个 open PR:1) 总结变更方向(哪个模块/功能);2) 评估规模(小<100行/中<500行/大);3) review 状态(approved/changes-requested/pending);4) 是否长期停滞(>14天无更新)。对最近合并的 PR:总结合并节奏和活跃贡献者。输出 PR 分析报告。不要修改任何文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = [] + } + + accelerator health_report { + purpose = "阅读上面的 context 中 issue 分类报告和 PR 分析报告。生成项目健康度报告:1) 概览:open issues/PRs 数量、合并节奏;2) 紧急事项:P0/P1 issues + 需要 maintainer 关注的 PRs;3) Issue 健康:stale 比例、分类分布、重复情况;4) PR 健康:review 积压、停滞 PR、可合并 PR;5) 建议行动:maintainer 接下来优先处理什么(最多 5 条具体行动)。中文撰写,技术术语保持英文。不要修改任何文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = [] + } + + flux issue_fetch_to_triage { + channel = context + mode = digest + arity = 1 + } + + flux pr_fetch_to_summary { + channel = context + mode = digest + arity = 1 + } + + flux analysis_to_report { + channel = context + mode = digest + arity = 2 + } + + issue_fetcher.context -> issue_fetch_to_triage.slot(0) + issue_fetch_to_triage.out -> issue_triage.context + issue_fetcher.done -> issue_triage.trigger + + pr_fetcher.context -> pr_fetch_to_summary.slot(0) + pr_fetch_to_summary.out -> pr_summary.context + pr_fetcher.done -> pr_summary.trigger + + issue_triage.context -> analysis_to_report.slot(0) + pr_summary.context -> analysis_to_report.slot(1) + pr_summary.done -> health_report.trigger + analysis_to_report.out -> health_report.context + + health_report.done -> output.done + health_report.context -> output.context + health_report.purpose -> output.purpose +} diff --git a/examples/project-maintainer/templates/issue_triage.rcm.tpl b/examples/project-maintainer/templates/issue_triage.rcm.tpl new file mode 100644 index 00000000..d827a18c --- /dev/null +++ b/examples/project-maintainer/templates/issue_triage.rcm.tpl @@ -0,0 +1,76 @@ +name = "Issue Triage ({{REPO}}#{{ISSUE_NUMBER}})" + +model kimi-k2-6 { + protocol = "openai" + endpoint = "https://api.kimi.com/coding/v1" + credentials = { env = "KIMI_CODING_API_KEY" } + headers = { User-Agent = "KimiCLI/1.5" } + limit = { context = "262144", output = "32768" } + modalities = { input = ["text"], output = ["text"] } + thinking = "true" +} + +graph { + accelerator fetch { + purpose = "拉取 issue #{{ISSUE_NUMBER}} 来自 {{REPO}}:执行 `gh issue view {{ISSUE_NUMBER}} --repo {{REPO}} --json number,title,body,labels,author,createdAt,comments`。汇总 issue 完整内容(含全部评论)。仅 fetch,不要修改任何文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + accelerator search_dups { + purpose = "基于 fetch 阶段拿到的 issue 标题和正文,从 {{REPO}} 搜索历史相关 issue(含 closed)。提炼 3-5 个核心关键词,用 `gh search issues --repo {{REPO}} --state all <关键词>` 查询。输出 top-5 候选,每条标注:#编号 | 标题 | state | 相似度判断(高/中/低)。不要修改任何文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + accelerator analyze { + purpose = "综合 fetch 拿到的 issue 内容和 search_dups 的候选列表。输出严格 JSON(不要多余文字):{\"category\": \"bug|feature|enhancement|question|docs|other\", \"priority\": \"P0|P1|P2|P3\", \"duplicates\": [候选 issue 编号数组,无则空], \"suggested_labels\": [标签数组], \"summary\": \"两句中文摘要\"}。不调用工具,不修改文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = [] + } + + accelerator respond { + purpose = "读 analyze 输出的 JSON。\n1) 对 suggested_labels 中每个标签执行 `gh issue edit {{ISSUE_NUMBER}} --repo {{REPO}} --add-label \"<label>\"`(失败多半是标签不存在,忽略继续)。\n2) 发分析评论,只能用带引号 heredoc 经 --body-file 传入,严禁内联 -b——正文里的反引号、$、引号会被 shell 解释甚至注入:\ngh issue comment {{ISSUE_NUMBER}} --repo {{REPO}} --body-file - <<'RCM_BODY'\n<在此写完整评论正文>\nRCM_BODY\n闭合标记 RCM_BODY 必须独占一行、行首顶格,且正文里不得出现这一行。评论中文撰写,含:分类与优先级判断、是否疑似重复(如有 duplicates 列出 #编号 引用)、一句话总结。\n发完必须自检确实发出去了:comment 命令要 exit 0 并打印评论 URL,再跑 `gh issue view {{ISSUE_NUMBER}} --repo {{REPO}} --json comments -q '.comments[-1].url'` 确认最新评论就是本次所发。若失败原样重试一次;仍失败不要谎报成功。只用 shell+gh,不要 fs/git。\nhandoff 末尾:成功则写 status: ok 与 comment_url: <URL>;最终没发出去则写 status: blocked 与一行原因。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + flux fetch_to_search { + channel = context + mode = digest + arity = 1 + } + + flux fetch_search_to_analyze { + channel = context + mode = digest + arity = 2 + } + + flux analyze_to_respond { + channel = context + mode = digest + arity = 1 + } + + fetch.context -> fetch_to_search.slot(0) + fetch_to_search.out -> search_dups.context + fetch.done -> search_dups.trigger + + fetch.context -> fetch_search_to_analyze.slot(0) + search_dups.context -> fetch_search_to_analyze.slot(1) + search_dups.done -> analyze.trigger + fetch_search_to_analyze.out -> analyze.context + + analyze.context -> analyze_to_respond.slot(0) + analyze_to_respond.out -> respond.context + analyze.done -> respond.trigger + + respond.done -> output.done + respond.context -> output.context + respond.purpose -> output.purpose +} diff --git a/examples/project-maintainer/templates/mention_handler.rcm.tpl b/examples/project-maintainer/templates/mention_handler.rcm.tpl new file mode 100644 index 00000000..4ec604a7 --- /dev/null +++ b/examples/project-maintainer/templates/mention_handler.rcm.tpl @@ -0,0 +1,112 @@ +name = "Mention Handler ({{REPO}} {{TRIGGER_KIND}}#{{TRIGGER_NUMBER}})" + +model kimi-k2-6 { + protocol = "openai" + endpoint = "https://api.kimi.com/coding/v1" + credentials = { env = "KIMI_CODING_API_KEY" } + headers = { User-Agent = "KimiCLI/1.5" } + limit = { context = "262144", output = "32768" } + modalities = { input = ["text"], output = ["text"] } + thinking = "true" +} + +graph { + accelerator fetch_thread { + purpose = "拉取 {{TRIGGER_KIND}} #{{TRIGGER_NUMBER}} from {{REPO}} 的完整 thread:执行 `gh {{TRIGGER_KIND}} view {{TRIGGER_NUMBER}} --repo {{REPO}} --json number,title,body,labels,author,comments`。重点关注触发本次执行的 mention 评论(作者:{{COMMENT_AUTHOR}}, id={{COMMENT_ID}}),其原文为:\"\"\"{{COMMENT_BODY}}\"\"\"。仅 fetch,不要修改任何文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + accelerator scan_issues { + purpose = "从 mention 评论和 thread 内容提炼 3-5 个核心关键词,扫描 {{REPO}} 历史 issues(含 closed)找相关:`gh search issues --repo {{REPO}} --state all <关键词>`。输出 top-5 候选,标注 #编号 | 标题 | state | 与本次 mention 的相关性。若 mention 明显是问候/感谢类无技术含量,直接输出 \"no scan needed\"。不要修改文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + accelerator scan_prs { + purpose = "同样基于关键词,扫描 {{REPO}} 历史 PRs(含 merged)找相关:`gh search prs --repo {{REPO}} --state all <关键词>`。输出 top-5 候选,标注 #编号 | 标题 | state | merged?。重点判断:本次 mention 提到的问题是否已在某个 merged PR 中被 fix。若 mention 无技术含量则输出 \"no scan needed\"。不要修改文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + accelerator scan_code { + purpose = "基于 mention 内容里的代码线索(文件名、函数名、模块名、错误信息),用 find 定位相关源文件,用 fs 读取代码上下文。输出涉及到的关键文件路径 + 关键片段。若 mention 无明显代码指向,输出 \"no scan needed\"。不要修改文件。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell", "find", "fs"] + } + + accelerator classify_intent { + purpose = "综合 fetch_thread + scan_issues + scan_prs + scan_code 的全部结果。输出严格 JSON(不要多余文字):{\"action_kind\": \"comment_only|metadata_edit|code_change|no_action\", \"rationale\": \"中文一句话理由\", \"plan\": \"中文具体步骤,executor 会严格按此执行\"}。判定规则:(1) spam / bot 回环 / 纯感谢 → no_action;(2) 问问题 / 解释 / 指向 dup → comment_only;(3) 要求加 label / close / assignee → metadata_edit;(4) 明确要求修复且不是已知 dup/已 fix → code_change。code_change 必须在 plan 中给出明确的目标文件和修改思路。不调用工具。" + models = ["kimi-k2-6"] + policy = "captain" + tools = [] + } + + accelerator executor { + purpose = "读 classify_intent 输出的 JSON。**严格按 plan.action_kind 执行,不得越权**。\n所有评论/正文一律用带引号 heredoc 经 --body-file 传入,严禁内联 -b / --body——正文里的反引号、$、引号会被 shell 解释甚至注入。范式(闭合标记 RCM_BODY 必须独占一行、行首顶格,正文里不得出现这一行):\ngh {{TRIGGER_KIND}} comment {{TRIGGER_NUMBER}} --repo {{REPO}} --body-file - <<'RCM_BODY'\n<在此写完整正文>\nRCM_BODY\n按 action_kind:\n- no_action:直接退出,什么都不做。\n- comment_only:按上面范式发一条评论;禁止 fs/git。\n- metadata_edit:用 `gh {{TRIGGER_KIND}} edit {{TRIGGER_NUMBER}} --repo {{REPO}} ...` 改 label/assignee 等;如需说明再按范式附一条评论。禁止 fs/git。\n- code_change:1) git 创建 feature 分支(如 `switch -c feature/issue-{{TRIGGER_NUMBER}}-fix`);2) fs 改代码;3) `git add` + `git commit -m \"<简短单行,纯文本,勿含反引号>\"`;4) `git push origin <feature-branch>`;5) 用 heredoc 建 PR:`gh pr create --repo {{REPO}} --base main --title \"<简短标题>\" --body-file - <<'RCM_BODY'` … `RCM_BODY`;6) 回到原 {{TRIGGER_KIND}} 按范式评论引用新 PR。**禁止 push 到 main**、**禁止 force push**、**禁止 reset --hard**——git 工具会拒绝这些命令。\n凡发了评论(comment_only / 以及其它分支里的评论),发完必须自检:命令要 exit 0 并打印 URL,再跑 `gh {{TRIGGER_KIND}} view {{TRIGGER_NUMBER}} --repo {{REPO}} --json comments -q '.comments[-1].url'` 确认最新评论即本次所发;失败原样重试一次,仍失败不要谎报成功。\n所有评论中文撰写,代码标识符保持英文。handoff 末尾:做了动作就写 status: ok 与对应 URL;该做却没做成写 status: blocked 与一行原因;no_action 写 status: ok 与 note: no_action。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell", "fs", "find", "git"] + } + + flux fetch_to_scan_issues { + channel = context + mode = digest + arity = 1 + } + + flux fetch_to_scan_prs { + channel = context + mode = digest + arity = 1 + } + + flux fetch_to_scan_code { + channel = context + mode = digest + arity = 1 + } + + flux scans_to_classify { + channel = context + mode = digest + arity = 4 + } + + flux classify_to_executor { + channel = context + mode = digest + arity = 1 + } + + fetch_thread.context -> fetch_to_scan_issues.slot(0) + fetch_to_scan_issues.out -> scan_issues.context + fetch_thread.done -> scan_issues.trigger + + fetch_thread.context -> fetch_to_scan_prs.slot(0) + fetch_to_scan_prs.out -> scan_prs.context + fetch_thread.done -> scan_prs.trigger + + fetch_thread.context -> fetch_to_scan_code.slot(0) + fetch_to_scan_code.out -> scan_code.context + fetch_thread.done -> scan_code.trigger + + fetch_thread.context -> scans_to_classify.slot(0) + scan_issues.context -> scans_to_classify.slot(1) + scan_prs.context -> scans_to_classify.slot(2) + scan_code.context -> scans_to_classify.slot(3) + scan_code.done -> classify_intent.trigger + scans_to_classify.out -> classify_intent.context + + classify_intent.context -> classify_to_executor.slot(0) + classify_to_executor.out -> executor.context + classify_intent.done -> executor.trigger + + executor.done -> output.done + executor.context -> output.context + executor.purpose -> output.purpose +} diff --git a/examples/project-maintainer/templates/pr_review.rcm.tpl b/examples/project-maintainer/templates/pr_review.rcm.tpl new file mode 100644 index 00000000..1b49c0da --- /dev/null +++ b/examples/project-maintainer/templates/pr_review.rcm.tpl @@ -0,0 +1,77 @@ +name = "PR Review ({{REPO}}#{{PR_NUMBER}})" + +model kimi-k2-6 { + protocol = "openai" + endpoint = "https://api.kimi.com/coding/v1" + credentials = { env = "KIMI_CODING_API_KEY" } + headers = { User-Agent = "KimiCLI/1.5" } + limit = { context = "262144", output = "32768" } + modalities = { input = ["text"], output = ["text"] } + thinking = "true" +} + +graph { + accelerator meta { + purpose = "拉取 PR #{{PR_NUMBER}} 来自 {{REPO}} 的元数据:执行 `gh pr view {{PR_NUMBER}} --repo {{REPO}} --json number,title,body,author,additions,deletions,changedFiles,files,labels,baseRefName,headRefName`。输出紧凑的 PR 描述(标题/作者/目标),以及逐文件清单(path | +增 | -删)与变更总行数——这是后续 diff 预算的依据。只读,不改任何文件,不发评论。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + accelerator diff_review { + purpose = "审查 PR #{{PR_NUMBER}} 的代码改动。diff 是用来定位的,不要把全量 diff 倒进上下文:\n- 若 meta 给出的变更总行数 ≤ 1500,执行 `gh pr diff {{PR_NUMBER}} --repo {{REPO}}` 看全量;\n- 若超过 1500,只挑风险最高的文件深入:用 `gh api repos/{{REPO}}/pulls/{{PR_NUMBER}}/files --paginate --jq '.[] | {filename, additions, deletions, status}'` 定位重点文件,对它们取 patch,其余写明\"已省略(N 个文件 / M 行)\"。\n工作目录已 checkout 到本 PR 的代码,**用 fs 按需读取任意文件来核对 diff 是否合理**:读 hunk 周围的完整函数、追到调用点、确认是否有对应测试。\n评估并给出带依据(文件:行 / 函数)的结论:1) 正确性可疑点(逻辑 / 边界 / 错误处理);2) 测试覆盖(逻辑变更但无 tests 为 risk);3) 安全嗅味(新依赖、用户输入、unwrap/panic、unsafe、shell/SQL 拼接);4) diff 规模(small <100 / medium <500 / large ≥500)。只读,不改文件,不发评论。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell", "fs"] + } + + accelerator intent_check { + purpose = "判断 PR #{{PR_NUMBER}} 的**目标是否成立、是否真的达成**。依据 meta 的标题/正文(声称要做什么)+ diff_review 的改动结论(实际做了什么):\n1) 目标是否清晰合理(说清了 why,还是只有 what);\n2) 改动是否确实实现了该目标(有没有声称却没做的部分);\n3) 是否有范围漂移(夹带与目标无关的改动);\n4) 有无明显遗漏的配套改动(改了接口却没改调用方 / 文档 / 测试)。可用 fs / gh 进一步核对。\n输出 verdict(目标达成 / 部分达成 / 未达成 / 目标不清)+ 一句话理由 + 关键证据(文件:行)。只读,不改文件,不发评论。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell", "fs"] + } + + accelerator respond { + purpose = "综合 diff_review(代码改动结论)与 intent_check(目标是否达成)写一条 review 评论:开头一句总体判断(可合并 / 需调整,并点明目标是否达成);中间用 markdown checklist 列出关键发现(正面 ✓ 负面 ⚠),分「代码质量」与「目标 / 范围」两块;末尾给 1-3 条具体修改建议。中文撰写,代码标识符保持英文。\n发评论只能用带引号 heredoc 经 --body-file 传入,严禁内联 -b——正文里的反引号、$、引号会被 shell 解释甚至注入:\ngh pr comment {{PR_NUMBER}} --repo {{REPO}} --body-file - <<'RCM_BODY'\n<在此写完整评论正文>\nRCM_BODY\n闭合标记 RCM_BODY 必须独占一行、行首顶格,且正文里不得出现这一行。\n发完必须自检确实发出去了:1) 上面命令要 exit 0 并打印评论 URL;2) 再跑 `gh pr view {{PR_NUMBER}} --repo {{REPO}} --json comments -q '.comments[-1].url'` 确认最新评论就是本次所发。若失败(如 fork PR 的只读 token 报 403、网络错误)原样重试一次;仍失败不要谎报成功。只用 shell+gh。\nhandoff 末尾:成功则写 status: ok 与 comment_url: <URL>;最终没发出去则写 status: blocked 与一行原因。" + models = ["kimi-k2-6"] + policy = "captain" + tools = ["shell"] + } + + flux meta_to_diff { + channel = context + mode = digest + arity = 1 + } + + flux meta_diff_to_intent { + channel = context + mode = digest + arity = 2 + } + + flux diff_intent_to_respond { + channel = context + mode = digest + arity = 2 + } + + meta.context -> meta_to_diff.slot(0) + meta_to_diff.out -> diff_review.context + meta.done -> diff_review.trigger + + meta.context -> meta_diff_to_intent.slot(0) + diff_review.context -> meta_diff_to_intent.slot(1) + diff_review.done -> intent_check.trigger + meta_diff_to_intent.out -> intent_check.context + + diff_review.context -> diff_intent_to_respond.slot(0) + intent_check.context -> diff_intent_to_respond.slot(1) + intent_check.done -> respond.trigger + diff_intent_to_respond.out -> respond.context + + respond.done -> output.done + respond.context -> output.context + respond.purpose -> output.purpose +} diff --git a/examples/repo-snapshot/README.md b/examples/repo-snapshot/README.md new file mode 100644 index 00000000..285aa65b --- /dev/null +++ b/examples/repo-snapshot/README.md @@ -0,0 +1,40 @@ +# Repo Snapshot + +A single-agent RCM for answering: "What state is this repository in, and what should I do next?" It autonomously inspects git state, recent commits, project files, and local conventions, then writes an evidence-backed project snapshot. + +## Run + +From the repository you want to inspect: + +```sh +accelerate run /path/to/RCM/examples/repo-snapshot/repo_snapshot.rcm \ + --purpose "看一下这个项目现在是什么状态,我下一步该做什么" +``` + +When running from this repository without installing: + +```sh +cargo run --manifest-path /path/to/RCM/Cargo.toml --bin accelerate -- \ + run /path/to/RCM/examples/repo-snapshot/repo_snapshot.rcm \ + --purpose "看一下这个项目现在是什么状态,我下一步该做什么" +``` + +## What It Collects + +- `git status` +- Recent commits and current branch +- Local diffs when present +- README, instruction files, tests, docs, TODO-like markers + +## Output + +- `outputs/repo-snapshot.md` + +## Tools + +- Built-in: `fs`, `find`, `git`, `shell` +- MCP: none + +## Safety + +This example is read-only by design. It should not modify files, create commits, switch branches, or run long commands. diff --git a/examples/repo-snapshot/outputs/.gitignore b/examples/repo-snapshot/outputs/.gitignore new file mode 100644 index 00000000..bf27f311 --- /dev/null +++ b/examples/repo-snapshot/outputs/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!.gitkeep diff --git a/examples/repo-snapshot/outputs/.gitkeep b/examples/repo-snapshot/outputs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/repo-snapshot/prompts/repo_snapshot.txt b/examples/repo-snapshot/prompts/repo_snapshot.txt new file mode 100644 index 00000000..c3ca84a0 --- /dev/null +++ b/examples/repo-snapshot/prompts/repo_snapshot.txt @@ -0,0 +1,20 @@ +You are Repo Snapshot, a one-shot repository orientation agent. + +The user gives a short goal through runtime purpose, usually asking what state the project is in or what to do next. Do not ask follow-up questions. Inspect the repository and build a concise, evidence-backed snapshot. + +Use git, file search, and read-only shell commands as needed. Prefer concrete evidence from `git status`, recent commits, diffs, tests/docs layout, TODO-like markers, and project instruction files. Do not modify files. Do not commit. Do not switch branches. Do not run long commands. + +Write `outputs/repo-snapshot.md` with: + +- current branch and cleanliness +- uncommitted change summary grouped by intent +- recent commit narrative +- likely current workstream +- important project conventions or instruction files discovered +- tests or checks that look relevant +- risks/blockers +- recommended next 3 actions + +If there are no git changes, explain the repo's current shape and likely next useful entry points. + +Final response: give the short headline and point to `outputs/repo-snapshot.md`. \ No newline at end of file diff --git a/examples/repo-snapshot/repo_snapshot.rcm b/examples/repo-snapshot/repo_snapshot.rcm new file mode 100644 index 00000000..2f66166f --- /dev/null +++ b/examples/repo-snapshot/repo_snapshot.rcm @@ -0,0 +1,17 @@ +name = "Repo Snapshot" + +model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + modalities = { input = ["text"], output = ["text"] } +} + +accelerator { + purpose = "one-shot repository status snapshot" + models = ["deepseek-v4-flash"] + policy = "captain" + prompts = { captain = file "./prompts/repo_snapshot.txt" } + tools = ["fs", "find", "git", "shell"] +} diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..4038893b --- /dev/null +++ b/install.ps1 @@ -0,0 +1,49 @@ +# RCM Windows Installer +# Usage: irm https://raw.githubusercontent.com/EricSanchezok/recursive-context-machine/main/install.ps1 | iex + +param( + [string]$Version = "" +) + +$ErrorActionPreference = "Stop" + +$Repo = "EricSanchezok/recursive-context-machine" +$BaseUrl = "https://github.com/$Repo/releases" +$BinName = "accelerate" + +if (-not $Version) { + $ReleaseUrl = "https://api.github.com/repos/$Repo/releases/latest" + $Release = Invoke-RestMethod -Uri $ReleaseUrl -ErrorAction Stop + $Version = $Release.tag_name +} + +Write-Host "Installing $BinName $Version for Windows x86_64 ..." + +$ArchiveName = "${BinName}-x86_64-windows.zip" +$DownloadUrl = "${BaseUrl}/download/${Version}/${ArchiveName}" +$TempDir = Join-Path $env:TEMP "rcm-install-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8))" + +New-Item -ItemType Directory -Path $TempDir -Force | Out-Null +$ArchivePath = Join-Path $TempDir $ArchiveName + +Write-Host "Downloading from $DownloadUrl ..." +Invoke-WebRequest -Uri $DownloadUrl -OutFile $ArchivePath + +Expand-Archive -Path $ArchivePath -DestinationPath $TempDir -Force + +$InstallDir = if ($env:RCM_INSTALL_DIR) { $env:RCM_INSTALL_DIR } else { "$env:USERPROFILE\.rcm\bin" } +New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +Copy-Item -Path "$TempDir\${BinName}.exe" -Destination "$InstallDir\${BinName}.exe" -Force + +$env:Path = "$InstallDir;" + $env:Path +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($UserPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") +} + +Remove-Item -Recurse -Force $TempDir + +Write-Host "" +Write-Host "Installed ${BinName}.exe -> $InstallDir" +Write-Host "" +Write-Host "Run 'accelerate --help' to get started." diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..dc56fece --- /dev/null +++ b/install.sh @@ -0,0 +1,104 @@ +#!/bin/sh +set -eu + +REPO="EricSanchezok/recursive-context-machine" +BASE_URL="https://github.com/${REPO}/releases" +BIN_NAME="accelerate" + +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } + +detect_platform() { + OS="$(uname -s)" + ARCH="$(uname -m)" + + case "$OS" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + *) + red "Unsupported OS: $OS" + exit 1 + ;; + esac + + case "$ARCH" in + x86_64|amd64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + *) + red "Unsupported architecture: $ARCH" + exit 1 + ;; + esac + + PLATFORM="${arch}-${os}" +} + +resolve_version() { + VERSION="${RCM_VERSION:-}" + if [ -z "$VERSION" ]; then + VERSION="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')" + if [ -z "$VERSION" ]; then + red "Unable to find latest release. Set RCM_VERSION=vX.Y.Z to pin a version." + exit 1 + fi + fi +} + +install_binary() { + if [ "$os" = "windows" ]; then + ARCHIVE="${BIN_NAME}-${PLATFORM}.zip" + else + ARCHIVE="${BIN_NAME}-${PLATFORM}.tar.gz" + fi + DOWNLOAD_URL="${BASE_URL}/download/${VERSION}/${ARCHIVE}" + + bold "Downloading ${ARCHIVE} ..." + TMPDIR="$(mktemp -d)" + trap 'rm -rf "$TMPDIR"' EXIT + + curl -fsSL "$DOWNLOAD_URL" -o "${TMPDIR}/${ARCHIVE}" + + if [ "$os" = "windows" ]; then + unzip -q "${TMPDIR}/${ARCHIVE}" -d "$TMPDIR" + else + tar xzf "${TMPDIR}/${ARCHIVE}" -C "$TMPDIR" + fi + + if [ "$os" = "windows" ]; then + INSTALL_DIR="${RCM_INSTALL_DIR:-$HOME/.rcm/bin}" + elif [ -w /usr/local/bin ]; then + INSTALL_DIR="${RCM_INSTALL_DIR:-/usr/local/bin}" + else + INSTALL_DIR="${RCM_INSTALL_DIR:-$HOME/.local/bin}" + fi + + mkdir -p "$INSTALL_DIR" + install -m 755 "${TMPDIR}/${BIN_NAME}" "${INSTALL_DIR}/${BIN_NAME}" + + green "Installed ${BIN_NAME} → ${INSTALL_DIR}/${BIN_NAME}" +} + +main() { + detect_platform + resolve_version + install_binary + + bold "" + bold "Done! Run '${BIN_NAME} --help' to get started." + bold "" + + case ":$PATH:" in + *:"$INSTALL_DIR":*) ;; + *) + bold "Add ${INSTALL_DIR} to your PATH:" + bold " export PATH=\"${INSTALL_DIR}:\$PATH\"" + bold "" + ;; + esac +} + +main diff --git a/proto/rcm.proto b/proto/rcm.proto new file mode 100644 index 00000000..e2b0bda8 --- /dev/null +++ b/proto/rcm.proto @@ -0,0 +1,233 @@ +syntax = "proto3"; +package rcm; + +import "google/protobuf/empty.proto"; + +service RCM { + rpc Open(OpenRequest) returns (OpenResponse); + rpc Step(StepRequest) returns (StepResponse); + rpc Destroy(DestroyRequest) returns (google.protobuf.Empty); +} + +// ── Open ───────────────────────────────────────────────── + +message OpenRequest { + string purpose = 1; + repeated ModelSpec model_definitions = 2; + repeated McpServerSpec mcp_definitions = 3; + repeated string tools = 4; + map<string, string> prompts = 5; + repeated string models = 6; + repeated string mcps = 7; + string environment = 8; + // Optional directory for trajectory WAL recording. When omitted, the + // server records under its trajectory root (RCM_SERVER_TRAJECTORY_DIR, + // default ./rcm-trajectories/) keyed by machine id. + optional string run_dir = 9; +} + +message ModelSpec { + string name = 1; + string protocol = 2; + optional string endpoint = 3; + optional CredentialSpec credentials = 4; + optional LimitSpec limit = 5; + repeated string modalities_input = 6; + repeated string modalities_output = 7; + map<string, string> headers = 8; + optional uint64 timeout = 9; + optional CostSpec cost = 10; + bool thinking = 11; + optional double temperature = 12; +} + +message CostSpec { + double input = 1; + double output = 2; + optional double cache_read = 3; + optional double cache_write = 4; +} + +message CredentialSpec { + oneof source { + string env = 1; + string literal = 2; + } +} + +message LimitSpec { + uint64 context = 1; + optional uint64 input = 2; + uint64 output = 3; +} + +message McpServerSpec { + string label = 1; + McpTransportSpec transport = 2; +} + +message McpTransportSpec { + oneof kind { + McpStdioSpec stdio = 1; + McpHttpSpec http = 2; + McpSseSpec sse = 3; + } +} + +message McpStdioSpec { + string command = 1; + repeated string args = 2; + map<string, McpValueSpec> env = 3; + optional string cwd = 4; +} + +message McpHttpSpec { + string url = 1; + map<string, McpValueSpec> headers = 2; +} + +message McpSseSpec { + string url = 1; + map<string, McpValueSpec> headers = 2; +} + +message McpValueSpec { + oneof source { + string literal = 1; + string env = 2; + } +} + +message OpenResponse { + string machine_id = 1; + State state = 2; + ActionSpace action_space = 3; +} + +// ── Step ──────────────────────────────────────────────── + +message StepRequest { + string machine_id = 1; + ActionCommand command = 2; +} + +message StepResponse { + State state = 1; + ActionSpace action_space = 2; +} + +// ── Destroy ───────────────────────────────────────────── + +message DestroyRequest { + string machine_id = 1; +} + +// ── ActionCommand ─────────────────────────────────────── + +message ActionCommand { + string verb = 1; + + optional uint64 fragment_id = 2; + optional uint64 fragment_id2 = 3; + optional FragmentContent fragment = 4; + optional string name = 5; +} + +message MediaSource { + oneof source { + string url = 1; + string base64 = 2; + } + optional string media_type = 3; + optional string alt_text = 4; +} + +message FragmentContent { + string role = 1; + string text = 2; + optional string tag = 3; + string kind = 4; + optional MediaSource media_source = 5; +} + +// ── ActionSpace ───────────────────────────────────────── + +message ActionSpace { + repeated ActionItem actions = 1; +} + +message ActionItem { + ActionCommand command = 1; + string label = 2; + FragmentContent sink = 3; +} + +// ── State ──────────────────────────────────────────────── + +message State { + string purpose = 1; + string machine_id = 10; + repeated Fragment fragments = 2; + string workdir = 3; + map<string, string> env_vars = 4; + string active_model = 5; + repeated string active_tools = 6; + repeated string available_models = 7; + repeated string available_tools = 8; + bool done = 9; + bool inbox_pending = 11; + optional Fragment inbox_peek = 12; + map<string, uint64> counts = 13; + repeated Usage usages = 14; + repeated ToolProfile tool_profiles = 15; + repeated ModelProfile model_profiles = 16; + string platform = 17; + optional string root = 18; + Obs obs = 19; +} + +// ── Obs (controller observation) ───────────────────────── + +message Obs { + Budget budget = 1; +} + +message Budget { + uint64 context_limit = 1; + uint64 estimated_input = 2; + uint64 soft_threshold = 3; + uint64 headroom = 4; + optional uint64 last_actual_input = 5; +} + +message Usage { + uint64 input_tokens = 1; + uint64 output_tokens = 2; + uint64 total_tokens = 3; + uint64 cached_input_tokens = 4; + uint64 cache_creation_input_tokens = 5; + repeated uint64 fragment_ids = 6; +} + +message Fragment { + uint64 id = 1; + string role = 2; + string kind = 3; + string text_preview = 4; + optional string tag = 5; + optional string content_text = 6; + optional MediaSource media_source = 7; +} + +message ToolProfile { + string name = 1; + string description = 2; + bool active = 3; +} + +message ModelProfile { + string name = 1; + string protocol = 2; + optional LimitSpec limit = 3; + bool active = 4; +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..e36ade1b --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,9 @@ +max_width = 100 +hard_tabs = false +tab_spaces = 4 +newline_style = "Unix" +use_small_heuristics = "Default" + +# Reorder imports alphabetically by crate group: std → external → self +reorder_imports = true +reorder_modules = true diff --git a/scripts/audit-governance.mjs b/scripts/audit-governance.mjs new file mode 100644 index 00000000..9164f183 --- /dev/null +++ b/scripts/audit-governance.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +// audit-governance.mjs — read-only repository governance assessment. +// Never reads .env files, never accesses the network, and never writes state. +import { access, readdir, readFile, stat } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { assessCapabilities, CAPABILITIES } from './capabilities.mjs'; + +const execFileAsync = promisify(execFile); +const PACKAGE_MANIFESTS = new Set(['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'Gemfile', 'requirements.txt']); + +async function exists(abs) { + try { await access(abs); return true; } catch { return false; } +} + +async function countPackageManifests(repoRoot) { + let count = 0; + async function walk(dir, depth) { + if (depth > 2) return; + let entries = []; + try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === 'vendor' || entry.name === '.env') continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(full, depth + 1); + else if (PACKAGE_MANIFESTS.has(entry.name)) count++; + } + } + await walk(repoRoot, 0); + return count; +} + +async function detectTestCommand(repoRoot) { + try { + const pkg = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8')); + if (pkg.scripts?.test) return 'npm test'; + } catch {} + if (await exists(path.join(repoRoot, 'pyproject.toml'))) return 'pytest'; + if (await exists(path.join(repoRoot, 'go.mod'))) return 'go test ./...'; + if (await exists(path.join(repoRoot, 'Cargo.toml'))) return 'cargo test'; + if (await exists(path.join(repoRoot, 'Gemfile'))) return 'bundle exec rake test'; + try { + const agents = await readFile(path.join(repoRoot, 'AGENTS.md'), 'utf8'); + const declared = agents.match(/^- Test:\s*`([^`]+)`\s*$/m)?.[1]?.trim(); + if (declared) return declared; + } catch {} + return null; +} + +async function gitFacts(repoRoot) { + const isGit = await exists(path.join(repoRoot, '.git')); + if (!isGit) return { isGit: false, hasGitHubRemote: false, hasTags: false, hasPreCommitHook: false }; + let remote = ''; + let hasTags = false; + let hasPreCommitHook = false; + try { remote = (await execFileAsync('git', ['config', '--get', 'remote.origin.url'], { cwd: repoRoot })).stdout.trim(); } catch {} + try { hasTags = (await execFileAsync('git', ['tag', '-l'], { cwd: repoRoot })).stdout.trim() !== ''; } catch {} + try { + const hook = (await execFileAsync('git', ['rev-parse', '--git-path', 'hooks/pre-commit'], { cwd: repoRoot })).stdout.trim(); + hasPreCommitHook = await exists(path.resolve(repoRoot, hook)); + } catch { + hasPreCommitHook = await exists(path.join(repoRoot, '.git', 'hooks', 'pre-commit')); + } + return { isGit, hasGitHubRemote: /github\.com[:/]/i.test(remote), hasTags, hasPreCommitHook }; +} + +async function readManifest(repoRoot) { + try { return JSON.parse(await readFile(path.join(repoRoot, '.repo-seed', 'manifest.json'), 'utf8')); } catch { return null; } +} + +export async function collectGovernanceFacts(repoRoot) { + const git = await gitFacts(repoRoot); + const packageCount = await countPackageManifests(repoRoot); + const testCommand = await detectTestCommand(repoRoot); + const hasHookManager = await exists(path.join(repoRoot, '.husky')) || await exists(path.join(repoRoot, '.pre-commit-config.yaml')); + const hasCi = await exists(path.join(repoRoot, '.github', 'workflows')) || + await exists(path.join(repoRoot, '.gitlab-ci.yml')) || await exists(path.join(repoRoot, '.circleci')); + const hasReleasePolicy = await exists(path.join(repoRoot, 'docs', 'release-policy.md')) || + await exists(path.join(repoRoot, '.releaserc')) || await exists(path.join(repoRoot, 'release-please-config.json')); + const hasReleaseMarkers = git.hasTags || hasReleasePolicy || await exists(path.join(repoRoot, 'CHANGELOG.md')); + return { + ...git, + managed: await exists(path.join(repoRoot, '.repo-seed', 'manifest.json')), + testCommand, + hasTestCommand: Boolean(testCommand), + packageCount, + multiplePackages: packageCount > 1, + hasCi, + hasReleasePolicy, + hasReleaseMarkers, + hasDeploymentMarkers: await exists(path.join(repoRoot, 'Dockerfile')) || + await exists(path.join(repoRoot, 'docker-compose.yml')) || + await exists(path.join(repoRoot, 'compose.yml')) || + await exists(path.join(repoRoot, 'k8s')) || + await exists(path.join(repoRoot, 'terraform')) || + await exists(path.join(repoRoot, 'vercel.json')), + hasAgents: await exists(path.join(repoRoot, 'AGENTS.md')), + hasArchitecture: await exists(path.join(repoRoot, 'docs', 'architecture.md')) || await exists(path.join(repoRoot, 'ARCHITECTURE.md')), + hasDecisionSystem: await exists(path.join(repoRoot, 'docs', 'decisions')) || await exists(path.join(repoRoot, 'docs', 'adr')), + hasSpecSystem: await exists(path.join(repoRoot, 'docs', 'specs')) || await exists(path.join(repoRoot, 'docs', 'rfcs')), + hasPostmortemSystem: await exists(path.join(repoRoot, 'docs', 'postmortems')) || await exists(path.join(repoRoot, 'docs', 'incidents')), + hasSecurityPolicy: await exists(path.join(repoRoot, 'SECURITY.md')), + hasCodeowners: await exists(path.join(repoRoot, 'CODEOWNERS')) || await exists(path.join(repoRoot, '.github', 'CODEOWNERS')), + hasAiDisclosure: await exists(path.join(repoRoot, 'docs', 'ai-disclosure.md')), + hasSubtreeInstructions: packageCount > 1 && await exists(path.join(repoRoot, 'packages', 'AGENTS.md')), + hasHookManager, + isPublic: null, + requiresAiDisclosure: false, + riskyChange: false, + productionIncident: false, + }; +} + +export async function auditGovernance(repoRoot) { + const facts = await collectGovernanceFacts(repoRoot); + const manifest = await readManifest(repoRoot); + const items = manifest?.capabilities?.items ?? {}; + const capabilities = assessCapabilities(facts, items).map((assessment) => ({ + ...assessment, + ...(items[assessment.id]?.reason ? { recordedReason: items[assessment.id].reason } : {}), + ...(items[assessment.id]?.source ? { source: items[assessment.id].source } : {}), + title: CAPABILITIES[assessment.id].title, + benefit: CAPABILITIES[assessment.id].benefit, + cost: CAPABILITIES[assessment.id].cost, + reviewContribution: CAPABILITIES[assessment.id].reviewContribution, + })); + return { + managed: Boolean(manifest), + repoSeedVersion: manifest?.repoSeedVersion ?? null, + facts, + capabilities, + }; +} + +async function main() { + const args = process.argv.slice(2); + const json = args.includes('--json'); + const target = args.find((arg) => !arg.startsWith('--')) ?? '.'; + const result = await auditGovernance(path.resolve(target)); + if (json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`governance-audit: ${result.managed ? `managed by repo-seed ${result.repoSeedVersion ?? ''}`.trim() : 'unmanaged repository'}`); + const recommendations = result.capabilities.filter((item) => item.recommendation === 'recommended'); + if (!recommendations.length) console.log('governance-audit: no new capability recommendations'); + for (const item of recommendations) { + console.log(`governance-audit: ${item.urgency} recommendation ${item.id} — ${item.benefit} Cost: ${item.cost}`); + } +} + +const isMain = process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) main().catch((error) => { console.error(`governance-audit: fatal ${error.message}`); process.exit(2); }); diff --git a/scripts/capabilities.mjs b/scripts/capabilities.mjs new file mode 100644 index 00000000..37940de6 --- /dev/null +++ b/scripts/capabilities.mjs @@ -0,0 +1,346 @@ +#!/usr/bin/env node +// capabilities.mjs — capability catalog and progressive-governance helpers. +// Shared by the scaffold and the read-only governance audit. Zero dependencies. +import { createHash } from 'node:crypto'; + +export const CAPABILITY_STATES = new Set(['enabled', 'external', 'deferred', 'declined']); +export const CAPABILITY_MODEL_VERSION = 1; + +const CORE = { + baseline: { + id: 'baseline', + tier: 'core', + title: 'Agent-native repository baseline', + benefit: 'Keeps management identity, resident skill routing, repository orientation, human-readable implementation guidance, risk-driven testing policy, review policy, deterministic verification, and safe upgrades available in every managed repository.', + cost: 'Maintains a small governed surface and ownership manifest.', + urgency: 'blocking', + files: [ + ['AGENTS.md', 'instruction'], + ['CLAUDE.md', 'instruction'], + ['docs/AGENTS.md', 'docs'], + ['docs/architecture.md', 'docs'], + ['docs/development.md', 'docs'], + ['docs/testing.md', 'docs'], + ['.agents/skills/repo-review/SKILL.md', 'skill'], + ['.agents/skills/repo-governance/SKILL.md', 'skill'], + ['scripts/capabilities.mjs', 'gate'], + ['scripts/governance-config.mjs', 'gate'], + ['scripts/audit-governance.mjs', 'gate'], + ['scripts/run-gates.mjs', 'gate'], + ['scripts/verify-doc-links.mjs', 'gate'], + ['scripts/verify-placeholders.mjs', 'gate'], + ['scripts/verify-manifest.mjs', 'gate'], + ['scripts/install-hooks.mjs', 'gate'], + ['CONTRIBUTING.md', 'meta'], + ['LICENSE', 'meta'], + ['.editorconfig', 'meta'], + ['.gitattributes', 'meta'], + ['.repo-seed/update-strategy.md', 'meta'], + ['.github/pull_request_template.md', 'github'], + ['.github/ISSUE_TEMPLATE/bug.md', 'github'], + ['.github/ISSUE_TEMPLATE/feature.md', 'github'], + ], + recommendWhen: [], + reviewContribution: 'Management identity, resident skill routing, human-readable implementation, risk-adjusted test evidence, project review policy, documentation orientation, manifest ownership, and the upgrade channel stay coherent.', + verifyCommands: [ + 'node scripts/verify-doc-links.mjs', + 'node scripts/verify-placeholders.mjs', + 'node scripts/verify-manifest.mjs', + ], + }, + spec: { + id: 'spec', + tier: 'core', + title: 'Risk-triggered change contracts', + benefit: 'Keeps intent, observable behavior, constraints, and proof stable across agents and sessions.', + cost: 'One short contract for changes that cross a risk boundary; routine work is exempt.', + urgency: 'blocking', + files: [ + ['docs/specs/README.md', 'docs'], + ['scripts/verify-specs.mjs', 'gate'], + ], + recommendWhen: ['riskyChange'], + reviewContribution: 'Risk-boundary changes require an Approved spec before implementation and permanent Evidence before completion.', + verifyCommands: ['node scripts/verify-specs.mjs'], + legacyExtension: true, + }, + decisions: { + id: 'decisions', + tier: 'core', + title: 'Durable decision memory', + benefit: 'Preserves choices with genuine alternatives without turning every change into an ADR.', + cost: 'A MADR record only when rationale is likely to be revisited.', + urgency: 'blocking', + files: [ + ['docs/decisions/README.md', 'docs'], + ['docs/decisions/0000-use-markdown-architectural-decision-records.md', 'docs'], + ['docs/decisions/0001-repo-seed-is-a-skill-not-a-template.md', 'docs'], + ['docs/decisions/0002-self-governing-repository-design.md', 'docs'], + ['docs/decisions/0003-repo-review-instantiated-per-project.md', 'docs'], + ['.agents/skills/repo-decisions/SKILL.md', 'skill'], + ['scripts/verify-decisions.mjs', 'gate'], + ], + recommendWhen: [], + reviewContribution: 'Record durable choices with real alternatives; do not use ADRs as a change log.', + verifyCommands: ['node scripts/verify-decisions.mjs'], + }, + postmortems: { + id: 'postmortems', + tier: 'core', + title: 'Incident feedback loop', + benefit: 'Turns systemic failures into linked tests, gates, or standing rules.', + cost: 'A record only for subtle, systemic, or costly-to-rediscover failures.', + urgency: 'blocking', + files: [ + ['docs/postmortems/README.md', 'docs'], + ['scripts/verify-postmortems.mjs', 'gate'], + ], + recommendWhen: ['productionIncident'], + reviewContribution: 'A qualifying incident links at least one permanent repository guardrail.', + verifyCommands: ['node scripts/verify-postmortems.mjs'], + }, +}; + +const OPTIONAL = { + ci: { + id: 'ci', + tier: 'optional', + title: 'CI workflow', + benefit: 'Runs governance gates and tests for every shared change.', + cost: 'Maintains a hosted workflow and its permissions.', + urgency: 'advisory', + prerequisites: ['a real test entry point', 'a supported hosted CI target'], + files: [['.github/workflows/ci.yml', 'github']], + recommendWhen: ['hasTestCommand', 'hasGitHubRemote', '!hasCi'], + externalWhen: ['hasCi'], + agentsLine: '- CI runs the gates on every push/PR: [.github/workflows/ci.yml](.github/workflows/ci.yml).', + reviewContribution: 'Workflow changes retain least privilege and keep the governance runner plus the real test entry point.', + verifyCommands: ['node scripts/run-gates.mjs'], + legacyExtension: true, + }, + release: { + id: 'release', + tier: 'optional', + title: 'Release policy', + benefit: 'Makes versioning, change records, and commit messages predictable.', + cost: 'Adds a release policy and optional commit-msg enforcement.', + urgency: 'blocking', + prerequisites: ['a versioned release process'], + files: [ + ['docs/release-policy.md', 'docs'], + ['scripts/verify-commit-msg.mjs', 'gate'], + ], + recommendWhen: ['hasReleaseMarkers', '!hasReleasePolicy'], + externalWhen: ['hasReleasePolicy'], + agentsLine: '- Commit messages follow conventional commits; see [docs/release-policy.md](docs/release-policy.md).', + reviewContribution: 'Release-facing changes follow the repository release policy and preserve rollback information.', + verifyCommands: ['node scripts/verify-commit-msg.mjs'], + legacyExtension: true, + }, + community: { + id: 'community', + tier: 'optional', + title: 'Community health files', + benefit: 'Defines vulnerability reporting and contributor conduct for public projects.', + cost: 'Requires maintainers to own and keep public policies current.', + urgency: 'advisory', + prerequisites: ['a public maintainer contact'], + files: [ + ['SECURITY.md', 'meta'], + ['CODE_OF_CONDUCT.md', 'meta'], + ], + recommendWhen: ['isPublic', '!hasSecurityPolicy'], + externalWhen: ['hasSecurityPolicy'], + agentsLine: '- Security vulnerabilities are reported per [SECURITY.md](SECURITY.md).', + reviewContribution: 'Public-facing changes keep security reporting and community policy accurate.', + verifyCommands: [], + legacyExtension: true, + }, + codeowners: { + id: 'codeowners', + tier: 'optional', + title: 'CODEOWNERS', + benefit: 'Routes protected surfaces to named owners.', + cost: 'Requires real teams or handles and matching branch protection.', + urgency: 'blocking', + prerequisites: ['real user or team owner handles'], + files: [['CODEOWNERS', 'meta']], + recommendWhen: ['multiplePackages', '!hasCodeowners'], + externalWhen: ['hasCodeowners'], + agentsLine: '- Path-level owners live in [CODEOWNERS](CODEOWNERS); branch protection should require owner review.', + reviewContribution: 'Ownership changes name real owners and stay aligned with protected paths.', + verifyCommands: [], + legacyExtension: true, + }, + 'ai-disclosure': { + id: 'ai-disclosure', + tier: 'optional', + title: 'AI disclosure policy', + benefit: 'Makes AI participation auditable for repositories that require it.', + cost: 'Adds contribution metadata maintainers must enforce.', + urgency: 'advisory', + prerequisites: ['a maintainer-approved disclosure policy'], + files: [['docs/ai-disclosure.md', 'docs']], + recommendWhen: ['requiresAiDisclosure', '!hasAiDisclosure'], + externalWhen: ['hasAiDisclosure'], + agentsLine: '- AI-assisted commits/PRs disclose participation per [docs/ai-disclosure.md](docs/ai-disclosure.md).', + reviewContribution: 'AI-assisted changes carry the disclosure metadata required by policy.', + verifyCommands: [], + legacyExtension: true, + }, + monorepo: { + id: 'monorepo', + tier: 'optional', + title: 'Subtree agent instructions', + benefit: 'Gives packages with different commands or ownership focused local instructions.', + cost: 'Adds instruction surfaces that must stay consistent with the root.', + urgency: 'advisory', + prerequisites: ['multiple packages with distinct commands, policy, or ownership'], + files: [], + dynamicFiles: ['<package>/AGENTS.md'], + recommendWhen: ['multiplePackages', '!hasSubtreeInstructions'], + externalWhen: ['hasSubtreeInstructions'], + reviewContribution: 'Subtree instructions narrow local facts without copying root policy.', + verifyCommands: [], + }, + hook: { + id: 'hook', + tier: 'optional', + title: 'Local pre-commit enforcement', + benefit: 'Runs governance gates before a commit leaves the workstation.', + cost: 'Writes local Git state and may conflict with an existing hook manager.', + urgency: 'advisory', + prerequisites: ['a Git repository', 'explicit user authorization'], + files: [], + dynamicFiles: ['<git-common-dir>/hooks/pre-commit'], + recommendWhen: ['isGit', '!hasPreCommitHook', '!hasHookManager'], + externalWhen: ['hasPreCommitHook'], + reviewContribution: 'Hook changes preserve existing user hooks and call the shared governance runner.', + verifyCommands: ['node scripts/run-gates.mjs --staged'], + }, +}; + +function completeDefinition(definition) { + const signals = [...(definition.recommendWhen ?? [])]; + const equivalentSystemSignals = [...(definition.externalWhen ?? [])]; + return Object.freeze({ + prerequisites: [], + signals, + equivalentSystemSignals, + agentsContribution: definition.agentsLine ?? definition.reviewContribution ?? null, + gateContribution: [...(definition.verifyCommands ?? [])], + enableConditions: signals, + upgradeConditions: ['repoSeedVersionChanged', 'managedFilesUntouched'], + reevaluateConditions: [...new Set([...signals, ...equivalentSystemSignals].map((name) => name.replace(/^!/, '')))].sort(), + ...definition, + }); +} + +export const CAPABILITIES = Object.freeze(Object.fromEntries( + Object.entries({ ...CORE, ...OPTIONAL }).map(([id, definition]) => [id, completeDefinition(definition)]) +)); + +export function capabilityCatalog() { + return Object.values(CAPABILITIES); +} + +export function capabilityForFile(rel) { + for (const definition of capabilityCatalog()) { + if (definition.files.some(([file]) => file === rel)) return definition.id; + } + return null; +} + +export function extensionCapabilities() { + return capabilityCatalog().filter((c) => c.tier === 'optional' && c.legacyExtension); +} + +function normalizedRelevantFacts(definition, facts) { + const names = new Set( + [...(definition.recommendWhen ?? []), ...(definition.externalWhen ?? [])].map((n) => n.replace(/^!/, '')) + ); + return Object.fromEntries([...names].sort().map((name) => [name, facts[name] ?? null])); +} + +export function assessmentHash(definition, facts) { + const input = JSON.stringify({ + version: CAPABILITY_MODEL_VERSION, + id: definition.id, + urgency: definition.urgency, + recommendWhen: definition.recommendWhen ?? [], + externalWhen: definition.externalWhen ?? [], + facts: normalizedRelevantFacts(definition, facts), + }); + return createHash('sha256').update(input).digest('hex'); +} + +function conditionsMatch(conditions, facts) { + return (conditions ?? []).every((condition) => { + const negated = condition.startsWith('!'); + const key = negated ? condition.slice(1) : condition; + const value = Boolean(facts[key]); + return negated ? !value : value; + }); +} + +export function assessCapability(definition, facts, current = null) { + const hash = assessmentHash(definition, facts); + const timing = definition.urgency === 'blocking' ? 'before-implementation' : 'handoff'; + if (definition.tier === 'core') { + const state = current?.state === 'external' ? 'external' : 'enabled'; + return { + id: definition.id, + state, + recommendation: state === 'external' ? 'satisfied-external' : 'satisfied', + urgency: definition.urgency, + timing, + assessmentHash: hash, + reasons: [], + }; + } + if (current?.state === 'enabled' || current?.state === 'external') { + return { id: definition.id, state: current.state, recommendation: 'satisfied', urgency: definition.urgency, timing, assessmentHash: hash, reasons: [] }; + } + if (conditionsMatch(definition.externalWhen, facts)) { + return { id: definition.id, state: current?.state ?? 'external', recommendation: 'satisfied-external', urgency: definition.urgency, timing, assessmentHash: hash, reasons: definition.externalWhen }; + } + const relevant = conditionsMatch(definition.recommendWhen, facts) && (definition.recommendWhen?.length ?? 0) > 0; + const suppressed = relevant && ['declined', 'deferred'].includes(current?.state) && current.assessmentHash === hash; + return { + id: definition.id, + state: current?.state ?? null, + recommendation: suppressed ? 'suppressed' : relevant ? 'recommended' : 'not-applicable', + urgency: definition.urgency, + timing, + assessmentHash: hash, + reasons: relevant ? definition.recommendWhen : [], + }; +} + +export function assessCapabilities(facts, items = {}) { + return capabilityCatalog().map((definition) => assessCapability(definition, facts, items[definition.id])); +} + +export function defaultCapabilityState() { + return { + version: CAPABILITY_MODEL_VERSION, + items: Object.fromEntries( + Object.values(CORE).map((definition) => [definition.id, { state: 'enabled', ...(definition.id === 'spec' ? { mode: 'repo' } : {}) }]) + ), + }; +} + +export function normalizeCapabilities(manifest) { + const existing = manifest.capabilities?.items ?? {}; + const next = defaultCapabilityState(); + for (const [id, value] of Object.entries(existing)) { + if (CAPABILITIES[id] && CAPABILITY_STATES.has(value?.state)) next.items[id] = { ...value }; + } + for (const entry of manifest.files ?? []) { + if (entry.extension && CAPABILITIES[entry.extension]?.tier === 'optional') { + if (!next.items[entry.extension]) next.items[entry.extension] = { state: 'enabled' }; + } + } + manifest.capabilities = next; + return next; +} diff --git a/scripts/governance-config.mjs b/scripts/governance-config.mjs new file mode 100644 index 00000000..2ea52c5b --- /dev/null +++ b/scripts/governance-config.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +// governance-config.mjs — shared manifest, path, and artifact-policy helpers. +import { createHash } from 'node:crypto'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; + +export const DEFAULT_GOVERNANCE_PATHS = Object.freeze({ + architecture: 'docs/architecture.md', + testing: 'docs/testing.md', + decisions: 'docs/decisions', + specs: 'docs/specs', + postmortems: 'docs/postmortems', +}); + +export function safeRepositoryRelativePath(rel) { + if (typeof rel !== 'string' || !rel) return false; + const normalized = path.posix.normalize(rel.replaceAll('\\', '/')); + if (path.posix.isAbsolute(normalized) || normalized === '..' || normalized.startsWith('../')) return false; + return !normalized.split('/').some((segment) => /^\.env(?:\.|$)/.test(segment)); +} + +export function contentSha256(content) { + return createHash('sha256').update(content).digest('hex'); +} + +export async function readRepoManifest(repoRoot) { + try { return JSON.parse(await readFile(path.join(repoRoot, '.repo-seed', 'manifest.json'), 'utf8')); } + catch { return null; } +} + +export function governancePaths(manifest) { + return { ...DEFAULT_GOVERNANCE_PATHS, ...(manifest?.governance?.paths ?? {}) }; +} + +export function artifactVersion(text) { + const match = text.match(/^Artifact-Version:\s*(\d+)\s*$/m); + return match ? Number(match[1]) : null; +} + +export function grandfatheredArtifact(manifest, rel, content) { + const entry = manifest?.artifactPolicy?.legacy?.find((item) => item.path === rel); + return Boolean(entry && entry.sha256 === contentSha256(content)); +} + +async function artifactFiles(repoRoot, manifest) { + const paths = governancePaths(manifest); + const out = []; + for (const [kind, relDir] of [['spec', paths.specs], ['postmortem', paths.postmortems]]) { + if (!safeRepositoryRelativePath(relDir)) continue; + let entries = []; + try { entries = await readdir(path.join(repoRoot, relDir), { withFileTypes: true }); } catch { continue; } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.md') || entry.name === 'README.md') continue; + const rel = path.posix.join(relDir.split(path.sep).join('/'), entry.name); + const content = await readFile(path.join(repoRoot, rel), 'utf8'); + out.push({ kind, path: rel, content }); + } + } + return out; +} + +export async function initializeArtifactPolicy(repoRoot, manifest) { + if (manifest.artifactPolicy?.version === 1) return manifest.artifactPolicy; + const legacy = []; + for (const artifact of await artifactFiles(repoRoot, manifest)) { + if (artifactVersion(artifact.content) === null) legacy.push({ path: artifact.path, sha256: contentSha256(artifact.content) }); + } + manifest.artifactPolicy = { version: 1, legacy }; + return manifest.artifactPolicy; +} + +export async function refreshArtifactPolicy(repoRoot, manifest) { + const current = new Map((manifest.artifactPolicy?.legacy ?? []).map((entry) => [entry.path, entry])); + const legacy = []; + for (const artifact of await artifactFiles(repoRoot, manifest)) { + if (artifactVersion(artifact.content) !== null) continue; + const entry = current.get(artifact.path); + if (entry) legacy.push(entry); // Never refresh the hash: changed legacy content must migrate. + } + manifest.artifactPolicy = { version: 1, legacy }; + return manifest.artifactPolicy; +} diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs new file mode 100644 index 00000000..142f97d1 --- /dev/null +++ b/scripts/install-hooks.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +// install-hooks.mjs — install the repo-seed pre-commit (and optionally +// commit-msg) hook into a target repository's .git/hooks. Zero dependencies; +// Node >= 18. +// Usage: install-hooks.mjs <target-dir> [--with-commit-msg] +// Existing user hooks are never overwritten. Managed hooks resolve the active +// worktree at runtime and call the shared governance runner. +import { mkdir, writeFile, readFile, stat, chmod } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +export const HOOK_NAME = 'pre-commit'; +export const COMMIT_MSG_HOOK_NAME = 'commit-msg'; +export const MANAGED_MARKER = '# repo-seed managed hook'; + +export function hookScript() { + return `#!/bin/sh +${MANAGED_MARKER}: pre-commit +set -e +repo_root=$(git rev-parse --show-toplevel) || exit 1 +cd "$repo_root" || exit 1 +node scripts/run-gates.mjs --staged +`; +} + +export function commitMsgHookScript() { + return `#!/bin/sh +${MANAGED_MARKER}: commit-msg +set -e +repo_root=$(git rev-parse --show-toplevel) || exit 1 +cd "$repo_root" || exit 1 +node scripts/verify-commit-msg.mjs "\${1:-.git/COMMIT_EDITMSG}" +`; +} + +async function writeHookFile(gitDir, hookName, content) { + const hooksDir = path.join(gitDir, 'hooks'); + await mkdir(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, hookName); + let previous = null; + try { previous = await readFile(hookPath, 'utf8'); } catch {} + if (previous !== null && !previous.includes(MANAGED_MARKER)) { + return { status: 'conflict', hookPath }; + } + await writeFile(hookPath, content, { mode: 0o755 }); + await chmod(hookPath, 0o755); + return { status: previous === null ? 'installed' : 'updated', hookPath }; +} + +export async function resolveGitDir(targetDir) { + const gitDir = path.join(targetDir, '.git'); + let st; + try { + st = await stat(gitDir); + } catch { + throw new Error(`not a git repository: ${targetDir} (no .git)`); + } + let realGit = gitDir; + if (!st.isDirectory()) { + // worktree: .git is a file pointing to the real git dir + const gitFile = await readFile(gitDir, 'utf8'); + const m = gitFile.match(/^gitdir:\s*(.+)$/m); + if (!m) throw new Error(`cannot resolve git dir from ${gitDir}`); + realGit = path.resolve(targetDir, m[1].trim()); + // Linked worktrees keep hooks in the common Git directory, not under the + // worktree-specific metadata directory. Git records that relationship in + // `commondir`; resolving it here also keeps the installer testable without + // invoking Git or assuming a particular `.git/worktrees/*` layout. + try { + const common = (await readFile(path.join(realGit, 'commondir'), 'utf8')).trim(); + if (common) realGit = path.resolve(realGit, common); + } catch { + // A hand-written .git indirection may not provide commondir. In that + // case retain the pointed-to directory as the safest local target. + } + } + return realGit; +} + +export async function installHook(targetDir) { + const gitDir = await resolveGitDir(targetDir); + const result = await writeHookFile(gitDir, HOOK_NAME, hookScript()); + return { gitDir, ...result }; +} + +export async function installCommitMsgHook(gitDir) { + return writeHookFile(gitDir, COMMIT_MSG_HOOK_NAME, commitMsgHookScript()); +} + +async function main() { + const args = process.argv.slice(2); + const target = args[0]; + const withCommitMsg = args.includes('--with-commit-msg'); + if (!target) { + console.error('usage: install-hooks.mjs <target-dir> [--with-commit-msg]'); + process.exit(2); + } + const abs = path.resolve(target); + try { + const preCommit = await installHook(abs); + const results = [{ name: HOOK_NAME, ...preCommit }]; + if (withCommitMsg) { + results.push({ name: COMMIT_MSG_HOOK_NAME, gitDir: preCommit.gitDir, ...(await installCommitMsgHook(preCommit.gitDir)) }); + } + for (const result of results) { + if (result.status === 'conflict') console.warn(`install-hooks: preserved custom ${result.name} hook at ${result.hookPath}`); + else console.log(`install-hooks: ${result.status} ${result.name} at ${result.hookPath}`); + } + } catch (e) { + console.error(`install-hooks: ${e.message}`); + process.exit(1); + } +} + +const isMain = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) { + main().catch((e) => { + console.error('install-hooks: fatal', e); + process.exit(2); + }); +} diff --git a/scripts/render-formula.sh b/scripts/render-formula.sh new file mode 100755 index 00000000..036139ac --- /dev/null +++ b/scripts/render-formula.sh @@ -0,0 +1,80 @@ +#!/bin/sh +set -eu + +if [ "$#" -ne 6 ]; then + printf 'usage: %s VERSION SHA256_DARWIN_ARM SHA256_DARWIN_X86 SHA256_LINUX_ARM SHA256_LINUX_X86 OUTPUT\n' "$0" >&2 + exit 2 +fi + +VERSION=$1 +SHA256_DARWIN_ARM=$2 +SHA256_DARWIN_X86=$3 +SHA256_LINUX_ARM=$4 +SHA256_LINUX_X86=$5 +OUTPUT=$6 + +case "$VERSION" in + ''|*[!0-9.]*|.*|*.) + printf 'invalid version: %s\n' "$VERSION" >&2 + exit 1 + ;; +esac + +validate_hash() { + HASH=$1 + case "$HASH" in + [!0-9a-fA-F]*|*[!0-9a-fA-F]*) + return 1 + ;; + esac + [ "$(printf '%s' "$HASH" | awk '{ print length }')" -eq 64 ] +} + +validate_hash "$SHA256_DARWIN_ARM" || { printf 'invalid darwin arm sha256\n' >&2; exit 1; } +validate_hash "$SHA256_DARWIN_X86" || { printf 'invalid darwin x86 sha256\n' >&2; exit 1; } +validate_hash "$SHA256_LINUX_ARM" || { printf 'invalid linux arm sha256\n' >&2; exit 1; } +validate_hash "$SHA256_LINUX_X86" || { printf 'invalid linux x86 sha256\n' >&2; exit 1; } + +mkdir -p "$(dirname "$OUTPUT")" +cat > "$OUTPUT" <<EOF +class Rcm < Formula + desc "Composable AI pipelines with .rcm files" + homepage "https://github.com/EricSanchezok/recursive-context-machine" + version "$VERSION" + license "MIT" + + livecheck do + url "https://github.com/EricSanchezok/recursive-context-machine/releases/latest" + regex(%r{href=.*?/tag/v?(\\d+(?:\\.\\d+)+)}i) + strategy :page_match + end + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-aarch64-darwin.tar.gz" + sha256 "$SHA256_DARWIN_ARM" + else + url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-x86_64-darwin.tar.gz" + sha256 "$SHA256_DARWIN_X86" + end + end + + on_linux do + if Hardware::CPU.arm? + url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-aarch64-linux.tar.gz" + sha256 "$SHA256_LINUX_ARM" + else + url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-x86_64-linux.tar.gz" + sha256 "$SHA256_LINUX_X86" + end + end + + def install + bin.install "accelerate" + end + + test do + system "#{bin}/accelerate", "--version" + end +end +EOF diff --git a/scripts/run-gates.mjs b/scripts/run-gates.mjs new file mode 100644 index 00000000..75b156d5 --- /dev/null +++ b/scripts/run-gates.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// run-gates.mjs — execute all installed repo-seed governance gates. +import { readFile, stat } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +const EXCLUDED = new Set(['scripts/verify-commit-msg.mjs']); + +export function governanceGatePaths(manifest) { + const seen = new Set(); + const paths = []; + for (const entry of manifest?.files ?? []) { + if (entry.category !== 'gate' || !/^scripts\/verify-.+\.mjs$/.test(entry.path) || EXCLUDED.has(entry.path)) continue; + const state = entry.capability ? manifest?.capabilities?.items?.[entry.capability]?.state : null; + if (state && state !== 'enabled') continue; + if (!seen.has(entry.path)) paths.push(entry.path); + seen.add(entry.path); + } + return paths; +} + +function run(command, args, cwd) { + return new Promise((resolve) => { + const child = spawn(command, args, { cwd, stdio: 'inherit' }); + child.on('error', () => resolve(2)); + child.on('exit', (code) => resolve(code ?? 2)); + }); +} + +export async function runGates(repoRoot, { staged = false } = {}) { + let manifest; + try { manifest = JSON.parse(await readFile(path.join(repoRoot, '.repo-seed', 'manifest.json'), 'utf8')); } + catch { return { errors: ['cannot read .repo-seed/manifest.json'], gates: [] }; } + const gates = governanceGatePaths(manifest); + const errors = []; + for (const rel of gates) { + try { const file = await stat(path.join(repoRoot, rel)); if (!file.isFile()) throw new Error('not a file'); } + catch { errors.push(`${rel}: gate file missing`); continue; } + const code = await run(process.execPath, [rel], repoRoot); + if (code !== 0) errors.push(`${rel}: exited ${code}`); + } + if (staged) { + const code = await run('git', ['diff', '--cached', '--check'], repoRoot); + if (code !== 0) errors.push(`git diff --cached --check: exited ${code}`); + } + return { errors, gates }; +} + +async function main() { + const staged = process.argv.includes('--staged'); + const repoRoot = path.resolve(process.argv.find((arg, index) => index > 1 && !arg.startsWith('--')) ?? '.'); + const { errors, gates } = await runGates(repoRoot, { staged }); + if (errors.length) { + for (const error of errors) console.error(`run-gates: ${error}`); + process.exit(1); + } + console.log(`run-gates: OK (${gates.length} governance gates${staged ? ' + staged whitespace' : ''})`); +} + +const isMain = process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) main().catch((error) => { console.error(`run-gates: fatal ${error.message}`); process.exit(2); }); diff --git a/scripts/verify-commit-msg.mjs b/scripts/verify-commit-msg.mjs new file mode 100644 index 00000000..052f977a --- /dev/null +++ b/scripts/verify-commit-msg.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +// verify-commit-msg.mjs — conventional commits message gate (ext-release). +// Reads the commit message from .git/COMMIT_EDITMSG (or a file argument) and +// checks the subject against the Conventional Commits format: +// type(scope)!: subject +// Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, +// chore, revert. Merge commits are allowed through. +// Zero dependencies; Node >= 18. Exits non-zero on violation. +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +export const ALLOWED_TYPES = new Set([ + 'feat', + 'fix', + 'docs', + 'style', + 'refactor', + 'perf', + 'test', + 'build', + 'ci', + 'chore', + 'revert', +]); + +const SUBJECT_RE = /^([a-z]+)(\([a-z0-9-]+\))?(!)?: .+$/; + +export function checkCommitMessage(message) { + const errors = []; + const lines = message.split('\n'); + const subject = lines[0]?.trim() ?? ''; + if (subject === '') { + errors.push('empty commit message'); + return errors; + } + if (subject.startsWith('Merge ')) return errors; // merge commits are exempt + const m = subject.match(SUBJECT_RE); + if (!m) { + errors.push(`subject does not follow Conventional Commits: "${subject}"`); + return errors; + } + const type = m[1]; + if (!ALLOWED_TYPES.has(type)) { + errors.push(`unknown commit type "${type}" (allowed: ${[...ALLOWED_TYPES].join(', ')})`); + } + return errors; +} + +async function main() { + const target = process.argv[2] ?? path.join(process.cwd(), '.git', 'COMMIT_EDITMSG'); + let message; + try { + message = await readFile(target, 'utf8'); + } catch { + console.error(`verify-commit-msg: cannot read commit message at ${target}`); + process.exit(2); + } + const errors = checkCommitMessage(message); + if (errors.length) { + for (const e of errors) console.error(`verify-commit-msg: ${e}`); + process.exit(1); + } + console.log('verify-commit-msg: OK'); +} + +const isMain = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) { + main().catch((e) => { + console.error('verify-commit-msg: fatal', e); + process.exit(2); + }); +} diff --git a/scripts/verify-decisions.mjs b/scripts/verify-decisions.mjs new file mode 100644 index 00000000..04d22a42 --- /dev/null +++ b/scripts/verify-decisions.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// verify-decisions.mjs — decision log gate. +// Enforces the MADR-based decision log contract in docs/decisions/: +// - file naming NNNN-title.md, unique sequential numbering +// - required sections, in order +// - valid status values +// - valid Class: extension values when present +// - "Superseded by NNNN" targets that exist +// Zero dependencies; Node >= 18. Exits non-zero on any violation. +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { governancePaths, readRepoManifest } from './governance-config.mjs'; + +export const DECISIONS_DIR = 'docs/decisions'; +export const REQUIRED_SECTIONS = [ + 'Status', + 'Context and Problem Statement', + 'Decision Drivers', + 'Considered Options', + 'Decision Outcome', + 'Pros and Cons of the Options', + 'Links', +]; +export const VALID_STATUSES = new Set(['Proposed', 'Accepted', 'Rejected', 'Deprecated']); +export const SUPERSEDED_PREFIX = 'Superseded by '; +export const VALID_CLASSES = new Set([ + 'architecture', + 'process', + 'testing', + 'feature', + 'bug-fix', + 'simplification', +]); + +export function sectionOrder(text) { + const re = /^## (.+)$/gm; + const names = []; + let m; + while ((m = re.exec(text)) !== null) names.push(m[1].trim()); + if (names[0] !== 'Status') return false; + const positions = REQUIRED_SECTIONS.map((s) => names.indexOf(s)); + if (positions.some((p) => p === -1)) return false; + for (let i = 1; i < positions.length; i++) { + if (positions[i] < positions[i - 1]) return false; + } + return true; +} + +export function checkStatusAndClass(f, text, nums, errors) { + const statusMatch = text.match(/^## Status\n+([^\n]+)/m); + if (!statusMatch) { + errors.push(`${f}: missing status value`); + return; + } + const statusLine = statusMatch[1].trim(); + let statusOk = VALID_STATUSES.has(statusLine); + if (!statusOk && statusLine.startsWith(SUPERSEDED_PREFIX)) { + const num = statusLine.match(/(\d{4})/); + if (num && nums.includes(Number(num[1]))) { + statusOk = true; + } else { + errors.push(`${f}: superseded target missing or does not exist: "${statusLine}"`); + } + } + if (!statusOk) errors.push(`${f}: invalid status "${statusLine}"`); + + // Class extension: first non-empty line after the status value line. + const lines = text.split('\n'); + let idx = lines.findIndex((l) => l.startsWith('## Status')); + idx += 1; + while (idx < lines.length && lines[idx].trim() === '') idx++; // status value + idx += 1; + while (idx < lines.length && lines[idx].trim() === '') idx++; // possible Class line + if (idx < lines.length && lines[idx].trim().startsWith('Class: ')) { + const cls = lines[idx].trim().slice('Class: '.length).trim(); + if (!VALID_CLASSES.has(cls)) errors.push(`${f}: invalid Class "${cls}"`); + } +} + +export async function verifyDir(dirPath) { + const errors = []; + let entries; + try { + entries = await readdir(dirPath, { withFileTypes: true }); + } catch { + errors.push(`decisions directory missing: ${dirPath}`); + return errors; + } + const files = entries + .filter((e) => e.isFile() && /^\d{4}-.+\.md$/.test(e.name)) + .map((e) => e.name) + .sort(); + const nums = files.map((f) => Number(f.slice(0, 4))); + const seen = new Set(); + for (const n of nums) { + if (seen.has(n)) errors.push(`duplicate decision number ${String(n).padStart(4, '0')}`); + seen.add(n); + } + for (let i = 0; i < nums.length; i++) { + if (nums[i] !== i) { + errors.push(`decision numbers must be sequential starting at 0; found ${files[i]}`); + break; + } + } + for (const f of files) { + const text = await readFile(path.join(dirPath, f), 'utf8'); + if (!sectionOrder(text)) errors.push(`${f}: required sections missing or out of order`); + checkStatusAndClass(f, text, nums, errors); + } + return errors; +} + +async function main() { + const repoRoot = process.cwd(); + const manifest = await readRepoManifest(repoRoot); + const dir = process.argv[2] ?? path.join(repoRoot, governancePaths(manifest).decisions); + const errors = await verifyDir(dir); + if (errors.length) { + for (const e of errors) console.error(`verify-decisions: ${e}`); + process.exit(1); + } + const entries = await readdir(dir, { withFileTypes: true }); + const count = entries.filter((e) => e.isFile() && /^\d{4}-.+\.md$/.test(e.name)).length; + console.log(`verify-decisions: OK (${count} records)`); +} + +const isMain = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) { + main().catch((e) => { + console.error('verify-decisions: fatal', e); + process.exit(2); + }); +} diff --git a/scripts/verify-doc-links.mjs b/scripts/verify-doc-links.mjs new file mode 100644 index 00000000..6399f585 --- /dev/null +++ b/scripts/verify-doc-links.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// verify-doc-links.mjs — documentation link gate. +// Checks that relative Markdown links and #fragment anchors in the seeded +// documentation surface resolve to real files and real anchors. +// Scope (bounded, never user code): AGENTS.md, CLAUDE.md, docs/**, CONTRIBUTING.md. +// Zero dependencies; Node >= 18. Exits non-zero on any violation. +import { readdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { readRepoManifest, safeRepositoryRelativePath } from './governance-config.mjs'; + +export const SCOPED_PATHS = ['AGENTS.md', 'CLAUDE.md', 'docs', 'CONTRIBUTING.md', 'SECURITY.md', 'CODE_OF_CONDUCT.md']; + +// Links inside fenced code blocks and inline code are examples, not real +// links (e.g. `[0001](0001-title.md)` in a format description); strip them +// before extraction so they do not false-positive. +export function stripCode(text) { + return text + .replace(/```[\s\S]*?```/g, (m) => ' '.repeat(m.length)) + .replace(/`[^`]*`/g, (m) => ' '.repeat(m.length)); +} + +// Extract markdown links [text](target) and fragment references. +export function extractLinks(text) { + const links = []; + const re = /\[[^\]]*\]\(([^)]+)\)/g; + let m; + while ((m = re.exec(stripCode(text))) !== null) { + const target = m[1].trim(); + if (target.startsWith('http://') || target.startsWith('https://') || target.startsWith('mailto:')) continue; + if (target.startsWith('<') && target.endsWith('>')) continue; // autolink + const [filePart, fragment] = target.split('#'); + links.push({ filePart: filePart || null, fragment: fragment || null, raw: target }); + } + return links; +} + +// Collect headings (as slug anchors) from a markdown file. +export function headingAnchors(text) { + const anchors = new Set(); + const re = /^(#{1,6})\s+(.+)$/gm; + let m; + while ((m = re.exec(text)) !== null) { + anchors.add(slugify(m[2])); + } + return anchors; +} + +export function slugify(heading) { + return heading + .trim() + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-'); +} + +// Check a single file: all links resolve. +export async function checkFile(fileAbs, repoRoot, { allowedBareDirectories = new Set() } = {}) { + const errors = []; + let text; + try { + text = await readFile(fileAbs, 'utf8'); + } catch { + errors.push(`${path.relative(repoRoot, fileAbs)}: unreadable`); + return errors; + } + const fileDir = path.dirname(fileAbs); + const links = extractLinks(text); + for (const l of links) { + if (l.filePart === null) { + // Fragment-only link: must resolve in this file. + const anchors = headingAnchors(text); + if (l.fragment && !anchors.has(l.fragment)) { + errors.push(`${path.relative(repoRoot, fileAbs)}: fragment "#${l.fragment}" not found in file`); + } + continue; + } + // Relative link: resolve against fileDir, allow ../ + const targetAbs = path.resolve(fileDir, l.filePart); + // Reject escaping the repo root + const rel = path.relative(repoRoot, targetAbs); + if (rel.startsWith('..') || path.isAbsolute(rel) || !safeRepositoryRelativePath(rel)) { + errors.push(`${path.relative(repoRoot, fileAbs)}: link "${l.raw}" has an unsafe repository target`); + continue; + } + let st; + try { + st = await stat(targetAbs); + } catch { + errors.push(`${path.relative(repoRoot, fileAbs)}: link "${l.raw}" -> missing ${l.filePart}`); + continue; + } + if (st.isDirectory()) { + if (allowedBareDirectories.has(path.resolve(targetAbs))) continue; + // Directory link: check for README.md inside + const idx = path.join(targetAbs, 'README.md'); + try { + await stat(idx); + } catch { + errors.push(`${path.relative(repoRoot, fileAbs)}: link "${l.raw}" -> directory ${l.filePart} without README.md`); + } + continue; + } + if (l.fragment) { + const targetText = await readFile(targetAbs, 'utf8'); + const anchors = headingAnchors(targetText); + if (!anchors.has(l.fragment)) { + errors.push(`${path.relative(repoRoot, fileAbs)}: fragment "#${l.fragment}" not found in ${l.filePart}`); + } + } + } + return errors; +} + +export async function collectScopedFiles(repoRoot) { + const files = []; + async function walk(dir, prefix) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (e.name === '.git' || e.name === 'node_modules' || e.name === '.repo-seed') continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + await walk(full, path.join(prefix, e.name)); + } else if (e.name.endsWith('.md')) { + files.push(path.join(prefix, e.name)); + } + } + } + for (const p of SCOPED_PATHS) { + const abs = path.join(repoRoot, p); + let st; + try { + st = await stat(abs); + } catch { + continue; + } + if (st.isDirectory()) { + await walk(abs, p); + } else if (st.isFile() && p.endsWith('.md')) { + files.push(p); + } + } + return files; +} + +export async function verifyLinks(repoRoot) { + const errors = []; + const files = await collectScopedFiles(repoRoot); + const manifest = await readRepoManifest(repoRoot); + const allowedBareDirectories = new Set( + Object.values(manifest?.governance?.externalSources ?? {}) + .filter((source) => source?.type === 'repository' && source.location) + .map((source) => path.resolve(repoRoot, source.location)) + ); + for (const f of files) { + const fileErrors = await checkFile(path.join(repoRoot, f), repoRoot, { allowedBareDirectories }); + errors.push(...fileErrors); + } + return { errors, files }; +} + +async function main() { + const repoRoot = path.resolve(process.argv[2] ?? '.'); + const { errors, files } = await verifyLinks(repoRoot); + if (errors.length) { + for (const e of errors) console.error(`verify-doc-links: ${e}`); + process.exit(1); + } + console.log(`verify-doc-links: OK (${files.length} files)`); +} + +const isMain = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) { + main().catch((e) => { + console.error('verify-doc-links: fatal', e); + process.exit(2); + }); +} diff --git a/scripts/verify-manifest.mjs b/scripts/verify-manifest.mjs new file mode 100644 index 00000000..f7504c35 --- /dev/null +++ b/scripts/verify-manifest.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// verify-manifest.mjs — manifest gate. +// Checks that every file recorded in .repo-seed/manifest.json still exists and +// matches its recorded sha256, and that seeded files are recorded. +// Files marked userModified in the manifest are checked for existence only: +// the user took them over, so the recorded hash is informational. +// Zero dependencies; Node >= 18. Exits non-zero on any violation. +import { readFile, stat } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { CAPABILITIES, CAPABILITY_STATES } from './capabilities.mjs'; +import { safeRepositoryRelativePath } from './governance-config.mjs'; + +export const MANIFEST_PATH = '.repo-seed/manifest.json'; + +export function sha256(text) { + return createHash('sha256').update(text).digest('hex'); +} + +export async function loadManifest(repoRoot) { + const abs = path.join(repoRoot, MANIFEST_PATH); + try { + const raw = await readFile(abs, 'utf8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +export async function verifyManifest(repoRoot) { + const errors = []; + const manifest = await loadManifest(repoRoot); + if (!manifest) { + return { errors: ['no manifest found at .repo-seed/manifest.json — run the repo-seed skill first'], files: [] }; + } + if (manifest.version !== 1) { + errors.push(`unsupported manifest version ${manifest.version} (expected 1)`); + } + if (manifest.capabilities?.version !== 1) { + errors.push('capabilities.version must be 1'); + } + for (const [id, capability] of Object.entries(manifest.capabilities?.items ?? {})) { + if (!CAPABILITIES[id]) errors.push(`unknown capability: ${id}`); + if (!CAPABILITY_STATES.has(capability?.state)) errors.push(`invalid capability state for ${id}: ${capability?.state}`); + if (CAPABILITIES[id]?.tier === 'core' && !['enabled', 'external'].includes(capability?.state)) { + errors.push(`core capability ${id} must be enabled or external`); + } + if (id === 'baseline' && capability?.state !== 'enabled') { + errors.push('baseline capability must remain enabled in a managed repository'); + } + if (['declined', 'deferred'].includes(capability?.state) && !/^[a-f0-9]{64}$/.test(capability?.assessmentHash ?? '')) { + errors.push(`${id}: declined/deferred capability requires a sha256 assessmentHash`); + } + if (['declined', 'deferred'].includes(capability?.state) && !capability?.reason?.trim()) { + errors.push(`${id}: declined/deferred capability requires a reason`); + } + } + const governancePaths = manifest.governance?.paths ?? {}; + for (const [kind, rel] of Object.entries(governancePaths)) { + if (!safeRepositoryRelativePath(rel)) { + errors.push(`invalid governance path for ${kind}: ${rel}`); + } + } + for (const [kind, source] of Object.entries(manifest.governance?.externalSources ?? {})) { + if (!['repository', 'link'].includes(source?.type) || !source.location) errors.push(`invalid external source for ${kind}`); + } + for (const [id, definition] of Object.entries(CAPABILITIES)) { + const sourceKind = id === 'spec' ? 'specs' : id; + if (definition.tier === 'core' && !manifest.capabilities?.items?.[id]) { + errors.push(`core capability missing from manifest: ${id}`); + continue; + } + if (definition.tier === 'core' && manifest.capabilities?.items?.[id]?.state === 'external' && + !manifest.governance?.externalSources?.[sourceKind]) { + errors.push(`external core capability ${id} requires governance.externalSources.${sourceKind}`); + } + } + if (manifest.artifactPolicy?.version !== 1) errors.push('artifactPolicy.version must be 1'); + for (const entry of manifest.artifactPolicy?.legacy ?? []) { + if (!entry.path || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '')) errors.push(`invalid legacy artifact entry: ${JSON.stringify(entry)}`); + } + const files = manifest.files ?? []; + const seen = new Set(); + for (const entry of files) { + if (!entry.path || !entry.sha256) { + errors.push(`manifest entry missing path or sha256: ${JSON.stringify(entry)}`); + continue; + } + if (entry.capability && !CAPABILITIES[entry.capability]) { + errors.push(`manifest entry has unknown capability ${entry.capability}: ${entry.path}`); + } + if (!safeRepositoryRelativePath(entry.path)) { + errors.push(`manifest entry escapes repository: ${entry.path}`); + continue; + } + if (seen.has(entry.path)) { + errors.push(`duplicate manifest entry: ${entry.path}`); + } + seen.add(entry.path); + const abs = path.join(repoRoot, entry.path); + let st; + try { + st = await stat(abs); + } catch { + errors.push(`seeded file missing: ${entry.path}`); + continue; + } + if (st.isDirectory()) { + errors.push(`seeded path is a directory, expected file: ${entry.path}`); + continue; + } + if (entry.userModified) { + // User took over this seeded file; existence is the only invariant. + continue; + } + const content = await readFile(abs, 'utf8'); + const hash = sha256(content); + if (hash !== entry.sha256) { + errors.push(`seeded file modified since seed (hash mismatch): ${entry.path}`); + } + } + return { errors, files }; +} + +async function main() { + const repoRoot = path.resolve(process.argv[2] ?? '.'); + const { errors, files } = await verifyManifest(repoRoot); + if (errors.length) { + for (const e of errors) console.error(`verify-manifest: ${e}`); + process.exit(1); + } + console.log(`verify-manifest: OK (${files.length} entries)`); +} + +const isMain = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) { + main().catch((e) => { + console.error('verify-manifest: fatal', e); + process.exit(2); + }); +} diff --git a/scripts/verify-placeholders.mjs b/scripts/verify-placeholders.mjs new file mode 100644 index 00000000..b69e286c --- /dev/null +++ b/scripts/verify-placeholders.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// verify-placeholders.mjs — placeholder gate. +// Flags fill-in tokens (__UPPERCASE__) left in seeded documentation and skills. +// Scope: AGENTS.md, CLAUDE.md, docs/**, .agents/skills/repo-review, .agents/skills/repo-decisions, CONTRIBUTING.md. +// Zero dependencies; Node >= 18. Exits non-zero on any violation. +import { readdir, stat, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +export const SCOPED_PATHS = [ + 'AGENTS.md', + 'CLAUDE.md', + 'docs', + 'CONTRIBUTING.md', + '.agents/skills/repo-review/SKILL.md', + '.agents/skills/repo-decisions/SKILL.md', + '.agents/skills/repo-governance/SKILL.md', + 'SECURITY.md', + 'CODE_OF_CONDUCT.md', + 'CODEOWNERS', + '.github/workflows', +]; + +const TOKEN_RE = /__[A-Z][A-Z0-9_]*__/g; + +// No fill-in token may remain in shipped content. The model resolves every +// token during instantiation; the gate fails on any survivor. +const ALLOWED_TOKENS = new Set(); + +export function findPlaceholders(text) { + const found = []; + let m; + while ((m = TOKEN_RE.exec(text)) !== null) { + if (!ALLOWED_TOKENS.has(m[0])) found.push(m[0]); + } + return found; +} + +export async function collectScopedFiles(repoRoot) { + const files = []; + async function walk(dir, prefix) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (e.name === '.git' || e.name === 'node_modules' || e.name === '.repo-seed') continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + await walk(full, path.join(prefix, e.name)); + } else if (e.name.endsWith('.md') || e.name.endsWith('.yml') || e.name.endsWith('.yaml')) { + files.push(path.join(prefix, e.name)); + } + } + } + for (const p of SCOPED_PATHS) { + const abs = path.join(repoRoot, p); + let st; + try { + st = await stat(abs); + } catch { + continue; + } + if (st.isDirectory()) { + await walk(abs, p); + } else if (st.isFile()) { + files.push(p); + } + } + return files; +} + +export async function verifyPlaceholders(repoRoot) { + const errors = []; + const files = await collectScopedFiles(repoRoot); + for (const f of files) { + const text = await readFile(path.join(repoRoot, f), 'utf8'); + const tokens = findPlaceholders(text); + for (const t of tokens) { + errors.push(`${f}: placeholder ${t} left in shipped content`); + } + } + return { errors, files }; +} + +async function main() { + const repoRoot = path.resolve(process.argv[2] ?? '.'); + const { errors, files } = await verifyPlaceholders(repoRoot); + if (errors.length) { + for (const e of errors) console.error(`verify-placeholders: ${e}`); + process.exit(1); + } + console.log(`verify-placeholders: OK (${files.length} files)`); +} + +const isMain = + process.argv[1] && + pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) { + main().catch((e) => { + console.error('verify-placeholders: fatal', e); + process.exit(2); + }); +} diff --git a/scripts/verify-postmortems.mjs b/scripts/verify-postmortems.mjs new file mode 100644 index 00000000..4f18c23f --- /dev/null +++ b/scripts/verify-postmortems.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// verify-postmortems.mjs — incident feedback-loop gate. +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { artifactVersion, grandfatheredArtifact, governancePaths, readRepoManifest } from './governance-config.mjs'; + +export const POSTMORTEM_SECTIONS = ['Executive summary', 'Summary', 'Timeline', 'Root cause', 'Guardrails']; + +function orderedSections(text) { + const names = [...text.matchAll(/^## (.+)$/gm)].map((match) => match[1].trim()); + return POSTMORTEM_SECTIONS.every((section, index) => names[index] === section); +} + +function guardrailLinks(text) { + const body = text.match(/^## Guardrails\n([\s\S]*)$/m)?.[1] ?? ''; + return [...body.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)] + .map((match) => match[1].trim()) + .filter((target) => !/^(https?:|mailto:|#)/.test(target)); +} + +export async function verifyPostmortems(repoRoot) { + const errors = []; + const manifest = await readRepoManifest(repoRoot); + const dirRel = governancePaths(manifest).postmortems; + if (manifest?.capabilities?.items?.postmortems?.state === 'external') return { errors, files: [] }; + let entries = []; + try { entries = await readdir(path.join(repoRoot, dirRel), { withFileTypes: true }); } + catch { return { errors: [`postmortem directory missing: ${dirRel}`], files: [] }; } + const files = entries.filter((entry) => entry.isFile() && entry.name !== 'README.md' && entry.name.endsWith('.md')).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of files) if (!/^\d{4}-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/.test(entry.name)) errors.push(`${entry.name}: invalid postmortem filename`); + const numbers = files.filter((entry) => /^\d{4}-/.test(entry.name)).map((entry) => Number(entry.name.slice(0, 4))); + for (let index = 0; index < numbers.length; index++) { + if (numbers[index] !== index) { errors.push(`postmortem numbers must be sequential starting at 0; found ${files[index].name}`); break; } + } + for (const entry of files) { + const rel = path.posix.join(dirRel.split(path.sep).join('/'), entry.name); + const text = await readFile(path.join(repoRoot, rel), 'utf8'); + if (artifactVersion(text) === null && grandfatheredArtifact(manifest, rel, text)) continue; + if (artifactVersion(text) !== 1) { errors.push(`${entry.name}: missing or unsupported Artifact-Version: 1`); continue; } + if (!orderedSections(text)) errors.push(`${entry.name}: required postmortem sections missing or out of order`); + if (guardrailLinks(text).length === 0) errors.push(`${entry.name}: Guardrails must link a permanent repository test, gate, or rule`); + } + return { errors, files: files.map((entry) => entry.name) }; +} + +async function main() { + const repoRoot = path.resolve(process.argv[2] ?? '.'); + const { errors, files } = await verifyPostmortems(repoRoot); + if (errors.length) { for (const error of errors) console.error(`verify-postmortems: ${error}`); process.exit(1); } + console.log(`verify-postmortems: OK (${files.length} records)`); +} + +const isMain = process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) main().catch((error) => { console.error(`verify-postmortems: fatal ${error.message}`); process.exit(2); }); diff --git a/scripts/verify-specs.mjs b/scripts/verify-specs.mjs new file mode 100644 index 00000000..cb778542 --- /dev/null +++ b/scripts/verify-specs.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// verify-specs.mjs — risk-triggered change-contract gate. +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { artifactVersion, grandfatheredArtifact, governancePaths, readRepoManifest } from './governance-config.mjs'; + +export const SPEC_SECTIONS = ['Intent', 'Contract', 'Plan', 'Verification', 'Evidence']; + +function sectionNames(text) { + return [...text.matchAll(/^## (.+)$/gm)].map((match) => match[1].trim()); +} + +export function hasOrderedSpecSections(text) { + const names = sectionNames(text); + let cursor = -1; + for (const section of SPEC_SECTIONS) { + const next = names.indexOf(section); + if (next === -1 || next <= cursor) return false; + cursor = next; + } + return true; +} + +function relativeMarkdownLinks(text) { + return [...text.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)] + .map((match) => match[1].trim()) + .filter((target) => !/^(https?:|mailto:|#)/.test(target)); +} + +function sectionBody(text, name) { + const marker = `## ${name}\n`; + const start = text.indexOf(marker); + if (start === -1) return ''; + const rest = text.slice(start + marker.length); + const next = rest.search(/^## /m); + return (next === -1 ? rest : rest.slice(0, next)).trim(); +} + +export async function verifySpecs(repoRoot) { + const errors = []; + const manifest = await readRepoManifest(repoRoot); + const dirRel = governancePaths(manifest).specs; + const specCapability = manifest?.capabilities?.items?.spec ?? { state: 'enabled', mode: 'repo' }; + if (specCapability.state === 'external' || specCapability.mode === 'external') return { errors, files: [] }; + let entries = []; + try { entries = await readdir(path.join(repoRoot, dirRel), { withFileTypes: true }); } + catch { + errors.push(`spec directory missing: ${dirRel}`); + return { errors, files: [] }; + } + const markdown = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md'); + const validName = /^(?:draft-|\d{4}-)[a-z0-9]+(?:-[a-z0-9]+)*\.md$/; + for (const entry of markdown) if (!validName.test(entry.name)) errors.push(`${entry.name}: invalid spec filename`); + const numbered = markdown.filter((entry) => /^\d{4}-/.test(entry.name)).sort((a, b) => a.name.localeCompare(b.name)); + const numbers = numbered.map((entry) => Number(entry.name.slice(0, 4))); + for (let index = 0; index < numbers.length; index++) { + if (numbers[index] !== index) { errors.push(`spec numbers must be sequential starting at 0; found ${numbered[index].name}`); break; } + } + for (const entry of markdown) { + const rel = path.posix.join(dirRel.split(path.sep).join('/'), entry.name); + const text = await readFile(path.join(repoRoot, rel), 'utf8'); + if (artifactVersion(text) === null && grandfatheredArtifact(manifest, rel, text)) continue; + if (artifactVersion(text) !== 1) { errors.push(`${entry.name}: missing or unsupported Artifact-Version: 1`); continue; } + const status = text.match(/^Status:\s*(.+)$/m)?.[1]?.trim(); + if (!status) { errors.push(`${entry.name}: missing Status metadata`); continue; } + const isDraftName = entry.name.startsWith('draft-'); + if ((status === 'Draft') !== isDraftName) errors.push(`${entry.name}: Draft status and draft- filename must match`); + if (!['Draft', 'Approved', 'Implemented'].includes(status) && !status.startsWith('Superseded by ')) { + errors.push(`${entry.name}: invalid status "${status}"`); + } + if (status.startsWith('Superseded by ')) { + const target = status.match(/(\d{4})/)?.[1]; + if (!target || !numbers.includes(Number(target))) errors.push(`${entry.name}: superseded target missing`); + } + if (!hasOrderedSpecSections(text)) errors.push(`${entry.name}: required spec sections missing or out of order`); + if (status === 'Implemented' && relativeMarkdownLinks(sectionBody(text, 'Evidence')).length === 0) { + errors.push(`${entry.name}: Implemented spec Evidence must link a permanent repository artifact`); + } + } + return { errors, files: markdown.map((entry) => entry.name) }; +} + +async function main() { + const repoRoot = path.resolve(process.argv[2] ?? '.'); + const { errors, files } = await verifySpecs(repoRoot); + if (errors.length) { for (const error of errors) console.error(`verify-specs: ${error}`); process.exit(1); } + console.log(`verify-specs: OK (${files.length} records)`); +} + +const isMain = process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url; +if (isMain) main().catch((error) => { console.error(`verify-specs: fatal ${error.message}`); process.exit(2); }); diff --git a/sdks/python/AGENTS.md b/sdks/python/AGENTS.md new file mode 100644 index 00000000..827d8ef9 --- /dev/null +++ b/sdks/python/AGENTS.md @@ -0,0 +1,13 @@ +# Python SDK + +This directory is the Python client for the gRPC contract in `../../proto/rcm.proto`. + +- Keep generated `_pb2.py` and `_pb2_grpc.py` files synchronized with the proto. +- Regenerate with `bash generate.sh` after a proto change and review the generated + diff rather than hand-editing generated modules. +- Keep public client behavior and model types in `src/rcm/`; put behavior tests in + `tests/` and examples in `examples/`. +- Use the SDK's own `pyproject.toml`; the repository root is not a Python package. + +Run `pytest` from this directory for the SDK test suite and run the Rust server +integration tests when the wire contract changes. diff --git a/sdks/python/README.md b/sdks/python/README.md new file mode 100644 index 00000000..4cc29dd3 --- /dev/null +++ b/sdks/python/README.md @@ -0,0 +1,68 @@ +# RCM Python SDK + +Python gRPC client for RCM (Recursive Context Machine). + +## Setup + +```bash +cd sdks/python +pip install -e . grpcio-tools +bash generate.sh +``` + +Start the RCM gRPC server in another terminal: + +```bash +cargo run --release -p server --bin rcm-server +``` + +Set an OpenAI-compatible model key. The examples below use DeepSeek: + +```bash +export DEEPSEEK_API_KEY="REDACTED" +``` + +## Examples + +### Echo Agent + +A minimal external Python controller: define a model, select it by name, append a prompt, and let the model repeat the user message. + +```bash +PYTHONPATH=src python examples/echo_agent.py "hello from python" +``` + +### MCP Math + +A local stdio MCP server exposes `add` and `multiply`. The SDK registers the MCP server definition, selects it with `mcps=["math"]`, activates the discovered tools, and drives the loop from Python. + +```bash +PYTHONPATH=src python examples/mcp_math.py "What is 23 multiplied by 19, plus 7?" +``` + +## API Shape + +Definitions and selections are separate: + +```python +from rcm import McpServer, Model, RCMClient + +client = RCMClient("localhost:50051") +model = Model(name="deepseek-v4-flash", ...) +math_server = McpServer(label="math", transport=McpServer.Stdio(...)) + +machine_id, state, actions = client.open( + purpose="solve arithmetic", + model_definitions=[model], + models=["deepseek-v4-flash"], + mcp_definitions=[math_server], + mcps=["math"], + tools=[], + prompts={"math": "Use math tools for arithmetic."}, +) +``` + +- `model_definitions` and `mcp_definitions` register external resources for this machine. +- `models`, `tools`, and `mcps` select resources by name. +- `prompts` registers prompt texts that appear as append actions in the returned action space. +- `ReactPolicy` is a small external controller for the running loop; setup remains explicit in Python. diff --git a/sdks/python/examples/echo_agent.py b/sdks/python/examples/echo_agent.py new file mode 100644 index 00000000..6b52d0fc --- /dev/null +++ b/sdks/python/examples/echo_agent.py @@ -0,0 +1,76 @@ +import os +import sys + +from rcm import ActionCommand, FragmentContent, Model, RCMClient, ReactPolicy + +MODEL_NAME = "deepseek-v4-flash" +SYSTEM_PROMPT = "Repeat the user's message exactly. Do not add, remove, explain, or translate anything." + + +def pick_label(actions, label: str): + for action in actions.actions: + if action.label == label: + return action.command + raise RuntimeError(f"action not available: {label}") + + +def run_loop(client, machine_id, state, actions, max_steps: int = 6): + policy = ReactPolicy() + for step in range(max_steps): + command, label = policy(state, actions) + print(f"[{step + 1}] {label}") + state, actions = client.step(machine_id, command) + if state.done: + break + return state, actions + + +def main(): + if "DEEPSEEK_API_KEY" not in os.environ: + raise RuntimeError("DEEPSEEK_API_KEY is not set") + + user_message = " ".join(sys.argv[1:]) or "RCM external Python control works." + client = RCMClient("localhost:50051") + + machine_id, state, actions = client.open( + purpose=user_message, + model_definitions=[ + Model( + name=MODEL_NAME, + protocol="openai", + endpoint="https://api.deepseek.com", + credentials=Model.Credentials(env="DEEPSEEK_API_KEY"), + limit=Model.Limit(context=64_000, output=2_000), + modalities_input=["text"], + modalities_output=["text"], + ) + ], + models=[MODEL_NAME], + prompts={"echo": SYSTEM_PROMPT}, + ) + + try: + state, actions = client.step(machine_id, pick_label(actions, "Append echo")) + state, actions = client.step( + machine_id, + ActionCommand( + verb="Append", + fragment=FragmentContent(role="user", text=user_message, tag="purpose"), + ), + ) + state, actions = client.step( + machine_id, ActionCommand(verb="Model", name=MODEL_NAME) + ) + state, _ = run_loop(client, machine_id, state, actions) + + for fragment in state.fragments: + if fragment.role == "assistant" and fragment.kind == "text": + print(fragment.content_text or fragment.text_preview) + break + finally: + client.destroy(machine_id) + client.close() + + +if __name__ == "__main__": + main() diff --git a/sdks/python/examples/math_mcp_server.py b/sdks/python/examples/math_mcp_server.py new file mode 100644 index 00000000..ab02dcf9 --- /dev/null +++ b/sdks/python/examples/math_mcp_server.py @@ -0,0 +1,110 @@ +import json +import sys + + +def write_response(request_id, result): + sys.stdout.write( + json.dumps({"jsonrpc": "2.0", "id": request_id, "result": result}) + "\n" + ) + sys.stdout.flush() + + +def write_error(request_id, code, message): + sys.stdout.write( + json.dumps( + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": code, "message": message}, + } + ) + + "\n" + ) + sys.stdout.flush() + + +def tool_definitions(): + number_schema = {"type": "number"} + return [ + { + "name": "add", + "description": "Add two numbers and return the exact result.", + "inputSchema": { + "type": "object", + "properties": {"left": number_schema, "right": number_schema}, + "required": ["left", "right"], + }, + }, + { + "name": "multiply", + "description": "Multiply two numbers and return the exact result.", + "inputSchema": { + "type": "object", + "properties": {"left": number_schema, "right": number_schema}, + "required": ["left", "right"], + }, + }, + ] + + +def call_tool(name, arguments): + left = arguments.get("left") + right = arguments.get("right") + if not isinstance(left, (int, float)) or not isinstance(right, (int, float)): + raise ValueError("left and right must be numbers") + + if name == "add": + result = left + right + elif name == "multiply": + result = left * right + else: + raise ValueError(f"unknown tool: {name}") + + return { + "content": [{"type": "text", "text": str(result)}], + "structuredContent": {"result": result}, + } + + +def handle(request): + method = request.get("method") + request_id = request.get("id") + + if method == "initialize": + write_response( + request_id, + { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "math", "version": "0.1.0"}, + }, + ) + elif method == "notifications/initialized": + return + elif method == "tools/list": + write_response(request_id, {"tools": tool_definitions()}) + elif method == "tools/call": + params = request.get("params") or {} + try: + write_response( + request_id, call_tool(params.get("name"), params.get("arguments") or {}) + ) + except ValueError as error: + write_error(request_id, -32602, str(error)) + else: + write_error(request_id, -32601, f"unknown method: {method}") + + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + handle(json.loads(line)) + except json.JSONDecodeError as error: + write_error(None, -32700, str(error)) + + +if __name__ == "__main__": + main() diff --git a/sdks/python/examples/mcp_math.py b/sdks/python/examples/mcp_math.py new file mode 100644 index 00000000..86308110 --- /dev/null +++ b/sdks/python/examples/mcp_math.py @@ -0,0 +1,100 @@ +import os +import sys +from pathlib import Path + +from rcm import ActionCommand, FragmentContent, McpServer, Model, RCMClient, ReactPolicy + +MODEL_NAME = "deepseek-v4-flash" +SYSTEM_PROMPT = ( + "Solve the user's arithmetic request by calling the available math MCP tools. " + "Use tools for arithmetic instead of mental calculation. Return only the final answer." +) + + +def pick_label(actions, label: str): + for action in actions.actions: + if action.label == label: + return action.command + raise RuntimeError(f"action not available: {label}") + + +def run_loop(client, machine_id, state, actions, max_steps: int = 12): + policy = ReactPolicy() + for step in range(max_steps): + command, label = policy(state, actions) + print(f"[{step + 1}] {label}") + state, actions = client.step(machine_id, command) + if state.fragments: + fragment = state.fragments[-1] + print( + f" → [{fragment.role}/{fragment.kind}] {fragment.text_preview[:120]}" + ) + if state.done: + break + return state, actions + + +def main(): + if "DEEPSEEK_API_KEY" not in os.environ: + raise RuntimeError("DEEPSEEK_API_KEY is not set") + + question = " ".join(sys.argv[1:]) or "What is 23 multiplied by 19, plus 7?" + server_path = Path(__file__).with_name("math_mcp_server.py") + client = RCMClient("localhost:50051") + + machine_id, state, actions = client.open( + purpose=question, + model_definitions=[ + Model( + name=MODEL_NAME, + protocol="openai", + endpoint="https://api.deepseek.com", + credentials=Model.Credentials(env="DEEPSEEK_API_KEY"), + limit=Model.Limit(context=64_000, output=2_000), + modalities_input=["text"], + modalities_output=["text"], + ) + ], + models=[MODEL_NAME], + mcp_definitions=[ + McpServer( + label="math", + transport=McpServer.Stdio( + command=sys.executable, + args=[str(server_path)], + ), + ) + ], + mcps=["math"], + prompts={"math": SYSTEM_PROMPT}, + ) + + try: + state, actions = client.step(machine_id, pick_label(actions, "Append math")) + state, actions = client.step( + machine_id, + ActionCommand( + verb="Append", + fragment=FragmentContent(role="user", text=question, tag="purpose"), + ), + ) + state, actions = client.step( + machine_id, ActionCommand(verb="Model", name=MODEL_NAME) + ) + for tool_name in state.available_tools: + state, actions = client.step( + machine_id, ActionCommand(verb="Activate", name=tool_name) + ) + + state, _ = run_loop(client, machine_id, state, actions) + + for fragment in state.fragments: + if fragment.role == "assistant" and fragment.kind == "text": + print(fragment.content_text or fragment.text_preview) + finally: + client.destroy(machine_id) + client.close() + + +if __name__ == "__main__": + main() diff --git a/sdks/python/generate.sh b/sdks/python/generate.sh new file mode 100755 index 00000000..3beaa374 --- /dev/null +++ b/sdks/python/generate.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Regenerate Python gRPC stubs from proto/rcm.proto. +# Run from sdks/python/: +# bash generate.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROTO="$SCRIPT_DIR/../../proto/rcm.proto" + +PROTOBUF_INCLUDE=$(python -c ' +import grpc_tools.protoc, inspect, os +base = os.path.dirname(inspect.getfile(grpc_tools.protoc)) +for d in [os.path.join(base, "_proto"), os.path.join(base, "google")]: + if os.path.isdir(d): + print(os.path.dirname(d) if d.endswith("google") else d) + exit(0) +import google.protobuf +print(os.path.dirname(os.path.dirname(google.protobuf.__file__))) +') + +cd "$SCRIPT_DIR" + +python -m grpc_tools.protoc \ + -I "$(dirname "$PROTO")" \ + -I "$PROTOBUF_INCLUDE" \ + --python_out=src/rcm \ + --grpc_python_out=src/rcm \ + "$PROTO" + +mv src/rcm/rcm_pb2.py src/rcm/_pb2.py +mv src/rcm/rcm_pb2_grpc.py src/rcm/_pb2_grpc.py +sed -i '' 's/^import rcm_pb2 as rcm__pb2$/from . import _pb2 as rcm__pb2/' src/rcm/_pb2_grpc.py + +echo "Generated src/rcm/_pb2.py and src/rcm/_pb2_grpc.py" diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml new file mode 100644 index 00000000..c2b7c276 --- /dev/null +++ b/sdks/python/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "rcm" +version = "0.1.0" +description = "RCM (Recursive Context Machine) Python SDK" +requires-python = ">=3.10" +dependencies = [ + "grpcio>=1.60", + "protobuf>=4.20", +] + +[project.optional-dependencies] +# Codegen extra: regenerates src/rcm/_pb2*.py from proto/rcm.proto via +# `bash generate.sh`. Runtime installs do not need these. +codegen = [ + "grpcio-tools>=1.60", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/rcm"] diff --git a/sdks/python/src/rcm/__init__.py b/sdks/python/src/rcm/__init__.py new file mode 100644 index 00000000..66f37cb8 --- /dev/null +++ b/sdks/python/src/rcm/__init__.py @@ -0,0 +1,16 @@ +"""Python SDK for controlling RCM over gRPC.""" + +from ._pb2 import ActionCommand, FragmentContent +from .client import RCMClient +from .mcp import McpServer +from .model import Model +from .react import ReactPolicy + +__all__ = [ + "ActionCommand", + "FragmentContent", + "McpServer", + "Model", + "RCMClient", + "ReactPolicy", +] diff --git a/sdks/python/src/rcm/_pb2.py b/sdks/python/src/rcm/_pb2.py new file mode 100644 index 00000000..92a8e9e6 --- /dev/null +++ b/sdks/python/src/rcm/_pb2.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: rcm.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'rcm.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\trcm.proto\x12\x03rcm\x1a\x1bgoogle/protobuf/empty.proto\"\xba\x02\n\x0bOpenRequest\x12\x0f\n\x07purpose\x18\x01 \x01(\t\x12)\n\x11model_definitions\x18\x02 \x03(\x0b\x32\x0e.rcm.ModelSpec\x12+\n\x0fmcp_definitions\x18\x03 \x03(\x0b\x32\x12.rcm.McpServerSpec\x12\r\n\x05tools\x18\x04 \x03(\t\x12.\n\x07prompts\x18\x05 \x03(\x0b\x32\x1d.rcm.OpenRequest.PromptsEntry\x12\x0e\n\x06models\x18\x06 \x03(\t\x12\x0c\n\x04mcps\x18\x07 \x03(\t\x12\x13\n\x0b\x65nvironment\x18\x08 \x01(\t\x12\x14\n\x07run_dir\x18\t \x01(\tH\x00\x88\x01\x01\x1a.\n\x0cPromptsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\n\n\x08_run_dir\"\xd8\x03\n\tModelSpec\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08protocol\x18\x02 \x01(\t\x12\x15\n\x08\x65ndpoint\x18\x03 \x01(\tH\x00\x88\x01\x01\x12-\n\x0b\x63redentials\x18\x04 \x01(\x0b\x32\x13.rcm.CredentialSpecH\x01\x88\x01\x01\x12\"\n\x05limit\x18\x05 \x01(\x0b\x32\x0e.rcm.LimitSpecH\x02\x88\x01\x01\x12\x18\n\x10modalities_input\x18\x06 \x03(\t\x12\x19\n\x11modalities_output\x18\x07 \x03(\t\x12,\n\x07headers\x18\x08 \x03(\x0b\x32\x1b.rcm.ModelSpec.HeadersEntry\x12\x14\n\x07timeout\x18\t \x01(\x04H\x03\x88\x01\x01\x12 \n\x04\x63ost\x18\n \x01(\x0b\x32\r.rcm.CostSpecH\x04\x88\x01\x01\x12\x10\n\x08thinking\x18\x0b \x01(\x08\x12\x18\n\x0btemperature\x18\x0c \x01(\x01H\x05\x88\x01\x01\x1a.\n\x0cHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x0b\n\t_endpointB\x0e\n\x0c_credentialsB\x08\n\x06_limitB\n\n\x08_timeoutB\x07\n\x05_costB\x0e\n\x0c_temperature\"{\n\x08\x43ostSpec\x12\r\n\x05input\x18\x01 \x01(\x01\x12\x0e\n\x06output\x18\x02 \x01(\x01\x12\x17\n\ncache_read\x18\x03 \x01(\x01H\x00\x88\x01\x01\x12\x18\n\x0b\x63\x61\x63he_write\x18\x04 \x01(\x01H\x01\x88\x01\x01\x42\r\n\x0b_cache_readB\x0e\n\x0c_cache_write\"<\n\x0e\x43redentialSpec\x12\r\n\x03\x65nv\x18\x01 \x01(\tH\x00\x12\x11\n\x07literal\x18\x02 \x01(\tH\x00\x42\x08\n\x06source\"J\n\tLimitSpec\x12\x0f\n\x07\x63ontext\x18\x01 \x01(\x04\x12\x12\n\x05input\x18\x02 \x01(\x04H\x00\x88\x01\x01\x12\x0e\n\x06output\x18\x03 \x01(\x04\x42\x08\n\x06_input\"H\n\rMcpServerSpec\x12\r\n\x05label\x18\x01 \x01(\t\x12(\n\ttransport\x18\x02 \x01(\x0b\x32\x15.rcm.McpTransportSpec\"\x80\x01\n\x10McpTransportSpec\x12\"\n\x05stdio\x18\x01 \x01(\x0b\x32\x11.rcm.McpStdioSpecH\x00\x12 \n\x04http\x18\x02 \x01(\x0b\x32\x10.rcm.McpHttpSpecH\x00\x12\x1e\n\x03sse\x18\x03 \x01(\x0b\x32\x0f.rcm.McpSseSpecH\x00\x42\x06\n\x04kind\"\xaf\x01\n\x0cMcpStdioSpec\x12\x0f\n\x07\x63ommand\x18\x01 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x02 \x03(\t\x12\'\n\x03\x65nv\x18\x03 \x03(\x0b\x32\x1a.rcm.McpStdioSpec.EnvEntry\x12\x10\n\x03\x63wd\x18\x04 \x01(\tH\x00\x88\x01\x01\x1a=\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.rcm.McpValueSpec:\x02\x38\x01\x42\x06\n\x04_cwd\"\x8d\x01\n\x0bMcpHttpSpec\x12\x0b\n\x03url\x18\x01 \x01(\t\x12.\n\x07headers\x18\x02 \x03(\x0b\x32\x1d.rcm.McpHttpSpec.HeadersEntry\x1a\x41\n\x0cHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.rcm.McpValueSpec:\x02\x38\x01\"\x8b\x01\n\nMcpSseSpec\x12\x0b\n\x03url\x18\x01 \x01(\t\x12-\n\x07headers\x18\x02 \x03(\x0b\x32\x1c.rcm.McpSseSpec.HeadersEntry\x1a\x41\n\x0cHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.rcm.McpValueSpec:\x02\x38\x01\":\n\x0cMcpValueSpec\x12\x11\n\x07literal\x18\x01 \x01(\tH\x00\x12\r\n\x03\x65nv\x18\x02 \x01(\tH\x00\x42\x08\n\x06source\"e\n\x0cOpenResponse\x12\x12\n\nmachine_id\x18\x01 \x01(\t\x12\x19\n\x05state\x18\x02 \x01(\x0b\x32\n.rcm.State\x12&\n\x0c\x61\x63tion_space\x18\x03 \x01(\x0b\x32\x10.rcm.ActionSpace\"F\n\x0bStepRequest\x12\x12\n\nmachine_id\x18\x01 \x01(\t\x12#\n\x07\x63ommand\x18\x02 \x01(\x0b\x32\x12.rcm.ActionCommand\"Q\n\x0cStepResponse\x12\x19\n\x05state\x18\x01 \x01(\x0b\x32\n.rcm.State\x12&\n\x0c\x61\x63tion_space\x18\x02 \x01(\x0b\x32\x10.rcm.ActionSpace\"$\n\x0e\x44\x65stroyRequest\x12\x12\n\nmachine_id\x18\x01 \x01(\t\"\xc9\x01\n\rActionCommand\x12\x0c\n\x04verb\x18\x01 \x01(\t\x12\x18\n\x0b\x66ragment_id\x18\x02 \x01(\x04H\x00\x88\x01\x01\x12\x19\n\x0c\x66ragment_id2\x18\x03 \x01(\x04H\x01\x88\x01\x01\x12+\n\x08\x66ragment\x18\x04 \x01(\x0b\x32\x14.rcm.FragmentContentH\x02\x88\x01\x01\x12\x11\n\x04name\x18\x05 \x01(\tH\x03\x88\x01\x01\x42\x0e\n\x0c_fragment_idB\x0f\n\r_fragment_id2B\x0b\n\t_fragmentB\x07\n\x05_name\"\x84\x01\n\x0bMediaSource\x12\r\n\x03url\x18\x01 \x01(\tH\x00\x12\x10\n\x06\x62\x61se64\x18\x02 \x01(\tH\x00\x12\x17\n\nmedia_type\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08\x61lt_text\x18\x04 \x01(\tH\x02\x88\x01\x01\x42\x08\n\x06sourceB\r\n\x0b_media_typeB\x0b\n\t_alt_text\"\x93\x01\n\x0f\x46ragmentContent\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x0c\n\x04text\x18\x02 \x01(\t\x12\x10\n\x03tag\x18\x03 \x01(\tH\x00\x88\x01\x01\x12\x0c\n\x04kind\x18\x04 \x01(\t\x12+\n\x0cmedia_source\x18\x05 \x01(\x0b\x32\x10.rcm.MediaSourceH\x01\x88\x01\x01\x42\x06\n\x04_tagB\x0f\n\r_media_source\"/\n\x0b\x41\x63tionSpace\x12 \n\x07\x61\x63tions\x18\x01 \x03(\x0b\x32\x0f.rcm.ActionItem\"d\n\nActionItem\x12#\n\x07\x63ommand\x18\x01 \x01(\x0b\x32\x12.rcm.ActionCommand\x12\r\n\x05label\x18\x02 \x01(\t\x12\"\n\x04sink\x18\x03 \x01(\x0b\x32\x14.rcm.FragmentContent\"\x81\x05\n\x05State\x12\x0f\n\x07purpose\x18\x01 \x01(\t\x12\x12\n\nmachine_id\x18\n \x01(\t\x12 \n\tfragments\x18\x02 \x03(\x0b\x32\r.rcm.Fragment\x12\x0f\n\x07workdir\x18\x03 \x01(\t\x12)\n\x08\x65nv_vars\x18\x04 \x03(\x0b\x32\x17.rcm.State.EnvVarsEntry\x12\x14\n\x0c\x61\x63tive_model\x18\x05 \x01(\t\x12\x14\n\x0c\x61\x63tive_tools\x18\x06 \x03(\t\x12\x18\n\x10\x61vailable_models\x18\x07 \x03(\t\x12\x17\n\x0f\x61vailable_tools\x18\x08 \x03(\t\x12\x0c\n\x04\x64one\x18\t \x01(\x08\x12\x15\n\rinbox_pending\x18\x0b \x01(\x08\x12&\n\ninbox_peek\x18\x0c \x01(\x0b\x32\r.rcm.FragmentH\x00\x88\x01\x01\x12&\n\x06\x63ounts\x18\r \x03(\x0b\x32\x16.rcm.State.CountsEntry\x12\x1a\n\x06usages\x18\x0e \x03(\x0b\x32\n.rcm.Usage\x12\'\n\rtool_profiles\x18\x0f \x03(\x0b\x32\x10.rcm.ToolProfile\x12)\n\x0emodel_profiles\x18\x10 \x03(\x0b\x32\x11.rcm.ModelProfile\x12\x10\n\x08platform\x18\x11 \x01(\t\x12\x11\n\x04root\x18\x12 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x03obs\x18\x13 \x01(\x0b\x32\x08.rcm.Obs\x1a.\n\x0c\x45nvVarsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a-\n\x0b\x43ountsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x42\r\n\x0b_inbox_peekB\x07\n\x05_root\"\"\n\x03Obs\x12\x1b\n\x06\x62udget\x18\x01 \x01(\x0b\x32\x0b.rcm.Budget\"\x98\x01\n\x06\x42udget\x12\x15\n\rcontext_limit\x18\x01 \x01(\x04\x12\x17\n\x0f\x65stimated_input\x18\x02 \x01(\x04\x12\x16\n\x0esoft_threshold\x18\x03 \x01(\x04\x12\x10\n\x08headroom\x18\x04 \x01(\x04\x12\x1e\n\x11last_actual_input\x18\x05 \x01(\x04H\x00\x88\x01\x01\x42\x14\n\x12_last_actual_input\"\xa2\x01\n\x05Usage\x12\x14\n\x0cinput_tokens\x18\x01 \x01(\x04\x12\x15\n\routput_tokens\x18\x02 \x01(\x04\x12\x14\n\x0ctotal_tokens\x18\x03 \x01(\x04\x12\x1b\n\x13\x63\x61\x63hed_input_tokens\x18\x04 \x01(\x04\x12#\n\x1b\x63\x61\x63he_creation_input_tokens\x18\x05 \x01(\x04\x12\x14\n\x0c\x66ragment_ids\x18\x06 \x03(\x04\"\xcc\x01\n\x08\x46ragment\x12\n\n\x02id\x18\x01 \x01(\x04\x12\x0c\n\x04role\x18\x02 \x01(\t\x12\x0c\n\x04kind\x18\x03 \x01(\t\x12\x14\n\x0ctext_preview\x18\x04 \x01(\t\x12\x10\n\x03tag\x18\x05 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0c\x63ontent_text\x18\x06 \x01(\tH\x01\x88\x01\x01\x12+\n\x0cmedia_source\x18\x07 \x01(\x0b\x32\x10.rcm.MediaSourceH\x02\x88\x01\x01\x42\x06\n\x04_tagB\x0f\n\r_content_textB\x0f\n\r_media_source\"@\n\x0bToolProfile\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x0e\n\x06\x61\x63tive\x18\x03 \x01(\x08\"l\n\x0cModelProfile\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08protocol\x18\x02 \x01(\t\x12\"\n\x05limit\x18\x03 \x01(\x0b\x32\x0e.rcm.LimitSpecH\x00\x88\x01\x01\x12\x0e\n\x06\x61\x63tive\x18\x04 \x01(\x08\x42\x08\n\x06_limit2\x97\x01\n\x03RCM\x12+\n\x04Open\x12\x10.rcm.OpenRequest\x1a\x11.rcm.OpenResponse\x12+\n\x04Step\x12\x10.rcm.StepRequest\x1a\x11.rcm.StepResponse\x12\x36\n\x07\x44\x65stroy\x12\x13.rcm.DestroyRequest\x1a\x16.google.protobuf.Emptyb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'rcm_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_OPENREQUEST_PROMPTSENTRY']._loaded_options = None + _globals['_OPENREQUEST_PROMPTSENTRY']._serialized_options = b'8\001' + _globals['_MODELSPEC_HEADERSENTRY']._loaded_options = None + _globals['_MODELSPEC_HEADERSENTRY']._serialized_options = b'8\001' + _globals['_MCPSTDIOSPEC_ENVENTRY']._loaded_options = None + _globals['_MCPSTDIOSPEC_ENVENTRY']._serialized_options = b'8\001' + _globals['_MCPHTTPSPEC_HEADERSENTRY']._loaded_options = None + _globals['_MCPHTTPSPEC_HEADERSENTRY']._serialized_options = b'8\001' + _globals['_MCPSSESPEC_HEADERSENTRY']._loaded_options = None + _globals['_MCPSSESPEC_HEADERSENTRY']._serialized_options = b'8\001' + _globals['_STATE_ENVVARSENTRY']._loaded_options = None + _globals['_STATE_ENVVARSENTRY']._serialized_options = b'8\001' + _globals['_STATE_COUNTSENTRY']._loaded_options = None + _globals['_STATE_COUNTSENTRY']._serialized_options = b'8\001' + _globals['_OPENREQUEST']._serialized_start=48 + _globals['_OPENREQUEST']._serialized_end=362 + _globals['_OPENREQUEST_PROMPTSENTRY']._serialized_start=304 + _globals['_OPENREQUEST_PROMPTSENTRY']._serialized_end=350 + _globals['_MODELSPEC']._serialized_start=365 + _globals['_MODELSPEC']._serialized_end=837 + _globals['_MODELSPEC_HEADERSENTRY']._serialized_start=715 + _globals['_MODELSPEC_HEADERSENTRY']._serialized_end=761 + _globals['_COSTSPEC']._serialized_start=839 + _globals['_COSTSPEC']._serialized_end=962 + _globals['_CREDENTIALSPEC']._serialized_start=964 + _globals['_CREDENTIALSPEC']._serialized_end=1024 + _globals['_LIMITSPEC']._serialized_start=1026 + _globals['_LIMITSPEC']._serialized_end=1100 + _globals['_MCPSERVERSPEC']._serialized_start=1102 + _globals['_MCPSERVERSPEC']._serialized_end=1174 + _globals['_MCPTRANSPORTSPEC']._serialized_start=1177 + _globals['_MCPTRANSPORTSPEC']._serialized_end=1305 + _globals['_MCPSTDIOSPEC']._serialized_start=1308 + _globals['_MCPSTDIOSPEC']._serialized_end=1483 + _globals['_MCPSTDIOSPEC_ENVENTRY']._serialized_start=1414 + _globals['_MCPSTDIOSPEC_ENVENTRY']._serialized_end=1475 + _globals['_MCPHTTPSPEC']._serialized_start=1486 + _globals['_MCPHTTPSPEC']._serialized_end=1627 + _globals['_MCPHTTPSPEC_HEADERSENTRY']._serialized_start=1562 + _globals['_MCPHTTPSPEC_HEADERSENTRY']._serialized_end=1627 + _globals['_MCPSSESPEC']._serialized_start=1630 + _globals['_MCPSSESPEC']._serialized_end=1769 + _globals['_MCPSSESPEC_HEADERSENTRY']._serialized_start=1562 + _globals['_MCPSSESPEC_HEADERSENTRY']._serialized_end=1627 + _globals['_MCPVALUESPEC']._serialized_start=1771 + _globals['_MCPVALUESPEC']._serialized_end=1829 + _globals['_OPENRESPONSE']._serialized_start=1831 + _globals['_OPENRESPONSE']._serialized_end=1932 + _globals['_STEPREQUEST']._serialized_start=1934 + _globals['_STEPREQUEST']._serialized_end=2004 + _globals['_STEPRESPONSE']._serialized_start=2006 + _globals['_STEPRESPONSE']._serialized_end=2087 + _globals['_DESTROYREQUEST']._serialized_start=2089 + _globals['_DESTROYREQUEST']._serialized_end=2125 + _globals['_ACTIONCOMMAND']._serialized_start=2128 + _globals['_ACTIONCOMMAND']._serialized_end=2329 + _globals['_MEDIASOURCE']._serialized_start=2332 + _globals['_MEDIASOURCE']._serialized_end=2464 + _globals['_FRAGMENTCONTENT']._serialized_start=2467 + _globals['_FRAGMENTCONTENT']._serialized_end=2614 + _globals['_ACTIONSPACE']._serialized_start=2616 + _globals['_ACTIONSPACE']._serialized_end=2663 + _globals['_ACTIONITEM']._serialized_start=2665 + _globals['_ACTIONITEM']._serialized_end=2765 + _globals['_STATE']._serialized_start=2768 + _globals['_STATE']._serialized_end=3409 + _globals['_STATE_ENVVARSENTRY']._serialized_start=3292 + _globals['_STATE_ENVVARSENTRY']._serialized_end=3338 + _globals['_STATE_COUNTSENTRY']._serialized_start=3340 + _globals['_STATE_COUNTSENTRY']._serialized_end=3385 + _globals['_OBS']._serialized_start=3411 + _globals['_OBS']._serialized_end=3445 + _globals['_BUDGET']._serialized_start=3448 + _globals['_BUDGET']._serialized_end=3600 + _globals['_USAGE']._serialized_start=3603 + _globals['_USAGE']._serialized_end=3765 + _globals['_FRAGMENT']._serialized_start=3768 + _globals['_FRAGMENT']._serialized_end=3972 + _globals['_TOOLPROFILE']._serialized_start=3974 + _globals['_TOOLPROFILE']._serialized_end=4038 + _globals['_MODELPROFILE']._serialized_start=4040 + _globals['_MODELPROFILE']._serialized_end=4148 + _globals['_RCM']._serialized_start=4151 + _globals['_RCM']._serialized_end=4302 +# @@protoc_insertion_point(module_scope) diff --git a/sdks/python/src/rcm/_pb2_grpc.py b/sdks/python/src/rcm/_pb2_grpc.py new file mode 100644 index 00000000..9f89ce90 --- /dev/null +++ b/sdks/python/src/rcm/_pb2_grpc.py @@ -0,0 +1,184 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from . import _pb2 as rcm__pb2 + +GRPC_GENERATED_VERSION = '1.80.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in rcm_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class RCMStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Open = channel.unary_unary( + '/rcm.RCM/Open', + request_serializer=rcm__pb2.OpenRequest.SerializeToString, + response_deserializer=rcm__pb2.OpenResponse.FromString, + _registered_method=True) + self.Step = channel.unary_unary( + '/rcm.RCM/Step', + request_serializer=rcm__pb2.StepRequest.SerializeToString, + response_deserializer=rcm__pb2.StepResponse.FromString, + _registered_method=True) + self.Destroy = channel.unary_unary( + '/rcm.RCM/Destroy', + request_serializer=rcm__pb2.DestroyRequest.SerializeToString, + response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + _registered_method=True) + + +class RCMServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Open(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Step(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Destroy(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_RCMServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Open': grpc.unary_unary_rpc_method_handler( + servicer.Open, + request_deserializer=rcm__pb2.OpenRequest.FromString, + response_serializer=rcm__pb2.OpenResponse.SerializeToString, + ), + 'Step': grpc.unary_unary_rpc_method_handler( + servicer.Step, + request_deserializer=rcm__pb2.StepRequest.FromString, + response_serializer=rcm__pb2.StepResponse.SerializeToString, + ), + 'Destroy': grpc.unary_unary_rpc_method_handler( + servicer.Destroy, + request_deserializer=rcm__pb2.DestroyRequest.FromString, + response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'rcm.RCM', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('rcm.RCM', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class RCM(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Open(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/rcm.RCM/Open', + rcm__pb2.OpenRequest.SerializeToString, + rcm__pb2.OpenResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def Step(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/rcm.RCM/Step', + rcm__pb2.StepRequest.SerializeToString, + rcm__pb2.StepResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def Destroy(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/rcm.RCM/Destroy', + rcm__pb2.DestroyRequest.SerializeToString, + google_dot_protobuf_dot_empty__pb2.Empty.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/sdks/python/src/rcm/client.py b/sdks/python/src/rcm/client.py new file mode 100644 index 00000000..6ca48436 --- /dev/null +++ b/sdks/python/src/rcm/client.py @@ -0,0 +1,52 @@ +"""Thin Python wrapper over the RCM gRPC service.""" + +import grpc + +from ._pb2 import DestroyRequest, OpenRequest, StepRequest +from ._pb2_grpc import RCMStub +from .mcp import McpServer +from .model import Model + + +class RCMClient: + def __init__(self, endpoint: str = "localhost:50051"): + self._channel = grpc.insecure_channel(endpoint) + self._stub = RCMStub(self._channel) + + def open( + self, + purpose: str, + *, + model_definitions: list[Model] | None = None, + models: list[str] | None = None, + mcp_definitions: list[McpServer] | None = None, + mcps: list[str] | None = None, + tools: list[str] | None = None, + prompts: dict[str, str] | None = None, + environment: str | None = None, + ): + request = OpenRequest( + purpose=purpose, + model_definitions=[ + model._to_proto() for model in (model_definitions or []) + ], + mcp_definitions=[server._to_proto() for server in (mcp_definitions or [])], + models=models or [], + mcps=mcps or [], + tools=tools or [], + prompts=prompts or {}, + ) + if environment is not None: + request.environment = environment + response = self._stub.Open(request) + return response.machine_id, response.state, response.action_space + + def step(self, machine_id: str, command): + response = self._stub.Step(StepRequest(machine_id=machine_id, command=command)) + return response.state, response.action_space + + def destroy(self, machine_id: str): + self._stub.Destroy(DestroyRequest(machine_id=machine_id)) + + def close(self): + self._channel.close() diff --git a/sdks/python/src/rcm/mcp.py b/sdks/python/src/rcm/mcp.py new file mode 100644 index 00000000..42b9a906 --- /dev/null +++ b/sdks/python/src/rcm/mcp.py @@ -0,0 +1,95 @@ +"""MCP server configuration for the RCM gRPC SDK. + +Mirrors the .rcm language `mcp` block. +""" + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class McpServer: + label: str + transport: "McpServer.Stdio | McpServer.Http | McpServer.Sse" + + @staticmethod + def Env(name: str): + return McpServer.Value(env=name) + + @dataclass + class Value: + literal: Optional[str] = None + env: Optional[str] = None + + @dataclass + class Stdio: + command: str + args: list[str] = field(default_factory=list) + env: dict[str, "McpServer.Value"] = field(default_factory=dict) + cwd: Optional[str] = None + + @dataclass + class Http: + url: str + headers: dict[str, "McpServer.Value"] = field(default_factory=dict) + + @dataclass + class Sse: + url: str + headers: dict[str, "McpServer.Value"] = field(default_factory=dict) + + def _to_proto(self): + from ._pb2 import ( + McpHttpSpec, + McpServerSpec, + McpSseSpec, + McpStdioSpec, + McpTransportSpec, + McpValueSpec, + ) + + def _value_proto(value: "McpServer.Value"): + if value.env is not None: + return McpValueSpec(env=value.env) + if value.literal is not None: + return McpValueSpec(literal=value.literal) + return McpValueSpec() + + spec = McpServerSpec(label=self.label) + transport = self.transport + + if isinstance(transport, McpServer.Stdio): + spec.transport.CopyFrom( + McpTransportSpec( + stdio=McpStdioSpec( + command=transport.command, + args=transport.args, + env={k: _value_proto(v) for k, v in transport.env.items()}, + cwd=transport.cwd, + ) + ) + ) + elif isinstance(transport, McpServer.Http): + spec.transport.CopyFrom( + McpTransportSpec( + http=McpHttpSpec( + url=transport.url, + headers={ + k: _value_proto(v) for k, v in transport.headers.items() + }, + ) + ) + ) + elif isinstance(transport, McpServer.Sse): + spec.transport.CopyFrom( + McpTransportSpec( + sse=McpSseSpec( + url=transport.url, + headers={ + k: _value_proto(v) for k, v in transport.headers.items() + }, + ) + ) + ) + + return spec diff --git a/sdks/python/src/rcm/model.py b/sdks/python/src/rcm/model.py new file mode 100644 index 00000000..cb137cd6 --- /dev/null +++ b/sdks/python/src/rcm/model.py @@ -0,0 +1,86 @@ +"""Model configuration for the RCM gRPC SDK. + +Mirrors the .rcm language `model` block: + + model deepseek-v4-flash { + protocol = "openai" + endpoint = "https://api.deepseek.com" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "1000000", output = "393216" } + } + +Python equivalent: + from rcm import Model + + model = Model( + name="deepseek-v4-flash", + protocol="openai", + endpoint="https://api.deepseek.com", + credentials=Model.Credentials(env="DEEPSEEK_API_KEY"), + limit=Model.Limit(context=1000000, output=393216), + ) +""" + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class Model: + """LLM provider configuration.""" + + name: str + protocol: str = "openai" + endpoint: Optional[str] = None + timeout: Optional[int] = None + + credentials: Optional["Model.Credentials"] = None + limit: Optional["Model.Limit"] = None + + modalities_input: list[str] = field(default_factory=list) + modalities_output: list[str] = field(default_factory=list) + headers: dict[str, str] = field(default_factory=dict) + + @dataclass + class Credentials: + env: Optional[str] = None + literal: Optional[str] = None + + def __post_init__(self): + if self.env is not None and self.literal is not None: + raise ValueError("env and literal are mutually exclusive") + + @dataclass + class Limit: + context: int + output: int + input: Optional[int] = None + + def _to_proto(self): + from ._pb2 import ModelSpec, CredentialSpec, LimitSpec + + spec = ModelSpec( + name=self.name, + protocol=self.protocol, + endpoint=self.endpoint, + timeout=self.timeout, + modalities_input=self.modalities_input, + modalities_output=self.modalities_output, + headers=self.headers, + ) + if self.credentials is not None: + if self.credentials.env: + spec.credentials.CopyFrom(CredentialSpec(env=self.credentials.env)) + elif self.credentials.literal: + spec.credentials.CopyFrom( + CredentialSpec(literal=self.credentials.literal) + ) + if self.limit is not None: + spec.limit.CopyFrom( + LimitSpec( + context=self.limit.context, + output=self.limit.output, + input=self.limit.input, + ) + ) + return spec diff --git a/sdks/python/src/rcm/react.py b/sdks/python/src/rcm/react.py new file mode 100644 index 00000000..1109f284 --- /dev/null +++ b/sdks/python/src/rcm/react.py @@ -0,0 +1,57 @@ +"""Small ReAct controller for external Python RCM loops.""" + +from dataclasses import dataclass + + +@dataclass +class ReactPolicy: + max_hitch_retries: int = 3 + hitch_retries: int = 0 + + def __call__(self, state, action_space): + actions = list(action_space.actions) + + if state.inbox_pending: + return self._pick(actions, "Take") + + last_fragment = state.fragments[-1] if state.fragments else None + + if last_fragment is not None and last_fragment.kind == "hitch": + if self.hitch_retries < self.max_hitch_retries: + self.hitch_retries += 1 + return self._pick(actions, "Halt") + return self._pick(actions, "Done") + + self.hitch_retries = 0 + + if self._should_call_model(last_fragment): + return self._pick(actions, "Halt") + + if last_fragment is not None and self._last_was_tool_result(last_fragment): + return self._pick(actions, "Halt") + + if self._has(actions, "Done"): + return self._pick(actions, "Done") + + return self._pick(actions, "Halt") + + @staticmethod + def _should_call_model(fragment) -> bool: + if fragment is None: + return False + return fragment.role in {"user", "system"} and fragment.kind == "text" + + @staticmethod + def _last_was_tool_result(fragment) -> bool: + return fragment.role == "tool" or fragment.kind == "tool_result" + + @staticmethod + def _has(actions, verb: str) -> bool: + return any(action.command and action.command.verb == verb for action in actions) + + @staticmethod + def _pick(actions, verb: str): + for action in actions: + if action.command and action.command.verb == verb: + return action.command, action.label + raise ValueError(f"no {verb} action available") diff --git a/sdks/python/tests/test_react_policy.py b/sdks/python/tests/test_react_policy.py new file mode 100644 index 00000000..b2412f86 --- /dev/null +++ b/sdks/python/tests/test_react_policy.py @@ -0,0 +1,85 @@ +from rcm._pb2 import ActionCommand, ActionItem, ActionSpace, Fragment +from rcm.react import ReactPolicy + + +def _make_state(fragments: list, inbox_pending: bool = False): + return type( + "FakeState", + (), + { + "fragments": fragments, + "inbox_pending": inbox_pending, + }, + )() + + +def _make_action_space(*verbs: str) -> ActionSpace: + return ActionSpace( + actions=[ + ActionItem(command=ActionCommand(verb=verb), label=verb) for verb in verbs + ] + ) + + +def _make_fragment(role: str, kind: str = "text") -> Fragment: + return Fragment(id=1, role=role, kind=kind, text_preview="content", tag=kind) + + +def test_inbox_pending_takes(): + policy = ReactPolicy() + state = _make_state([], inbox_pending=True) + actions = _make_action_space("Take", "Halt", "Done") + command, _ = policy(state, actions) + assert command.verb == "Take" + + +def test_user_fragment_halts_for_model_response(): + policy = ReactPolicy() + state = _make_state([_make_fragment("user")]) + actions = _make_action_space("Halt", "Done") + command, _ = policy(state, actions) + assert command.verb == "Halt" + + +def test_last_tool_result_halts_for_model_response(): + policy = ReactPolicy() + state = _make_state([_make_fragment("tool", "tool_result")]) + actions = _make_action_space("Halt", "Done") + command, _ = policy(state, actions) + assert command.verb == "Halt" + + +def test_idle_after_assistant_dones(): + policy = ReactPolicy() + state = _make_state([_make_fragment("assistant")]) + actions = _make_action_space("Halt", "Done") + command, _ = policy(state, actions) + assert command.verb == "Done" + + +def test_idle_with_empty_fragments_dones(): + policy = ReactPolicy() + state = _make_state([], inbox_pending=False) + actions = _make_action_space("Halt", "Done") + command, _ = policy(state, actions) + assert command.verb == "Done" + + +def test_hitch_retries_then_dones(): + policy = ReactPolicy(max_hitch_retries=1) + state = _make_state([_make_fragment("system", "hitch")]) + actions = _make_action_space("Halt", "Done") + + first_command, _ = policy(state, actions) + second_command, _ = policy(state, actions) + + assert first_command.verb == "Halt" + assert second_command.verb == "Done" + + +def test_no_done_fallback_to_halt(): + policy = ReactPolicy() + state = _make_state([_make_fragment("assistant")]) + actions = _make_action_space("Halt") + command, _ = policy(state, actions) + assert command.verb == "Halt" From be1850f8a6bf959aad6bbf958c3df6099650da2d Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:54:25 +0800 Subject: [PATCH 03/12] fix(release): make promotion validation and reruns reliable (#160) --- .github/workflows/release-accelerate.yml | 11 +++++++++-- .github/workflows/release-promotion.yml | 16 ++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-accelerate.yml b/.github/workflows/release-accelerate.yml index 937637df..3b5c5bb3 100644 --- a/.github/workflows/release-accelerate.yml +++ b/.github/workflows/release-accelerate.yml @@ -209,9 +209,16 @@ jobs: Formula/rcm.rb git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git switch -C "$RELEASE_BRANCH" + if git ls-remote --exit-code --heads origin "$RELEASE_BRANCH" >/dev/null 2>&1; then + git fetch origin "$RELEASE_BRANCH" + git switch -C "$RELEASE_BRANCH" "origin/$RELEASE_BRANCH" + else + git switch -C "$RELEASE_BRANCH" + fi git add Formula/rcm.rb - git commit -m "chore(release): update formula for $TAG" + if ! git diff --cached --quiet; then + git commit -m "chore(release): update formula for $TAG" + fi git push origin "HEAD:$RELEASE_BRANCH" PR_URL=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$RELEASE_BRANCH" --base main --state open --json url --jq '.[0].url') if [ -z "$PR_URL" ]; then diff --git a/.github/workflows/release-promotion.yml b/.github/workflows/release-promotion.yml index 85cc40e0..2459e116 100644 --- a/.github/workflows/release-promotion.yml +++ b/.github/workflows/release-promotion.yml @@ -30,14 +30,18 @@ jobs: GH_TOKEN: ${{ github.token }} EVENT_NAME: ${{ github.event_name }} INPUT_PR_NUMBER: ${{ inputs.pr_number }} - EVENT_PATH: ${{ github.event_path }} + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_HEAD_REF: ${{ github.event.pull_request.head.ref }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | if [ "$EVENT_NAME" = "pull_request" ]; then - PR_NUMBER=$(jq -r '.pull_request.number' "$EVENT_PATH") - BASE_REF=$(jq -r '.pull_request.base.ref' "$EVENT_PATH") - HEAD_REF=$(jq -r '.pull_request.head.ref' "$EVENT_PATH") - HEAD_SHA=$(jq -r '.pull_request.head.sha' "$EVENT_PATH") - BASE_SHA=$(jq -r '.pull_request.base.sha' "$EVENT_PATH") + PR_NUMBER="$EVENT_PR_NUMBER" + BASE_REF="$EVENT_BASE_REF" + HEAD_REF="$EVENT_HEAD_REF" + HEAD_SHA="$EVENT_HEAD_SHA" + BASE_SHA="$EVENT_BASE_SHA" else PR_NUMBER="$INPUT_PR_NUMBER" PR_JSON=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER") From d0459cf81b6d17d681c35b1a9072e5d6c9fca547 Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:04:36 +0800 Subject: [PATCH 04/12] chore(dependabot): target version updates at dev (#178) --- .github/dependabot.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index aecb88c1..891db053 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,16 +2,19 @@ version: 2 updates: - package-ecosystem: cargo directory: / + target-branch: dev schedule: interval: monthly open-pull-requests-limit: 10 - package-ecosystem: pip directory: /sdks/python + target-branch: dev schedule: interval: monthly open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: / + target-branch: dev schedule: interval: monthly open-pull-requests-limit: 10 From 84f7049217478eaa844601d5a65ff29542572c0e Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:13:07 +0800 Subject: [PATCH 05/12] fix(deps): update quinn-proto past memory exhaustion advisory (#180) --- Cargo.lock | 79 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a6699f1f..efa55335 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -349,6 +349,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -527,6 +538,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32c" version = "0.6.8" @@ -755,7 +775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -966,11 +986,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -980,10 +998,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1949,15 +1970,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.2", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -2011,18 +2033,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -2035,16 +2058,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -2056,11 +2069,17 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core 0.10.1", ] [[package]] @@ -2252,7 +2271,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2309,7 +2328,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2556,7 +2575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2785,7 +2804,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3530,7 +3549,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] From d5353b57496e35b6a9e1bbe7b38ef3095a81dae1 Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:37:13 +0800 Subject: [PATCH 06/12] fix(machine): diagnose provider request failures safely (#182) Expose content-free request-shape and provider classifications. Redact HTTP response bodies before failures enter machine state. Add the 0.2.21 release contract and incident guardrails. --- .repo-seed/manifest.json | 5 +- Cargo.lock | 2 +- crates/cli/Cargo.toml | 2 +- crates/cli/src/cmd/run.rs | 59 +++ crates/cli/src/hook/mod.rs | 57 +++ crates/cli/tests/run_stream.rs | 55 ++- crates/machine/src/completion.rs | 396 ++++++++++++++++-- crates/machine/src/event.rs | 85 ++++ crates/machine/src/reactor.rs | 20 +- crates/machine/tests/completion.rs | 47 ++- crates/machine/tests/completion_telemetry.rs | 39 +- .../0000-provider-400-observability-gap.md | 31 ++ docs/postmortems/README.md | 2 +- ...0001-provider-request-shape-diagnostics.md | 36 ++ 14 files changed, 796 insertions(+), 40 deletions(-) create mode 100644 docs/postmortems/0000-provider-400-observability-gap.md create mode 100644 docs/specs/0001-provider-request-shape-diagnostics.md diff --git a/.repo-seed/manifest.json b/.repo-seed/manifest.json index c341d823..3cbc8137 100644 --- a/.repo-seed/manifest.json +++ b/.repo-seed/manifest.json @@ -97,7 +97,8 @@ "path": ".agents/skills/repo-review/SKILL.md", "sha256": "b0c935491592d2cc61008fc91c95af1f3fb39041e1f419ace80e6c3c41605bee", "category": "skill", - "capability": "baseline" + "capability": "baseline", + "userModified": true }, { "path": ".agents/skills/repo-governance/SKILL.md", @@ -257,7 +258,7 @@ }, { "path": "docs/postmortems/README.md", - "sha256": "2c25abcb13a98f5c3c7f4408665c24c80e47855e5540dfa83b76777e6c46f3c3", + "sha256": "60f73daf42810198b809c54a071c42357187fa4bca7ab376eb1322e0cb897889", "category": "docs", "capability": "postmortems" }, diff --git a/Cargo.lock b/Cargo.lock index efa55335..0b0f64c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "0.2.20" +version = "0.2.21" dependencies = [ "accelerator", "anyhow", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index fb5043fc..55eefca5 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cli" -version = "0.2.20" +version = "0.2.21" edition = "2024" publish = false diff --git a/crates/cli/src/cmd/run.rs b/crates/cli/src/cmd/run.rs index 13d1fe1b..4404f417 100644 --- a/crates/cli/src/cmd/run.rs +++ b/crates/cli/src/cmd/run.rs @@ -291,6 +291,7 @@ pub fn completion_event_json(event: &hook::CompletionEvent) -> serde_json::Value failure_kind, retryable, duration_ms, + diagnostics, } = event else { return serde_json::json!({}); @@ -322,6 +323,64 @@ pub fn completion_event_json(event: &hook::CompletionEvent) -> serde_json::Value if let Some(value) = duration_ms { object.insert("duration_ms".into(), serde_json::json!(value)); } + if let Some(diagnostics) = diagnostics { + object.insert( + "serialized_request_bytes".into(), + serde_json::json!(diagnostics.serialized_request_bytes), + ); + object.insert( + "estimated_input_tokens".into(), + serde_json::json!(diagnostics.estimated_input_tokens), + ); + object.insert( + "message_count".into(), + serde_json::json!(diagnostics.message_count), + ); + object.insert( + "tool_definition_count".into(), + serde_json::json!(diagnostics.tool_definition_count), + ); + object.insert( + "tool_call_count".into(), + serde_json::json!(diagnostics.tool_call_count), + ); + object.insert( + "tool_result_count".into(), + serde_json::json!(diagnostics.tool_result_count), + ); + object.insert( + "thinking_enabled".into(), + serde_json::json!(diagnostics.thinking_enabled), + ); + object.insert( + "reasoning_content_present".into(), + serde_json::json!(diagnostics.reasoning_content_present), + ); + object.insert( + "reasoning_content_bytes".into(), + serde_json::json!(diagnostics.reasoning_content_bytes), + ); + object.insert( + "unmatched_tool_call_count".into(), + serde_json::json!(diagnostics.unmatched_tool_call_count), + ); + object.insert( + "duplicate_tool_call_count".into(), + serde_json::json!(diagnostics.duplicate_tool_call_count), + ); + if let Some(value) = &diagnostics.provider_code { + object.insert("provider_code".into(), serde_json::json!(value)); + } + if let Some(value) = &diagnostics.provider_type { + object.insert("provider_type".into(), serde_json::json!(value)); + } + if let Some(value) = &diagnostics.request_id { + object.insert("request_id".into(), serde_json::json!(value)); + } + if let Some(value) = &diagnostics.request_class { + object.insert("request_class".into(), serde_json::json!(value)); + } + } payload } diff --git a/crates/cli/src/hook/mod.rs b/crates/cli/src/hook/mod.rs index 76106e19..5fd815c2 100644 --- a/crates/cli/src/hook/mod.rs +++ b/crates/cli/src/hook/mod.rs @@ -80,9 +80,29 @@ pub enum CompletionEvent { failure_kind: Option<String>, retryable: Option<bool>, duration_ms: Option<u64>, + diagnostics: Option<Box<CompletionDiagnosticFields>>, }, } +#[derive(Debug, Clone)] +pub struct CompletionDiagnosticFields { + pub serialized_request_bytes: u64, + pub estimated_input_tokens: u64, + pub message_count: u64, + pub tool_definition_count: u64, + pub tool_call_count: u64, + pub tool_result_count: u64, + pub thinking_enabled: bool, + pub reasoning_content_present: bool, + pub reasoning_content_bytes: u64, + pub unmatched_tool_call_count: u64, + pub duplicate_tool_call_count: u64, + pub provider_code: Option<String>, + pub provider_type: Option<String>, + pub request_id: Option<String>, + pub request_class: Option<String>, +} + #[derive(Debug, Clone)] pub enum ToolEvent { Call { @@ -363,6 +383,43 @@ impl HookEvent { .then(|| fields.bool("retryable")) .flatten(), duration_ms: fields.u64("duration_ms"), + diagnostics: Some(Box::new(CompletionDiagnosticFields { + serialized_request_bytes: fields + .u64("serialized_request_bytes") + .unwrap_or_default(), + estimated_input_tokens: fields + .u64("estimated_input_tokens") + .unwrap_or_default(), + message_count: fields.u64("message_count").unwrap_or_default(), + tool_definition_count: fields.u64("tool_definition_count").unwrap_or_default(), + tool_call_count: fields.u64("tool_call_count").unwrap_or_default(), + tool_result_count: fields.u64("tool_result_count").unwrap_or_default(), + thinking_enabled: fields.bool("thinking_enabled").unwrap_or_default(), + reasoning_content_present: fields + .bool("reasoning_content_present") + .unwrap_or_default(), + reasoning_content_bytes: fields + .u64("reasoning_content_bytes") + .unwrap_or_default(), + unmatched_tool_call_count: fields + .u64("unmatched_tool_call_count") + .unwrap_or_default(), + duplicate_tool_call_count: fields + .u64("duplicate_tool_call_count") + .unwrap_or_default(), + provider_code: fields + .string("provider_code") + .filter(|value| !value.is_empty()), + provider_type: fields + .string("provider_type") + .filter(|value| !value.is_empty()), + request_id: fields + .string("request_id") + .filter(|value| !value.is_empty()), + request_class: fields + .string("request_class") + .filter(|value| !value.is_empty()), + })), }), "tool_call" => HookKind::Tool(ToolEvent::Call { call_id: fields.string("call_id").unwrap_or_default(), diff --git a/crates/cli/tests/run_stream.rs b/crates/cli/tests/run_stream.rs index 4a327643..84760c0f 100644 --- a/crates/cli/tests/run_stream.rs +++ b/crates/cli/tests/run_stream.rs @@ -1,5 +1,8 @@ use cli::cmd::run::{completion_event_json, is_terminal_event}; -use cli::hook::{CompletionEvent, ComponentMeta, GraphEvent, HookEvent, HookKind, MachineEvent}; +use cli::hook::{ + CompletionDiagnosticFields, CompletionEvent, ComponentMeta, GraphEvent, HookEvent, HookKind, + MachineEvent, +}; use serde::Deserialize; fn event(kind: HookKind) -> HookEvent { @@ -110,6 +113,23 @@ fn completion_failure_json_adds_sanitized_metadata() { failure_kind: Some("provider_error".into()), retryable: Some(true), duration_ms: Some(12_345), + diagnostics: Some(Box::new(CompletionDiagnosticFields { + serialized_request_bytes: 524_288, + estimated_input_tokens: 131_072, + message_count: 12, + tool_definition_count: 3, + tool_call_count: 4, + tool_result_count: 3, + thinking_enabled: true, + reasoning_content_present: true, + reasoning_content_bytes: 4096, + unmatched_tool_call_count: 1, + duplicate_tool_call_count: 0, + provider_code: Some("server_error".into()), + provider_type: Some("upstream_error".into()), + request_id: Some("req-123".into()), + request_class: Some("thinking_tool_history".into()), + })), }); assert_eq!(payload["outcome"], "failure"); @@ -117,6 +137,21 @@ fn completion_failure_json_adds_sanitized_metadata() { assert_eq!(payload["failure_kind"], "provider_error"); assert_eq!(payload["retryable"], true); assert_eq!(payload["duration_ms"], 12_345); + assert_eq!(payload["serialized_request_bytes"], 524_288); + assert_eq!(payload["estimated_input_tokens"], 131_072); + assert_eq!(payload["message_count"], 12); + assert_eq!(payload["tool_definition_count"], 3); + assert_eq!(payload["tool_call_count"], 4); + assert_eq!(payload["tool_result_count"], 3); + assert_eq!(payload["thinking_enabled"], true); + assert_eq!(payload["reasoning_content_present"], true); + assert_eq!(payload["reasoning_content_bytes"], 4096); + assert_eq!(payload["unmatched_tool_call_count"], 1); + assert_eq!(payload["duplicate_tool_call_count"], 0); + assert_eq!(payload["provider_code"], "server_error"); + assert_eq!(payload["provider_type"], "upstream_error"); + assert_eq!(payload["request_id"], "req-123"); + assert_eq!(payload["request_class"], "thinking_tool_history"); assert!(payload.get("error").is_none()); assert!(payload.get("message").is_none()); } @@ -135,6 +170,7 @@ fn legacy_completion_event_omits_new_optional_fields() { failure_kind: None, retryable: None, duration_ms: None, + diagnostics: None, }); assert!(payload.get("outcome").is_none()); @@ -164,6 +200,23 @@ fn legacy_consumers_can_ignore_additive_completion_fields() { failure_kind: Some("rate_limited".into()), retryable: Some(true), duration_ms: Some(100), + diagnostics: Some(Box::new(CompletionDiagnosticFields { + serialized_request_bytes: 1024, + estimated_input_tokens: 256, + message_count: 2, + tool_definition_count: 0, + tool_call_count: 0, + tool_result_count: 0, + thinking_enabled: false, + reasoning_content_present: false, + reasoning_content_bytes: 0, + unmatched_tool_call_count: 0, + duplicate_tool_call_count: 0, + provider_code: Some("rate_limit".into()), + provider_type: None, + request_id: Some("req-safe".into()), + request_class: None, + })), }); let legacy: LegacyCompletionConsumer = serde_json::from_value(payload).unwrap(); diff --git a/crates/machine/src/completion.rs b/crates/machine/src/completion.rs index 27fb4dab..f9dab1cb 100644 --- a/crates/machine/src/completion.rs +++ b/crates/machine/src/completion.rs @@ -42,12 +42,39 @@ use rig::http_client; use tokio::time::{Duration, timeout}; use crate::context::Context; +use crate::event::{CompletionDiagnostics, ProviderFailureDiagnostics, RequestShapeDiagnostics}; use crate::fragment::{Content, Fragment, Role}; use crate::model::{Model, Protocol}; use crate::resources::Resources; use crate::usage::TokenUsage; use tracing::{debug, warn}; +const ESTIMATED_CHARS_PER_TOKEN: u64 = 4; +const MAX_PROVIDER_IDENTIFIER_BYTES: usize = 128; + +struct CompletionFailure { + hitch: Fragment, + provider: ProviderFailureDiagnostics, +} + +#[derive(Default)] +struct CountingWriter { + bytes: u64, +} + +impl std::io::Write for CountingWriter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> { + self.bytes = self + .bytes + .saturating_add(u64::try_from(buffer.len()).unwrap_or(u64::MAX)); + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + /// The bare model name to send on the wire. When a `Model` is constructed via /// the accelerator's provider table, `name` is `"<provider>/<model>"` and the /// bare model name is stashed in `extra["wire_name"]`. Hand-built `Model`s @@ -66,6 +93,16 @@ pub async fn complete( resources: &Resources, overlay: &crate::overlay::Overlay, ) -> (Vec<Fragment>, TokenUsage) { + let (fragments, usage, _) = complete_with_diagnostics(ctx, resources, overlay).await; + (fragments, usage) +} + +/// Call the active LLM and return content-free request/provider diagnostics. +pub async fn complete_with_diagnostics( + ctx: &Context, + resources: &Resources, + overlay: &crate::overlay::Overlay, +) -> (Vec<Fragment>, TokenUsage, CompletionDiagnostics) { let Some(model) = resources.active_model() else { warn!("completion requested but no active model is set"); let hitch = Fragment::hitch( @@ -74,7 +111,11 @@ pub async fn complete( Role::System, None::<&str>, ); - return (vec![hitch], TokenUsage::empty()); + return ( + vec![hitch], + TokenUsage::empty(), + CompletionDiagnostics::default(), + ); }; let messages = assemble_messages(ctx.fragments(), model.thinking, overlay); @@ -100,6 +141,22 @@ pub async fn complete( let endpoint_url = model.endpoint.as_deref(); let wire_name = wire_name(model); + let request = match build_request(&messages, &tools, model) { + Ok(request) => request, + Err(hitch) => { + return ( + vec![hitch], + TokenUsage::empty(), + CompletionDiagnostics::default(), + ); + } + }; + let request_diagnostics = request_shape_diagnostics(&request, model.thinking); + let mut diagnostics = CompletionDiagnostics { + request: request_diagnostics, + provider: ProviderFailureDiagnostics::default(), + }; + let result = match model.protocol { Protocol::OpenAI => { let mut builder = rig::providers::openai::CompletionsClient::builder() @@ -113,7 +170,7 @@ pub async fn complete( .build() .expect("failed to build openai client") .completion_model(wire_name); - send(&endpoint, model, &messages, &tools).await + send(&endpoint, model, request).await } Protocol::Anthropic => { let mut builder = rig::providers::anthropic::Client::builder().api_key(api_key); @@ -125,7 +182,7 @@ pub async fn complete( .build() .expect("failed to build anthropic client") .completion_model(wire_name); - send(&endpoint, model, &messages, &tools).await + send(&endpoint, model, request).await } Protocol::Gemini => { let mut builder = rig::providers::gemini::Client::builder().api_key(api_key); @@ -137,7 +194,7 @@ pub async fn complete( .build() .expect("failed to build gemini client") .completion_model(wire_name); - send(&endpoint, model, &messages, &tools).await + send(&endpoint, model, request).await } }; @@ -152,11 +209,35 @@ pub async fn complete( .filter(|c| matches!(c, AssistantContent::ToolCall(_))) .count(); debug!(text_fragments, tool_calls, "completion response"); - (decode(choice.iter()), usage) + (decode(choice.iter()), usage, diagnostics) } - Err(hitch) => { - warn!(?hitch, "completion failed"); - (vec![hitch], TokenUsage::empty()) + Err(failure) => { + diagnostics.provider = failure.provider; + let http_status = match &failure.hitch.content { + Content::Hitch { code, .. } => *code, + _ => None, + }; + warn!( + model = %model.name, + http_status = http_status.unwrap_or_default(), + provider_code = diagnostics.provider.provider_code.as_deref().unwrap_or(""), + provider_type = diagnostics.provider.provider_type.as_deref().unwrap_or(""), + request_id = diagnostics.provider.request_id.as_deref().unwrap_or(""), + request_class = diagnostics.provider.request_class.unwrap_or(""), + serialized_request_bytes = diagnostics.request.serialized_request_bytes, + estimated_input_tokens = diagnostics.request.estimated_input_tokens, + message_count = diagnostics.request.message_count, + tool_definition_count = diagnostics.request.tool_definition_count, + tool_call_count = diagnostics.request.tool_call_count, + tool_result_count = diagnostics.request.tool_result_count, + thinking_enabled = diagnostics.request.thinking_enabled, + reasoning_content_present = diagnostics.request.reasoning_content_present, + reasoning_content_bytes = diagnostics.request.reasoning_content_bytes, + unmatched_tool_call_count = diagnostics.request.unmatched_tool_call_count, + duplicate_tool_call_count = diagnostics.request.duplicate_tool_call_count, + "completion failed" + ); + (vec![failure.hitch], TokenUsage::empty(), diagnostics) } } } @@ -333,11 +414,8 @@ pub fn assemble_messages( async fn send( endpoint: &impl CompletionModel, model: &Model, - messages: &[Message], - tools: &[RigToolDefinition], -) -> Result<(OneOrMany<AssistantContent>, TokenUsage), Fragment> { - let request = build_request(messages, tools, model)?; - + request: CompletionRequest, +) -> Result<(OneOrMany<AssistantContent>, TokenUsage), CompletionFailure> { match timeout( Duration::from_secs(model.timeout), endpoint.completion(request), @@ -355,30 +433,226 @@ async fn send( }, )), Ok(Err(error)) => { - let code = match &error { - CompletionError::HttpError(http_client::Error::InvalidStatusCode(s)) => { - Some(s.as_u16()) - } + let (code, provider) = match &error { + CompletionError::HttpError(http_client::Error::InvalidStatusCode(s)) => ( + Some(s.as_u16()), + provider_failure_diagnostics(s.as_u16(), ""), + ), CompletionError::HttpError(http_client::Error::InvalidStatusCodeWithMessage( s, - _, - )) => Some(s.as_u16()), - _ => None, + message, + )) => ( + Some(s.as_u16()), + provider_failure_diagnostics(s.as_u16(), message), + ), + _ => (None, ProviderFailureDiagnostics::default()), }; - Err(Fragment::hitch( - error.to_string(), - code, + let hitch_message = provider_hitch_message(&error, code); + Err(CompletionFailure { + hitch: Fragment::hitch(hitch_message, code, Role::Assistant, None::<&str>), + provider, + }) + } + Err(_) => Err(CompletionFailure { + hitch: Fragment::hitch( + format!("request timed out after {}s", model.timeout), + None, Role::Assistant, None::<&str>, - )) + ), + provider: ProviderFailureDiagnostics::default(), + }), + } +} + +fn provider_hitch_message(error: &CompletionError, status: Option<u16>) -> String { + status.map_or_else( + || error.to_string(), + |status| format!("provider completion failed with HTTP {status}"), + ) +} + +/// Measure a normalized generic request without retaining any request content. +pub fn request_shape_diagnostics( + request: &CompletionRequest, + thinking_enabled: bool, +) -> RequestShapeDiagnostics { + use std::collections::{HashMap, HashSet}; + + let mut counter = CountingWriter::default(); + let serialized_request_bytes = serde_json::to_writer(&mut counter, request) + .map(|()| counter.bytes) + .unwrap_or_default(); + let mut tool_call_ids = Vec::new(); + let mut tool_result_counts: HashMap<&str, u64> = HashMap::new(); + let mut reasoning_content_bytes = 0u64; + let mut reasoning_content_present = false; + let mut tool_result_count = 0u64; + + for message in request.chat_history.iter() { + match message { + Message::Assistant { content, .. } => { + for item in content.iter() { + match item { + AssistantContent::ToolCall(tool_call) => { + tool_call_ids.push(tool_call.id.as_str()); + } + AssistantContent::Reasoning(reasoning) => { + reasoning_content_present = true; + let bytes = serde_json::to_vec(reasoning) + .map(|serialized| serialized.len()) + .unwrap_or_default(); + reasoning_content_bytes = reasoning_content_bytes + .saturating_add(u64::try_from(bytes).unwrap_or(u64::MAX)); + } + _ => {} + } + } + } + Message::User { content } => { + for item in content.iter() { + if let UserContent::ToolResult(tool_result) = item { + tool_result_count = tool_result_count.saturating_add(1); + let call_id = tool_result + .call_id + .as_deref() + .unwrap_or(tool_result.id.as_str()); + *tool_result_counts.entry(call_id).or_default() += 1; + } + } + } + Message::System { .. } => {} } - Err(_) => Err(Fragment::hitch( - format!("request timed out after {}s", model.timeout), - None, - Role::Assistant, - None::<&str>, - )), } + + let mut seen_call_ids = HashSet::new(); + let duplicate_tool_call_count = tool_call_ids.iter().fold(0u64, |count, call_id| { + count.saturating_add(u64::from(!seen_call_ids.insert(*call_id))) + }); + let mut remaining_results = tool_result_counts; + let unmatched_tool_call_count = tool_call_ids.iter().fold(0u64, |count, call_id| { + let Some(remaining) = remaining_results.get_mut(*call_id) else { + return count.saturating_add(1); + }; + if *remaining == 0 { + count.saturating_add(1) + } else { + *remaining -= 1; + count + } + }); + + RequestShapeDiagnostics { + serialized_request_bytes, + estimated_input_tokens: serialized_request_bytes + .saturating_add(ESTIMATED_CHARS_PER_TOKEN - 1) + .saturating_div(ESTIMATED_CHARS_PER_TOKEN), + message_count: u64::try_from(request.chat_history.len()).unwrap_or(u64::MAX), + tool_definition_count: u64::try_from(request.tools.len()).unwrap_or(u64::MAX), + tool_call_count: u64::try_from(tool_call_ids.len()).unwrap_or(u64::MAX), + tool_result_count, + thinking_enabled, + reasoning_content_present, + reasoning_content_bytes, + unmatched_tool_call_count, + duplicate_tool_call_count, + } +} + +fn provider_failure_diagnostics(status: u16, body: &str) -> ProviderFailureDiagnostics { + let parsed = serde_json::from_str::<serde_json::Value>(body).ok(); + let provider_code = parsed + .as_ref() + .and_then(|value| provider_identifier(value, "code")); + let provider_type = parsed + .as_ref() + .and_then(|value| provider_identifier(value, "type")); + let request_id = parsed + .as_ref() + .and_then(|value| provider_identifier(value, "request_id")) + .or_else(|| { + parsed + .as_ref() + .and_then(|value| provider_identifier(value, "requestId")) + }); + let request_class = classify_provider_request( + status, + provider_code.as_deref(), + provider_type.as_deref(), + body, + ); + + ProviderFailureDiagnostics { + provider_code, + provider_type, + request_id, + request_class, + } +} + +fn provider_identifier(value: &serde_json::Value, key: &str) -> Option<String> { + let candidate = value + .get("error") + .and_then(|error| error.get(key)) + .or_else(|| value.get(key))?; + let candidate = match candidate { + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Number(value) => value.to_string(), + _ => return None, + }; + let valid = !candidate.is_empty() + && candidate.len() <= MAX_PROVIDER_IDENTIFIER_BYTES + && candidate + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':')); + valid.then_some(candidate) +} + +fn classify_provider_request( + status: u16, + provider_code: Option<&str>, + provider_type: Option<&str>, + body: &str, +) -> Option<&'static str> { + if status != 400 && status != 413 { + return None; + } + let normalized = format!( + "{} {} {}", + provider_code.unwrap_or_default(), + provider_type.unwrap_or_default(), + body + ) + .to_ascii_lowercase(); + let size_markers = [ + "context_length", + "context length", + "maximum context", + "request_too_large", + "request too large", + "payload_too_large", + "payload too large", + "too many tokens", + "token limit", + ]; + if status == 413 + || size_markers + .iter() + .any(|marker| normalized.contains(marker)) + { + return Some("request_size"); + } + let invalid_history_marker = ["missing", "required", "unmatched", "duplicate", "invalid"] + .iter() + .any(|marker| normalized.contains(marker)); + if normalized.contains("reasoning_content") + || normalized.contains("thinking is enabled") + || ((normalized.contains("tool_call") || normalized.contains("tool call")) + && invalid_history_marker) + { + return Some("thinking_tool_history"); + } + Some("unknown_request") } /// Construct a `CompletionRequest` whose `chat_history` is the encoded @@ -640,8 +914,70 @@ pub fn encode(frag: &Fragment, thinking: bool) -> Option<Message> { #[cfg(test)] mod transport_tests { + use super::{provider_failure_diagnostics, provider_hitch_message}; + #[test] fn openai_http_client_builds_with_gateway_safe_settings() { let _client = super::openai_http_client(); } + + #[test] + fn provider_failure_diagnostics_classify_without_retaining_response_message() { + let sensitive = "PRIVATE PAPER BODY MUST NOT ENTER DIAGNOSTICS"; + let body = serde_json::json!({ + "error": { + "message": format!("{sensitive}: reasoning_content is missing"), + "type": "invalid_request_error", + "code": "invalid_request_error" + }, + "request_id": "req-123" + }) + .to_string(); + + let diagnostics = provider_failure_diagnostics(400, &body); + + assert_eq!( + diagnostics.provider_code.as_deref(), + Some("invalid_request_error") + ); + assert_eq!( + diagnostics.provider_type.as_deref(), + Some("invalid_request_error") + ); + assert_eq!(diagnostics.request_id.as_deref(), Some("req-123")); + assert_eq!(diagnostics.request_class, Some("thinking_tool_history")); + assert!(!format!("{diagnostics:?}").contains(sensitive)); + } + + #[test] + fn provider_failure_diagnostics_distinguish_size_and_unknown_requests() { + let size = provider_failure_diagnostics( + 400, + r#"{"error":{"code":"context_length_exceeded","message":"too many tokens"}}"#, + ); + let unknown = provider_failure_diagnostics( + 400, + r#"{"error":{"code":"bad code with spaces","message":"rejected"}}"#, + ); + + assert_eq!(size.request_class, Some("request_size")); + assert_eq!(unknown.request_class, Some("unknown_request")); + assert_eq!(unknown.provider_code, None); + } + + #[test] + fn http_hitch_summary_does_not_retain_provider_body() { + let sensitive = "PRIVATE PAPER BODY MUST NOT ENTER THE HITCH"; + let error = rig::completion::CompletionError::HttpError( + rig::http_client::Error::InvalidStatusCodeWithMessage( + http::StatusCode::BAD_REQUEST, + sensitive.into(), + ), + ); + let status = Some(http::StatusCode::BAD_REQUEST.as_u16()); + let message = provider_hitch_message(&error, status); + + assert_eq!(message, "provider completion failed with HTTP 400"); + assert!(!message.contains(sensitive)); + } } diff --git a/crates/machine/src/event.rs b/crates/machine/src/event.rs index fb2721e5..b62973d3 100644 --- a/crates/machine/src/event.rs +++ b/crates/machine/src/event.rs @@ -1,5 +1,37 @@ use crate::fragment::{Content, Fragment, Role}; +/// Content-free structural facts about one serialized completion request. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RequestShapeDiagnostics { + pub serialized_request_bytes: u64, + pub estimated_input_tokens: u64, + pub message_count: u64, + pub tool_definition_count: u64, + pub tool_call_count: u64, + pub tool_result_count: u64, + pub thinking_enabled: bool, + pub reasoning_content_present: bool, + pub reasoning_content_bytes: u64, + pub unmatched_tool_call_count: u64, + pub duplicate_tool_call_count: u64, +} + +/// Allowlisted metadata extracted from a provider failure envelope. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProviderFailureDiagnostics { + pub provider_code: Option<String>, + pub provider_type: Option<String>, + pub request_id: Option<String>, + pub request_class: Option<&'static str>, +} + +/// Diagnostics carried beside a completion result, never on the machine tape. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CompletionDiagnostics { + pub request: RequestShapeDiagnostics, + pub provider: ProviderFailureDiagnostics, +} + /// Sanitized metadata describing one model completion attempt. /// /// The classification intentionally contains no provider response text. It is @@ -10,10 +42,33 @@ pub struct CompletionTelemetry { pub http_status: Option<u16>, pub failure_kind: Option<&'static str>, pub retryable: Option<bool>, + pub serialized_request_bytes: u64, + pub estimated_input_tokens: u64, + pub message_count: u64, + pub tool_definition_count: u64, + pub tool_call_count: u64, + pub tool_result_count: u64, + pub thinking_enabled: bool, + pub reasoning_content_present: bool, + pub reasoning_content_bytes: u64, + pub unmatched_tool_call_count: u64, + pub duplicate_tool_call_count: u64, + pub provider_code: Option<String>, + pub provider_type: Option<String>, + pub request_id: Option<String>, + pub request_class: Option<&'static str>, } /// Classify the completion result without exposing hitch content. pub fn completion_telemetry(fragments: &[Fragment]) -> CompletionTelemetry { + completion_telemetry_with_diagnostics(fragments, &CompletionDiagnostics::default()) +} + +/// Classify a completion and attach content-free request/provider diagnostics. +pub fn completion_telemetry_with_diagnostics( + fragments: &[Fragment], + diagnostics: &CompletionDiagnostics, +) -> CompletionTelemetry { let failure = fragments .iter() .find_map(|fragment| match &fragment.content { @@ -26,6 +81,21 @@ pub fn completion_telemetry(fragments: &[Fragment]) -> CompletionTelemetry { http_status: None, failure_kind: None, retryable: None, + serialized_request_bytes: diagnostics.request.serialized_request_bytes, + estimated_input_tokens: diagnostics.request.estimated_input_tokens, + message_count: diagnostics.request.message_count, + tool_definition_count: diagnostics.request.tool_definition_count, + tool_call_count: diagnostics.request.tool_call_count, + tool_result_count: diagnostics.request.tool_result_count, + thinking_enabled: diagnostics.request.thinking_enabled, + reasoning_content_present: diagnostics.request.reasoning_content_present, + reasoning_content_bytes: diagnostics.request.reasoning_content_bytes, + unmatched_tool_call_count: diagnostics.request.unmatched_tool_call_count, + duplicate_tool_call_count: diagnostics.request.duplicate_tool_call_count, + provider_code: diagnostics.provider.provider_code.clone(), + provider_type: diagnostics.provider.provider_type.clone(), + request_id: diagnostics.provider.request_id.clone(), + request_class: diagnostics.provider.request_class, }; }; @@ -35,6 +105,21 @@ pub fn completion_telemetry(fragments: &[Fragment]) -> CompletionTelemetry { http_status, failure_kind: Some(failure_kind), retryable: Some(retryable), + serialized_request_bytes: diagnostics.request.serialized_request_bytes, + estimated_input_tokens: diagnostics.request.estimated_input_tokens, + message_count: diagnostics.request.message_count, + tool_definition_count: diagnostics.request.tool_definition_count, + tool_call_count: diagnostics.request.tool_call_count, + tool_result_count: diagnostics.request.tool_result_count, + thinking_enabled: diagnostics.request.thinking_enabled, + reasoning_content_present: diagnostics.request.reasoning_content_present, + reasoning_content_bytes: diagnostics.request.reasoning_content_bytes, + unmatched_tool_call_count: diagnostics.request.unmatched_tool_call_count, + duplicate_tool_call_count: diagnostics.request.duplicate_tool_call_count, + provider_code: diagnostics.provider.provider_code.clone(), + provider_type: diagnostics.provider.provider_type.clone(), + request_id: diagnostics.provider.request_id.clone(), + request_class: diagnostics.provider.request_class, } } diff --git a/crates/machine/src/reactor.rs b/crates/machine/src/reactor.rs index 0939b605..ae1b6a80 100644 --- a/crates/machine/src/reactor.rs +++ b/crates/machine/src/reactor.rs @@ -25,9 +25,10 @@ pub async fn react( hook!(event = "completion_start", machine_id); - let (fragments, usage) = completion::complete(ctx, resources, overlay).await; + let (fragments, usage, diagnostics) = + completion::complete_with_diagnostics(ctx, resources, overlay).await; let elapsed = started_at.elapsed(); - let telemetry = crate::event::completion_telemetry(&fragments); + let telemetry = crate::event::completion_telemetry_with_diagnostics(&fragments, &diagnostics); hook!( event = "completion_end", machine_id, @@ -37,6 +38,21 @@ pub async fn react( http_status = telemetry.http_status.unwrap_or_default(), failure_kind = telemetry.failure_kind.unwrap_or_default(), retryable = telemetry.retryable.unwrap_or(false), + serialized_request_bytes = telemetry.serialized_request_bytes, + estimated_input_tokens = telemetry.estimated_input_tokens, + message_count = telemetry.message_count, + tool_definition_count = telemetry.tool_definition_count, + tool_call_count = telemetry.tool_call_count, + tool_result_count = telemetry.tool_result_count, + thinking_enabled = telemetry.thinking_enabled, + reasoning_content_present = telemetry.reasoning_content_present, + reasoning_content_bytes = telemetry.reasoning_content_bytes, + unmatched_tool_call_count = telemetry.unmatched_tool_call_count, + duplicate_tool_call_count = telemetry.duplicate_tool_call_count, + provider_code = telemetry.provider_code.as_deref().unwrap_or_default(), + provider_type = telemetry.provider_type.as_deref().unwrap_or_default(), + request_id = telemetry.request_id.as_deref().unwrap_or_default(), + request_class = telemetry.request_class.unwrap_or_default(), fragments = fragments.len(), input_tokens = usage.input_tokens, output_tokens = usage.output_tokens, diff --git a/crates/machine/tests/completion.rs b/crates/machine/tests/completion.rs index 9371ddd3..7cf7e93b 100644 --- a/crates/machine/tests/completion.rs +++ b/crates/machine/tests/completion.rs @@ -1,4 +1,6 @@ -use machine::completion::{build_request, decode, encode, encode_context}; +use machine::completion::{ + build_request, decode, encode, encode_context, request_shape_diagnostics, +}; use machine::{Content, Fragment, Limit, Model, Protocol, Role}; use rig::completion::message::{Reasoning, Text as RigText, ToolCall as RigToolCall, ToolFunction}; use rig::completion::{AssistantContent, Message}; @@ -493,6 +495,49 @@ fn build_request_passes_model_temperature_and_max_tokens() { assert_eq!(req.max_tokens, Some(4096)); } +#[test] +fn request_shape_diagnostics_count_structure_without_retaining_content() { + let sensitive_text = "PRIVATE PAPER BODY MUST NOT ENTER DIAGNOSTICS"; + let messages = vec![ + Message::system("system"), + Message::user(sensitive_text), + Message::Assistant { + id: None, + content: rig::OneOrMany::many(vec![ + assistant_tool_call("call_1", "search"), + assistant_reasoning("private chain of thought"), + ]) + .expect("assistant content"), + }, + Message::tool_result("call_1", "private tool result"), + Message::Assistant { + id: None, + content: rig::OneOrMany::one(assistant_tool_call("call_1", "search")), + }, + ]; + let tools = vec![rig::completion::ToolDefinition { + name: "search".into(), + description: "search private sources".into(), + parameters: json!({"type": "object"}), + }]; + let request = build_request(&messages, &tools, &dummy_model()).expect("request builds"); + + let diagnostics = request_shape_diagnostics(&request, true); + + assert_eq!(diagnostics.message_count, 5); + assert_eq!(diagnostics.tool_definition_count, 1); + assert_eq!(diagnostics.tool_call_count, 2); + assert_eq!(diagnostics.tool_result_count, 1); + assert!(diagnostics.thinking_enabled); + assert!(diagnostics.reasoning_content_present); + assert!(diagnostics.reasoning_content_bytes > 0); + assert_eq!(diagnostics.unmatched_tool_call_count, 1); + assert_eq!(diagnostics.duplicate_tool_call_count, 1); + assert!(diagnostics.serialized_request_bytes > 0); + assert!(diagnostics.estimated_input_tokens > 0); + assert!(!format!("{diagnostics:?}").contains(sensitive_text)); +} + // ── decode: reasoning preservation ── fn assistant_reasoning(text: &str) -> AssistantContent { diff --git a/crates/machine/tests/completion_telemetry.rs b/crates/machine/tests/completion_telemetry.rs index 5d693ba2..dbaa9dd5 100644 --- a/crates/machine/tests/completion_telemetry.rs +++ b/crates/machine/tests/completion_telemetry.rs @@ -1,4 +1,7 @@ -use machine::event::completion_telemetry; +use machine::event::{ + CompletionDiagnostics, ProviderFailureDiagnostics, completion_telemetry, + completion_telemetry_with_diagnostics, +}; use machine::{Fragment, Role}; #[test] @@ -67,3 +70,37 @@ fn statusless_transport_failures_are_classified_without_exposing_the_message() { assert_eq!(configuration.failure_kind, Some("configuration")); assert_eq!(configuration.retryable, Some(false)); } + +#[test] +fn provider_failure_metadata_is_additive_and_content_free() { + let diagnostics = CompletionDiagnostics { + provider: ProviderFailureDiagnostics { + provider_code: Some("invalid_request_error".into()), + provider_type: Some("invalid_request_error".into()), + request_id: Some("req-123".into()), + request_class: Some("thinking_tool_history"), + }, + ..CompletionDiagnostics::default() + }; + let telemetry = completion_telemetry_with_diagnostics( + &[Fragment::hitch( + "PRIVATE PAPER BODY reasoning_content is missing", + Some(400), + Role::Assistant, + None::<&str>, + )], + &diagnostics, + ); + + assert_eq!( + telemetry.provider_code.as_deref(), + Some("invalid_request_error") + ); + assert_eq!( + telemetry.provider_type.as_deref(), + Some("invalid_request_error") + ); + assert_eq!(telemetry.request_id.as_deref(), Some("req-123")); + assert_eq!(telemetry.request_class, Some("thinking_tool_history")); + assert!(!format!("{telemetry:?}").contains("PRIVATE PAPER BODY")); +} diff --git a/docs/postmortems/0000-provider-400-observability-gap.md b/docs/postmortems/0000-provider-400-observability-gap.md new file mode 100644 index 00000000..5ca69bb0 --- /dev/null +++ b/docs/postmortems/0000-provider-400-observability-gap.md @@ -0,0 +1,31 @@ +# Provider 400 observability gap + +Artifact-Version: 1 + +## Executive summary + +A production research workflow repeatedly received HTTP 400 during reference expansion, but RCM exposed only the status and an unsafe raw error string, so operators could not distinguish request size from malformed thinking/tool history without inspecting user-bearing data; the permanent correction is content-free request-shape telemetry with stable failure classes and redaction tests. + +## Summary + +The affected workflow grouped several bibliography-heavy papers into one model unit. When its OpenAI-compatible provider rejected a request, the execution produced a generic invalid-request outcome and downstream processing continued without the expected artifact. The incident did not establish one unique provider-side cause because the available evidence lacked request-shape counters and a stable provider error classification. + +## Timeline + +- A production Survey reference-expansion unit began returning HTTP 400. +- Identical transport retries did not add diagnostic evidence or create the required expansion artifact. +- Investigation identified an observability gap spanning request size, reasoning replay, and tool-call history validation. +- The host workflow was redesigned around per-seed shards and RCM received a content-free provider diagnostic contract. + +## Root cause + +RCM converted provider failures into a hitch containing the raw display string and an optional HTTP status. Completion telemetry classified all client errors as `invalid_request`, while the operational warning formatted the full hitch. No contract measured the serialized request, counted reasoning and tool-history structure, extracted allowlisted provider identifiers, or prevented arbitrary provider content from entering diagnostics. + +The test suite covered reasoning replay regressions but did not assert the observability behavior needed when a provider still rejects a structurally complex request. + +## Guardrails + +- [Machine provider diagnostic tests](../../crates/machine/tests/completion_telemetry.rs) assert stable request classes and content-free values. +- [Machine request-shape tests](../../crates/machine/tests/completion.rs) assert size and tool-history counters without retaining message content. +- [CLI stream tests](../../crates/cli/tests/run_stream.rs) assert additive sanitized output and legacy-consumer compatibility. +- [Provider request-shape specification](../specs/0001-provider-request-shape-diagnostics.md) owns the diagnostic boundary. diff --git a/docs/postmortems/README.md b/docs/postmortems/README.md index 642ba016..e91a7614 100644 --- a/docs/postmortems/README.md +++ b/docs/postmortems/README.md @@ -22,4 +22,4 @@ Name files `NNNN-short-title.md` (sequential number) and put `Artifact-Version: ## Index -None yet. +- [Provider 400 observability gap](0000-provider-400-observability-gap.md) diff --git a/docs/specs/0001-provider-request-shape-diagnostics.md b/docs/specs/0001-provider-request-shape-diagnostics.md new file mode 100644 index 00000000..9ba94b83 --- /dev/null +++ b/docs/specs/0001-provider-request-shape-diagnostics.md @@ -0,0 +1,36 @@ +# Provider request-shape diagnostics + +Artifact-Version: 1 +Status: Implemented + +## Intent + +Completion failures from OpenAI-compatible providers must expose enough content-free metadata to distinguish oversized requests, invalid thinking/tool history, and unknown client errors without copying prompts, tool results, provider response messages, model output, or credentials into operational telemetry. + +This contract is additive to the completion stream and does not change provider selection, model-visible history, or retry policy. + +## Contract + +- The release artifact carrying this contract is versioned `0.2.21`; downstream pins must not adopt it before its archive checksum is published and the provider canary passes. +- Every completion end event reports the serialized generic request-envelope size, estimated input tokens, message count, tool-definition count, tool-call count, tool-result count, thinking flag, reasoning-content presence and byte length, unmatched tool-call count, and duplicate tool-call count when a request was constructed. +- Provider failures may report only a sanitized provider code, sanitized provider type, sanitized request identifier, and one stable request class: `request_size`, `thinking_tool_history`, or `unknown_request`. +- Provider error bodies are inspected only in memory for classification and allowlisted identifiers; arbitrary response fields and messages never enter completion telemetry or the sanitized operational failure log. +- HTTP provider hitches retain the status code but replace the response body with a stable content-free summary before entering the machine tape. +- HTTP 400 remains non-retryable at the RCM transport boundary so a host workflow can select a bounded request-shrink or non-thinking fallback instead of replaying the same invalid request. +- Existing stream consumers remain compatible because every field is additive. + +## Plan + +Derive shape counters from the normalized `CompletionRequest`, parse provider error envelopes into allowlisted identifiers, carry sanitized diagnostics alongside the completion result, and project them into tracing hooks and JSON-line output. + +## Verification + +- Machine tests cover request counters, tool-history anomalies, provider classification, and diagnostic redaction. +- CLI stream tests prove the additive JSON contract and legacy-consumer compatibility. +- Workspace tests, formatting, Clippy, and repository gates pass. + +## Evidence + +- [Request-shape and redaction tests](../../crates/machine/tests/completion.rs) +- [Completion telemetry classification tests](../../crates/machine/tests/completion_telemetry.rs) +- [Additive CLI stream contract tests](../../crates/cli/tests/run_stream.rs) From e3164ac29be6b18c62d28c5145eeeb2f5370f2f1 Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:09:24 +0800 Subject: [PATCH 07/12] fix(machine): send explicit thinking mode (#194) Preserve whether RCM model declarations explicitly enable or disable thinking. Emit the OpenAI-compatible request extension only for explicit OpenAI declarations. --- .repo-seed/manifest.json | 2 +- Cargo.lock | 2 +- crates/cli/Cargo.toml | 2 +- crates/cli/src/rcm/ast.rs | 2 + crates/cli/src/rcm/compile.rs | 3 + crates/cli/src/rcm/parser.rs | 3 + crates/cli/tests/compile.rs | 37 +++++++++++ crates/cli/tests/parser.rs | 23 +++++++ crates/machine/src/completion.rs | 14 ++++- crates/machine/src/model.rs | 19 ++++++ crates/machine/tests/completion.rs | 33 ++++++++++ .../0005-explicit-openai-thinking-mode.md | 63 +++++++++++++++++++ ...001-explicit-thinking-mode-was-not-sent.md | 30 +++++++++ docs/postmortems/README.md | 1 + .../0002-explicit-openai-thinking-mode.md | 36 +++++++++++ 15 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 docs/decisions/0005-explicit-openai-thinking-mode.md create mode 100644 docs/postmortems/0001-explicit-thinking-mode-was-not-sent.md create mode 100644 docs/specs/0002-explicit-openai-thinking-mode.md diff --git a/.repo-seed/manifest.json b/.repo-seed/manifest.json index 3cbc8137..edd53311 100644 --- a/.repo-seed/manifest.json +++ b/.repo-seed/manifest.json @@ -258,7 +258,7 @@ }, { "path": "docs/postmortems/README.md", - "sha256": "60f73daf42810198b809c54a071c42357187fa4bca7ab376eb1322e0cb897889", + "sha256": "20bf7e30cc2b66d150f4ac767b7562ff22be345f3c41189f740cbe11c51c4d84", "category": "docs", "capability": "postmortems" }, diff --git a/Cargo.lock b/Cargo.lock index 0b0f64c0..dabc2e20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "0.2.21" +version = "0.2.22" dependencies = [ "accelerator", "anyhow", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 55eefca5..bd4f5b2f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cli" -version = "0.2.21" +version = "0.2.22" edition = "2024" publish = false diff --git a/crates/cli/src/rcm/ast.rs b/crates/cli/src/rcm/ast.rs index 92ad063c..4d7e4936 100644 --- a/crates/cli/src/rcm/ast.rs +++ b/crates/cli/src/rcm/ast.rs @@ -79,6 +79,8 @@ pub struct ModelDef { pub modalities_output: Vec<String>, pub headers: HashMap<String, String>, pub thinking: bool, + /// Whether the source explicitly declared `thinking`, including `false`. + pub thinking_configured: bool, /// Per-request timeout in seconds. `None` uses the engine default. pub timeout: Option<u64>, } diff --git a/crates/cli/src/rcm/compile.rs b/crates/cli/src/rcm/compile.rs index 07ebdf11..29a5475f 100644 --- a/crates/cli/src/rcm/compile.rs +++ b/crates/cli/src/rcm/compile.rs @@ -356,6 +356,9 @@ fn build_models(defs: &[ast::ModelDef]) -> Result<Vec<Model>, String> { thinking: def.thinking, ..Default::default() }; + if def.thinking_configured && protocol == Protocol::OpenAI { + model.set_openai_thinking_mode(def.thinking); + } if let Some(timeout) = def.timeout { model.timeout = timeout; } diff --git a/crates/cli/src/rcm/parser.rs b/crates/cli/src/rcm/parser.rs index 8d42f79d..02fed90b 100644 --- a/crates/cli/src/rcm/parser.rs +++ b/crates/cli/src/rcm/parser.rs @@ -292,6 +292,7 @@ impl Parser { let mut modalities_output = Vec::new(); let mut headers = std::collections::HashMap::new(); let mut thinking = false; + let mut thinking_configured = false; let mut timeout = None; while !self.eat(Token::RBrace) { @@ -320,6 +321,7 @@ impl Parser { thinking = value.parse().map_err(|_| { format!("thinking must be \"true\" or \"false\", got: {}", value) })?; + thinking_configured = true; } "timeout" => { let value = self.expect_string()?; @@ -349,6 +351,7 @@ impl Parser { modalities_output, headers, thinking, + thinking_configured, timeout, }) } diff --git a/crates/cli/tests/compile.rs b/crates/cli/tests/compile.rs index 057024db..e2098814 100644 --- a/crates/cli/tests/compile.rs +++ b/crates/cli/tests/compile.rs @@ -148,6 +148,43 @@ fn compile_selects_resource_pools_without_initial_activation() { assert!(state.resources.active_tools.is_empty()); } +#[test] +fn compile_preserves_explicit_openai_thinking_modes() { + let source = r#" + name = "thinking modes" + model enabled { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + thinking = "true" + } + model disabled { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + thinking = "false" + } + model implicit { + protocol = "openai" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + } + accelerator { + models = ["enabled", "disabled", "implicit"] + } + "#; + + let accelerator = compile_result(source).unwrap(); + let models = &primitive_state(&accelerator).resources.models; + + assert_eq!(models["enabled"].openai_thinking_mode(), Some(true)); + assert_eq!(models["disabled"].openai_thinking_mode(), Some(false)); + assert_eq!(models["implicit"].openai_thinking_mode(), None); +} + #[test] fn compile_keeps_no_tools_when_only_prompts_are_supplied() { let source = r#" diff --git a/crates/cli/tests/parser.rs b/crates/cli/tests/parser.rs index b785b59c..7fbf5157 100644 --- a/crates/cli/tests/parser.rs +++ b/crates/cli/tests/parser.rs @@ -291,6 +291,7 @@ fn parse_model_with_thinking_true() { file.models[0].thinking, "thinking = \"true\" must be parsed as bool true" ); + assert!(file.models[0].thinking_configured); } #[test] @@ -313,6 +314,28 @@ fn parse_model_without_thinking_defaults_to_false() { !file.models[0].thinking, "models without `thinking =` default to false (no reasoning_content pollution)" ); + assert!(!file.models[0].thinking_configured); +} + +#[test] +fn parse_model_with_thinking_false_remembers_explicit_mode() { + let source = r#" + name = "deepseek-non-thinking" + model deepseek { + protocol = "openai" + credentials = { env = "DEEPSEEK_API_KEY" } + limit = { context = "100000", output = "4096" } + modalities = { input = ["text"], output = ["text"] } + thinking = "false" + } + accelerator { + models = ["deepseek"] + } + "#; + + let file = rcm::parse(source).unwrap(); + assert!(!file.models[0].thinking); + assert!(file.models[0].thinking_configured); } #[test] diff --git a/crates/machine/src/completion.rs b/crates/machine/src/completion.rs index f9dab1cb..6fabffa9 100644 --- a/crates/machine/src/completion.rs +++ b/crates/machine/src/completion.rs @@ -681,6 +681,18 @@ pub fn build_request( ) })?; + let additional_params = if model.protocol == Protocol::OpenAI { + model.openai_thinking_mode().map(|enabled| { + serde_json::json!({ + "thinking": { + "type": if enabled { "enabled" } else { "disabled" }, + } + }) + }) + } else { + None + }; + Ok(CompletionRequest { model: None, preamble: None, @@ -690,7 +702,7 @@ pub fn build_request( temperature: model.temperature, max_tokens: model.limit.as_ref().map(|l| l.output), tool_choice: None, - additional_params: None, + additional_params, output_schema: None, }) } diff --git a/crates/machine/src/model.rs b/crates/machine/src/model.rs index 3e9b04e8..4f433585 100644 --- a/crates/machine/src/model.rs +++ b/crates/machine/src/model.rs @@ -3,6 +3,7 @@ use serde_json::Value; use std::collections::HashMap; pub const DEFAULT_MODEL_TIMEOUT_SECS: u64 = 1_800; +const OPENAI_THINKING_MODE_EXTRA: &str = "openai_thinking_mode"; /// LLM configuration. /// @@ -60,6 +61,24 @@ impl Default for Model { } } +impl Model { + /// Configure the OpenAI-compatible `thinking.type` request parameter. + /// + /// This is opt-in so generic OpenAI-compatible providers that do not + /// recognize the extension keep receiving the historical request shape. + pub fn set_openai_thinking_mode(&mut self, enabled: bool) { + self.extra + .insert(OPENAI_THINKING_MODE_EXTRA.to_string(), Value::Bool(enabled)); + } + + /// Return an explicitly configured OpenAI-compatible thinking mode. + pub fn openai_thinking_mode(&self) -> Option<bool> { + self.extra + .get(OPENAI_THINKING_MODE_EXTRA) + .and_then(Value::as_bool) + } +} + #[cfg(test)] mod tests { use super::Model; diff --git a/crates/machine/tests/completion.rs b/crates/machine/tests/completion.rs index 7cf7e93b..747540b4 100644 --- a/crates/machine/tests/completion.rs +++ b/crates/machine/tests/completion.rs @@ -495,6 +495,39 @@ fn build_request_passes_model_temperature_and_max_tokens() { assert_eq!(req.max_tokens, Some(4096)); } +#[test] +fn build_request_sends_explicit_openai_thinking_mode() { + for (enabled, expected) in [(true, "enabled"), (false, "disabled")] { + let mut model = dummy_model(); + model.set_openai_thinking_mode(enabled); + + let request = build_request(&[Message::user("hi")], &[], &model).expect("builds"); + + assert_eq!( + request.additional_params, + Some(json!({"thinking": {"type": expected}})), + ); + } +} + +#[test] +fn build_request_omits_openai_thinking_mode_when_not_configured() { + let request = build_request(&[Message::user("hi")], &[], &dummy_model()).expect("builds"); + + assert_eq!(request.additional_params, None); +} + +#[test] +fn build_request_does_not_send_openai_thinking_extension_to_other_protocols() { + let mut model = dummy_model(); + model.protocol = Protocol::Anthropic; + model.set_openai_thinking_mode(true); + + let request = build_request(&[Message::user("hi")], &[], &model).expect("builds"); + + assert_eq!(request.additional_params, None); +} + #[test] fn request_shape_diagnostics_count_structure_without_retaining_content() { let sensitive_text = "PRIVATE PAPER BODY MUST NOT ENTER DIAGNOSTICS"; diff --git a/docs/decisions/0005-explicit-openai-thinking-mode.md b/docs/decisions/0005-explicit-openai-thinking-mode.md new file mode 100644 index 00000000..3d5fe894 --- /dev/null +++ b/docs/decisions/0005-explicit-openai-thinking-mode.md @@ -0,0 +1,63 @@ +# Preserve explicit OpenAI-compatible thinking declarations + +## Status +Accepted +Class: bug-fix + +## Context and Problem Statement + +OpenAI-compatible providers can expose thinking control through a non-standard `thinking.type` request object and can require every prior reasoning block to be replayed when tools are present. The RCM grammar accepted `thinking = "true"` or `"false"`, but compilation reduced both omission and explicit false to one Boolean default and used the value only while encoding history. A provider whose thinking mode defaults to enabled therefore could not be deterministically switched to non-thinking mode. + +## Decision Drivers + +- Explicit enabled and disabled declarations must produce deterministic wire behavior. +- Model declarations that omit the extension must remain compatible with OpenAI-compatible providers that reject unknown fields. +- The machine crate must not contain provider names, endpoints, or presets. +- Programmatic callers and the protobuf model contract must remain source and wire compatible. +- Thinking-history replay and provider mode selection must agree. + +## Considered Options + +- Send a `thinking` object for every OpenAI-compatible model based on the existing Boolean. +- Detect DeepSeek endpoints inside the machine crate and send the object only there. +- Preserve whether the RCM declaration is explicit and compile that choice into reserved model metadata. +- Move the affected workflow to an Anthropic-compatible endpoint. + +## Decision Outcome + +The parser preserves declaration presence, and the compiler records an explicit enabled or disabled OpenAI-compatible thinking mode in model metadata. Request construction sends `thinking.type` only when this metadata exists. The existing public `Model.thinking` field continues to control reasoning-history encoding; additive methods expose explicit mode configuration to programmatic callers. + +This beats unconditional emission because omitted declarations keep the historical request shape, and it beats endpoint detection because the machine remains provider-neutral. The trade-off is a small internal metadata contract shared by the CLI compiler and machine request builder. + +## Pros and Cons of the Options + +### Send the extension unconditionally + +- Good: the implementation is small. +- Bad: generic OpenAI-compatible providers may reject the non-standard field. +- Bad: omission can no longer preserve historical provider defaults. + +### Detect provider endpoints + +- Good: known DeepSeek requests get the required shape automatically. +- Bad: provider policy and hostname matching leak into the machine layer. +- Bad: gateways and alternate endpoints remain ambiguous. + +### Preserve explicit declaration presence + +- Good: enabled, disabled, and omitted remain distinct. +- Good: request behavior follows user intent without provider detection. +- Good: existing programmatic and protobuf callers retain their current behavior unless they opt in. +- Trade-off: the compiler and machine share one reserved metadata key behind typed methods. + +### Use the Anthropic-compatible endpoint + +- Good: some clients can avoid OpenAI-specific reasoning-history compatibility. +- Bad: it changes the protocol for every workflow and does not repair the false declaration contract. +- Bad: it requires separate canary and tool-history validation. + +## Links + +- [DeepSeek thinking-mode guide](https://api-docs.deepseek.com/guides/thinking_mode/) +- [DeepSeek Chat Completions API](https://api-docs.deepseek.com/api/create-chat-completion/) +- [Explicit thinking-mode specification](../specs/0002-explicit-openai-thinking-mode.md) diff --git a/docs/postmortems/0001-explicit-thinking-mode-was-not-sent.md b/docs/postmortems/0001-explicit-thinking-mode-was-not-sent.md new file mode 100644 index 00000000..c52e40fb --- /dev/null +++ b/docs/postmortems/0001-explicit-thinking-mode-was-not-sent.md @@ -0,0 +1,30 @@ +# Explicit thinking mode was not sent + +Artifact-Version: 1 + +## Executive summary + +A production release canary received HTTP 400 from a thinking-capable OpenAI-compatible provider because RCM treated an explicit `thinking` declaration only as a history-encoding hint and did not send the corresponding request parameter; omission and explicit false were also indistinguishable. The permanent correction preserves declaration presence, emits deterministic enabled or disabled wire control only for explicit OpenAI declarations, and tests all three states. + +## Summary + +The host application defined a thinking-mode protocol canary and a non-thinking fallback. The RCM grammar accepted both declarations, but the compiled model retained only a Boolean whose default was false. Request construction never emitted the provider's `thinking.type` object, so the fallback could not override a provider default and the canary could not prove the declared mode was active. + +## Timeline + +- A release containing provider request-shape diagnostics passed unit and hermetic host tests. +- The immutable host image was published and its additive database migration succeeded. +- The production candidate canary received HTTP 400 before any service or CloudFormation update. +- Inspection found that explicit mode intent disappeared between parsing and request construction. +- The grammar/compiler boundary and completion request tests were extended to distinguish enabled, disabled, and omitted modes. + +## Root cause + +The `thinking` grammar field served two responsibilities: selecting provider mode and deciding whether missing reasoning should be synthesized while replaying tool history. The AST stored a plain Boolean, so explicit false collapsed into the same value as omission, and the request builder used neither state to construct provider parameters. Tests covered reasoning replay but did not assert the serialized mode-selection request. + +## Guardrails + +- [Explicit thinking-mode specification](../specs/0002-explicit-openai-thinking-mode.md) owns the observable contract. +- [Thinking-mode decision](../decisions/0005-explicit-openai-thinking-mode.md) prevents provider hostname logic from entering the machine layer. +- [Parser and compiler tests](../../crates/cli/tests/parser.rs) distinguish explicit declarations from omission. +- [Completion request tests](../../crates/machine/tests/completion.rs) assert enabled, disabled, implicit, and non-OpenAI behavior. diff --git a/docs/postmortems/README.md b/docs/postmortems/README.md index e91a7614..b64fb2d6 100644 --- a/docs/postmortems/README.md +++ b/docs/postmortems/README.md @@ -23,3 +23,4 @@ Name files `NNNN-short-title.md` (sequential number) and put `Artifact-Version: ## Index - [Provider 400 observability gap](0000-provider-400-observability-gap.md) +- [Explicit thinking mode was not sent](0001-explicit-thinking-mode-was-not-sent.md) diff --git a/docs/specs/0002-explicit-openai-thinking-mode.md b/docs/specs/0002-explicit-openai-thinking-mode.md new file mode 100644 index 00000000..eaf3936d --- /dev/null +++ b/docs/specs/0002-explicit-openai-thinking-mode.md @@ -0,0 +1,36 @@ +# Explicit OpenAI-compatible thinking mode + +Artifact-Version: 1 +Status: Implemented + +## Intent + +An RCM model declaration that explicitly enables or disables thinking must send the matching OpenAI-compatible request parameter while preserving the historical request shape for declarations that omit the field. This makes a non-thinking fallback actually disable providers whose thinking mode defaults to enabled and makes canary behavior deterministic without embedding provider hostnames in the machine crate. + +## Contract + +- The release artifact carrying this contract is versioned `0.2.22`; downstream pins must use its published archive checksum. +- `thinking = "true"` on an OpenAI-protocol model sends `thinking.type = "enabled"` and preserves reasoning content for later tool-bearing requests. +- `thinking = "false"` on an OpenAI-protocol model sends `thinking.type = "disabled"` and does not synthesize reasoning history. +- Omitting `thinking` sends no provider-specific thinking parameter and retains the historical default behavior. +- Anthropic and Gemini requests never receive the OpenAI-compatible `thinking` object through this mechanism. +- Programmatic `Model` callers remain source compatible; the existing `thinking` field retains its history-encoding role and additive methods opt into an explicit OpenAI-compatible mode. +- No provider endpoint, model name, prompt content, tool result, or credential is used to select the request shape. + +## Plan + +Preserve declaration presence in the CLI AST, compile explicit OpenAI declarations into reserved model metadata, and project that metadata into `CompletionRequest.additional_params` as an enabled or disabled `thinking` object. + +## Verification + +- Parser tests distinguish explicit false from omission. +- Compiler tests retain enabled, disabled, and implicit modes. +- Machine tests assert the exact request parameter and its absence for implicit or non-OpenAI models. +- Workspace tests, documentation tests, formatting, Clippy, and repository gates pass. + +## Evidence + +- [Parser contract tests](../../crates/cli/tests/parser.rs) +- [Compiler contract tests](../../crates/cli/tests/compile.rs) +- [Request construction tests](../../crates/machine/tests/completion.rs) +- [Thinking-mode request decision](../decisions/0005-explicit-openai-thinking-mode.md) From a5694355cb098ee6d428a55e5b7b1dab744f45ed Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:45:26 +0800 Subject: [PATCH 08/12] fix(machine): retain thinking tool-call content (#196) --- .repo-seed/manifest.json | 2 +- Cargo.lock | 2 +- crates/cli/Cargo.toml | 2 +- crates/machine/src/completion.rs | 11 ++++ crates/machine/tests/completion.rs | 30 +++++++++ ...006-preserve-thinking-tool-call-content.md | 63 +++++++++++++++++++ ...-thinking-tool-call-content-was-omitted.md | 30 +++++++++ docs/postmortems/README.md | 1 + docs/specs/0003-thinking-tool-call-content.md | 34 ++++++++++ 9 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 docs/decisions/0006-preserve-thinking-tool-call-content.md create mode 100644 docs/postmortems/0002-thinking-tool-call-content-was-omitted.md create mode 100644 docs/specs/0003-thinking-tool-call-content.md diff --git a/.repo-seed/manifest.json b/.repo-seed/manifest.json index edd53311..43665be6 100644 --- a/.repo-seed/manifest.json +++ b/.repo-seed/manifest.json @@ -258,7 +258,7 @@ }, { "path": "docs/postmortems/README.md", - "sha256": "20bf7e30cc2b66d150f4ac767b7562ff22be345f3c41189f740cbe11c51c4d84", + "sha256": "d5ddcfd1ad66efd11f1d4ad4d183c2d6c72100b48ed83a982d2addda2d391de9", "category": "docs", "capability": "postmortems" }, diff --git a/Cargo.lock b/Cargo.lock index dabc2e20..7a81798e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "0.2.22" +version = "0.2.23" dependencies = [ "accelerator", "anyhow", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index bd4f5b2f..27d56c86 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cli" -version = "0.2.22" +version = "0.2.23" edition = "2024" publish = false diff --git a/crates/machine/src/completion.rs b/crates/machine/src/completion.rs index 6fabffa9..88edf5d9 100644 --- a/crates/machine/src/completion.rs +++ b/crates/machine/src/completion.rs @@ -368,6 +368,9 @@ pub fn encode_context(fragments: &[Fragment], thinking: bool) -> Vec<Message> { let has_leading_text = tool_start > index; if (has_leading_text || tool_call_count > 1) && tool_call_count == tool_results.len() { + if thinking && !has_leading_text { + assistant_content.push(AssistantContent::text("")); + } assistant_content.push(AssistantContent::Reasoning(Reasoning::new( shared_reasoning, ))); @@ -889,6 +892,14 @@ pub fn encode(frag: &Fragment, thinking: bool) -> Option<Message> { &tc.name, tc.arguments.clone(), )); + // Source: DeepSeek "Thinking Mode" tool-call contract — + // https://api-docs.deepseek.com/guides/thinking_mode/ + // DeepSeek requires assistant `content` beside thinking-mode tool + // calls. Rig omits the wire field for an empty text collection, so + // retain an explicit empty item when no visible text was emitted. + if thinking { + content.push(AssistantContent::text("")); + } // Echo the model's original reasoning when present — thinking-mode // providers (DeepSeek, Kimi) validate that assistant tool-call // turns carry the same `reasoning_content` they previously emitted. diff --git a/crates/machine/tests/completion.rs b/crates/machine/tests/completion.rs index 747540b4..0e7cf572 100644 --- a/crates/machine/tests/completion.rs +++ b/crates/machine/tests/completion.rs @@ -4,6 +4,9 @@ use machine::completion::{ use machine::{Content, Fragment, Limit, Model, Protocol, Role}; use rig::completion::message::{Reasoning, Text as RigText, ToolCall as RigToolCall, ToolFunction}; use rig::completion::{AssistantContent, Message}; +use rig::providers::openai::completion::{ + CompletionRequest as OpenAICompletionRequest, OpenAIRequestParams, +}; use serde_json::json; fn tool_call_fragment() -> Fragment { @@ -292,6 +295,33 @@ fn encode_tool_call_emits_stored_reasoning_not_placeholder() { } } +#[test] +fn thinking_tool_call_replay_keeps_openai_content_field() { + let fragments = vec![ + Fragment::tool_call("call_x", "shell", json!({"command": "ls"})) + .with_reasoning("the user asked for a directory listing"), + Fragment::tool_result("call_x", "file.txt", None), + ]; + let messages = encode_context(&fragments, true); + let mut model = dummy_model(); + model.set_openai_thinking_mode(true); + let request = build_request(&messages, &[], &model).expect("request builds"); + let wire_request = OpenAICompletionRequest::try_from(OpenAIRequestParams { + model: "deepseek-v4-flash".into(), + request, + strict_tools: false, + tool_result_array_content: false, + }) + .expect("OpenAI request converts"); + let wire_json = serde_json::to_value(wire_request).expect("OpenAI request serializes"); + + assert_ne!( + wire_json["messages"][0]["content"], + serde_json::Value::Null, + "thinking tool-call history must retain the required assistant content field", + ); +} + /// Symmetric: text-only assistant fragments never carry reasoning, even when /// `thinking=true`. The placeholder is tool-call-only. #[test] diff --git a/docs/decisions/0006-preserve-thinking-tool-call-content.md b/docs/decisions/0006-preserve-thinking-tool-call-content.md new file mode 100644 index 00000000..3134ace2 --- /dev/null +++ b/docs/decisions/0006-preserve-thinking-tool-call-content.md @@ -0,0 +1,63 @@ +# Preserve content in thinking tool-call history + +## Status +Accepted +Class: bug-fix + +## Context and Problem Statement + +DeepSeek thinking-mode requests with tools require prior assistant messages to retain `content`, `reasoning_content`, and `tool_calls`. RCM preserved the reasoning and calls, but Rig 0.36 omitted the OpenAI `content` field when an assistant tool-call turn had no visible text. The first mixed-text tool turn could therefore succeed while a later pure tool turn failed with HTTP 400. + +## Decision Drivers + +- The actual OpenAI wire request must satisfy the provider's multi-turn tool-history contract. +- The fix must not fabricate visible assistant prose or alter captured reasoning. +- Non-thinking and non-OpenAI request shapes must remain stable. +- RCM must not branch on provider hostnames or model names. +- The downstream production canary must exercise the same shipped entry path. + +## Considered Options + +- Preserve an empty text item for explicitly thinking tool-call messages before OpenAI conversion. +- Upgrade Rig and rely on a newer provider-conversion contract. +- Route the affected workflows through DeepSeek's Anthropic-compatible endpoint. +- Detect DeepSeek hosts inside the machine request builder. + +## Decision Outcome + +RCM preserves an explicit empty text item only while encoding tool-call history with the existing thinking-history flag. Rig therefore serializes a present `content` field alongside the original reasoning and tool calls. The item carries no visible prose, and the existing explicit model declaration remains the capability signal. + +This beats an immediate Rig upgrade because the narrow compatibility correction is independently testable without coupling an incident fix to a multi-version dependency migration. It beats a protocol switch because the existing OpenAI workflows and fallback remain valid. It gives up relying exclusively on the provider adapter to supply required empty fields. + +## Pros and Cons of the Options + +### Preserve an empty text item + +- Good: fixes the exact omitted wire field at the smallest owned seam. +- Good: keeps model names and endpoints out of the machine layer. +- Good: permits a serialized-request regression test against the pinned Rig version. +- Trade-off: RCM carries one compatibility item that a future Rig upgrade may make redundant. + +### Upgrade Rig + +- Good: a provider adapter can centralize wire compatibility. +- Bad: the available upgrade spans several Rig releases and expands the regression surface during a production incident. +- Bad: adoption still requires proof that the new adapter retains empty assistant content. + +### Use the Anthropic-compatible endpoint + +- Good: avoids the affected OpenAI conversion path. +- Bad: changes protocol behavior across every affected workflow and requires a separate compatibility rollout. +- Bad: does not repair existing OpenAI-compatible users. + +### Detect DeepSeek endpoints + +- Good: scopes the workaround to one known provider. +- Bad: gateways and alternate endpoints make hostname policy incomplete. +- Bad: provider identity would leak into the provider-neutral machine layer. + +## Links + +- [DeepSeek thinking-mode tool-call contract](https://api-docs.deepseek.com/guides/thinking_mode/) +- [DeepSeek Chat Completions assistant message](https://api-docs.deepseek.com/api/create-chat-completion/) +- [Thinking tool-call content specification](../specs/0003-thinking-tool-call-content.md) diff --git a/docs/postmortems/0002-thinking-tool-call-content-was-omitted.md b/docs/postmortems/0002-thinking-tool-call-content-was-omitted.md new file mode 100644 index 00000000..499f6d2e --- /dev/null +++ b/docs/postmortems/0002-thinking-tool-call-content-was-omitted.md @@ -0,0 +1,30 @@ +# Thinking tool-call content was omitted + +Artifact-Version: 1 + +## Executive summary + +A production release canary received HTTP 400 on a later DeepSeek thinking-mode tool turn because the replayed assistant message retained its reasoning and tool call but omitted the required `content` field when no visible text existed. Intermediate-message tests missed the provider conversion step; the permanent correction retains an empty content item and asserts the serialized OpenAI request. + +## Summary + +The fixed canary performs two sequential filesystem tool calls before returning a final response. Its first tool turn included visible assistant text and succeeded. A later pure tool-call turn was replayed through Rig 0.36 with an empty assistant content collection, which Serde omitted from the OpenAI request even though the reasoning and tool-call fields remained present. + +## Timeline + +- Reasoning replay and explicit thinking-mode selection were released with unit coverage. +- The downstream immutable Survey image passed hermetic tests and entered the production candidate gate. +- The multi-turn provider canary failed with HTTP 400 and content-free diagnostics classified the request as `thinking_tool_history`. +- A serialized-request regression reproduced the absent field without provider credentials or user content. +- The history encoder retained an explicit empty content item, and the provider canary became the adoption gate for the corrected release. + +## Root cause + +RCM tests asserted that the provider-neutral assistant message contained its tool call and reasoning. They did not inspect the request after Rig's OpenAI conversion. Rig represented assistant content as a collection and skipped the field when that collection was empty, while the provider requires the field to remain present beside thinking-mode tool calls. + +## Guardrails + +- [Serialized OpenAI request regression](../../crates/machine/tests/completion.rs) asserts that a pure thinking tool-call turn retains `content` after provider conversion. +- [Thinking tool-call content specification](../specs/0003-thinking-tool-call-content.md) owns the observable compatibility contract. +- [Request-shape decision](../decisions/0006-preserve-thinking-tool-call-content.md) records why the compatibility item lives at the RCM history boundary. +- The downstream release workflow runs a live two-tool-turn provider canary before any production stack update. diff --git a/docs/postmortems/README.md b/docs/postmortems/README.md index b64fb2d6..40b1931d 100644 --- a/docs/postmortems/README.md +++ b/docs/postmortems/README.md @@ -24,3 +24,4 @@ Name files `NNNN-short-title.md` (sequential number) and put `Artifact-Version: - [Provider 400 observability gap](0000-provider-400-observability-gap.md) - [Explicit thinking mode was not sent](0001-explicit-thinking-mode-was-not-sent.md) +- [Thinking tool-call content was omitted](0002-thinking-tool-call-content-was-omitted.md) diff --git a/docs/specs/0003-thinking-tool-call-content.md b/docs/specs/0003-thinking-tool-call-content.md new file mode 100644 index 00000000..9ca79b79 --- /dev/null +++ b/docs/specs/0003-thinking-tool-call-content.md @@ -0,0 +1,34 @@ +# Thinking tool-call assistant content + +Artifact-Version: 1 +Status: Implemented + +## Intent + +An explicitly thinking OpenAI-compatible model must replay assistant tool-call turns with every provider-required sibling field. A tool-call turn that contains no visible text must retain a present assistant `content` field together with its original `reasoning_content` and tool calls, without inventing model-visible prose or selecting behavior from a provider hostname. + +## Contract + +- The release artifact carrying this contract is versioned `0.2.23`; downstream pins must use its published archive checksum. +- A replayed tool-call assistant message for `thinking = "true"` always serializes a present, non-null `content` field, including when the provider emitted no visible text. +- The compatibility value is an empty text item and does not add visible prose to the model history. +- Stored reasoning is replayed unchanged; the compatibility field does not replace, truncate, or synthesize reasoning. +- Non-thinking history retains its existing request shape. +- Provider endpoints and model names do not participate in the decision. + +## Plan + +Keep an explicit empty text item in thinking-mode assistant tool-call messages before Rig converts the provider-neutral history into the OpenAI wire request. Cover the serialized OpenAI request, rather than only the intermediate RCM message, with a regression test. + +## Verification + +- The regression test demonstrates that the released adapter omitted `content` before the fix and that the corrected wire request contains it. +- Machine tests cover single, mixed-text, parallel, and failed tool-call replay. +- Workspace tests, documentation tests, formatting, Clippy, and repository gates pass. +- A live DeepSeek multi-turn tool canary completes before a downstream release adopts the new archive. + +## Evidence + +- [Serialized request regression test](../../crates/machine/tests/completion.rs) +- [Request-shape decision](../decisions/0006-preserve-thinking-tool-call-content.md) +- [Escaped-failure postmortem](../postmortems/0002-thinking-tool-call-content-was-omitted.md) From cef2c906c42c3d4651355eea1d8df3f4ab787280 Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:16:49 +0800 Subject: [PATCH 09/12] fix(deepseek): use provider-native transport (#198) --- .repo-seed/manifest.json | 4 +- Cargo.lock | 2 +- crates/accelerator/src/provider.rs | 2 +- crates/accelerator/tests/provider.rs | 2 + crates/cli/Cargo.toml | 2 +- crates/cli/src/rcm/compile.rs | 5 +- crates/cli/tests/compile.rs | 13 +- crates/machine/src/completion.rs | 47 ++++- crates/machine/src/model.rs | 47 +++-- crates/machine/tests/completion.rs | 170 +++++++++++++++++- crates/server/src/decode.rs | 13 ++ docs/architecture.md | 2 + ...006-preserve-thinking-tool-call-content.md | 3 +- ...-use-provider-native-deepseek-transport.md | 66 +++++++ ...t-presence-did-not-prove-deepseek-shape.md | 31 ++++ docs/postmortems/README.md | 1 + docs/specs/0003-thinking-tool-call-content.md | 2 +- docs/specs/0004-deepseek-native-transport.md | 40 +++++ 18 files changed, 424 insertions(+), 28 deletions(-) create mode 100644 docs/decisions/0007-use-provider-native-deepseek-transport.md create mode 100644 docs/postmortems/0003-content-presence-did-not-prove-deepseek-shape.md create mode 100644 docs/specs/0004-deepseek-native-transport.md diff --git a/.repo-seed/manifest.json b/.repo-seed/manifest.json index 43665be6..c8a509d3 100644 --- a/.repo-seed/manifest.json +++ b/.repo-seed/manifest.json @@ -77,7 +77,7 @@ }, { "path": "docs/architecture.md", - "sha256": "135e9e3812edafbc1af5c849660ea1416aab0bbb51aeedd1cebea72d0d6aca19", + "sha256": "8aff1a006b838161fb55274c8f192719f37a93fd04c2752b68a4cc26ec123c9d", "category": "docs", "capability": "baseline" }, @@ -258,7 +258,7 @@ }, { "path": "docs/postmortems/README.md", - "sha256": "d5ddcfd1ad66efd11f1d4ad4d183c2d6c72100b48ed83a982d2addda2d391de9", + "sha256": "f6b4879a58d15e90f9c899073554673d616be160795cfa2004c0c2145e859a87", "category": "docs", "capability": "postmortems" }, diff --git a/Cargo.lock b/Cargo.lock index 7a81798e..442178bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "0.2.23" +version = "0.2.24" dependencies = [ "accelerator", "anyhow", diff --git a/crates/accelerator/src/provider.rs b/crates/accelerator/src/provider.rs index 7909a4be..812ee1f4 100644 --- a/crates/accelerator/src/provider.rs +++ b/crates/accelerator/src/provider.rs @@ -42,7 +42,7 @@ pub const PROVIDERS: &[Provider] = &[ }, Provider { key: "deepseek", - protocol: Protocol::OpenAI, + protocol: Protocol::DeepSeek, endpoint: "https://api.deepseek.com", env_var: "DEEPSEEK_API_KEY", default_model: "deepseek-v4-flash", diff --git a/crates/accelerator/tests/provider.rs b/crates/accelerator/tests/provider.rs index c36e9903..f133610c 100644 --- a/crates/accelerator/tests/provider.rs +++ b/crates/accelerator/tests/provider.rs @@ -1,6 +1,7 @@ //! Tests for the provider table and `resolve_model`. use accelerator::provider::{self, ResolveError}; +use machine::Protocol; use std::sync::Mutex; /// Tests in this file all mutate process-wide environment variables. Cargo @@ -87,6 +88,7 @@ fn resolves_provider_only_to_default_model() { let model = provider::resolve_model(Some("deepseek")).expect("resolve"); assert_eq!(model.name, "deepseek/deepseek-v4-flash"); assert_eq!(model.endpoint.as_deref(), Some("https://api.deepseek.com")); + assert_eq!(model.protocol, Protocol::DeepSeek); } #[test] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 27d56c86..3bd15a4d 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cli" -version = "0.2.23" +version = "0.2.24" edition = "2024" publish = false diff --git a/crates/cli/src/rcm/compile.rs b/crates/cli/src/rcm/compile.rs index 29a5475f..38e853c7 100644 --- a/crates/cli/src/rcm/compile.rs +++ b/crates/cli/src/rcm/compile.rs @@ -356,8 +356,8 @@ fn build_models(defs: &[ast::ModelDef]) -> Result<Vec<Model>, String> { thinking: def.thinking, ..Default::default() }; - if def.thinking_configured && protocol == Protocol::OpenAI { - model.set_openai_thinking_mode(def.thinking); + if def.thinking_configured && matches!(protocol, Protocol::OpenAI | Protocol::DeepSeek) { + model.set_thinking_mode(def.thinking); } if let Some(timeout) = def.timeout { model.timeout = timeout; @@ -373,6 +373,7 @@ fn build_models(defs: &[ast::ModelDef]) -> Result<Vec<Model>, String> { fn parse_protocol(name: &str) -> Result<Protocol, String> { match name { "openai" => Ok(Protocol::OpenAI), + "deepseek" => Ok(Protocol::DeepSeek), "anthropic" => Ok(Protocol::Anthropic), "gemini" => Ok(Protocol::Gemini), _ => Err(format!("unknown protocol: {}", name)), diff --git a/crates/cli/tests/compile.rs b/crates/cli/tests/compile.rs index e2098814..c14602ab 100644 --- a/crates/cli/tests/compile.rs +++ b/crates/cli/tests/compile.rs @@ -149,7 +149,7 @@ fn compile_selects_resource_pools_without_initial_activation() { } #[test] -fn compile_preserves_explicit_openai_thinking_modes() { +fn compile_preserves_explicit_thinking_modes_for_supported_protocols() { let source = r#" name = "thinking modes" model enabled { @@ -172,8 +172,15 @@ fn compile_preserves_explicit_openai_thinking_modes() { limit = { context = "1000", output = "100" } modalities = { input = ["text"], output = ["text"] } } + model deepseek { + protocol = "deepseek" + credentials = { key = "REDACTED" } + limit = { context = "1000", output = "100" } + modalities = { input = ["text"], output = ["text"] } + thinking = "true" + } accelerator { - models = ["enabled", "disabled", "implicit"] + models = ["enabled", "disabled", "implicit", "deepseek"] } "#; @@ -183,6 +190,8 @@ fn compile_preserves_explicit_openai_thinking_modes() { assert_eq!(models["enabled"].openai_thinking_mode(), Some(true)); assert_eq!(models["disabled"].openai_thinking_mode(), Some(false)); assert_eq!(models["implicit"].openai_thinking_mode(), None); + assert_eq!(models["deepseek"].protocol, machine::Protocol::DeepSeek); + assert_eq!(models["deepseek"].thinking_mode(), Some(true)); } #[test] diff --git a/crates/machine/src/completion.rs b/crates/machine/src/completion.rs index 88edf5d9..c7d37874 100644 --- a/crates/machine/src/completion.rs +++ b/crates/machine/src/completion.rs @@ -172,6 +172,26 @@ pub async fn complete_with_diagnostics( .completion_model(wire_name); send(&endpoint, model, request).await } + Protocol::DeepSeek => { + // DeepSeek documents assistant `content` as string-or-null and + // requires the complete prior reasoning/tool-call response to be + // replayed. Its native Rig adapter owns that exact conversion. + // Sources: + // https://api-docs.deepseek.com/api/create-chat-completion/ + // https://api-docs.deepseek.com/guides/thinking_mode/ + let mut builder = rig::providers::deepseek::Client::builder() + .api_key(api_key) + .http_client(openai_http_client()); + if let Some(endpoint) = endpoint_url { + builder = builder.base_url(endpoint); + } + builder = apply_headers(builder, &model.headers); + let endpoint = builder + .build() + .expect("failed to build deepseek client") + .completion_model(wire_name); + send(&endpoint, model, request).await + } Protocol::Anthropic => { let mut builder = rig::providers::anthropic::Client::builder().api_key(api_key); if let Some(endpoint) = endpoint_url { @@ -684,16 +704,33 @@ pub fn build_request( ) })?; - let additional_params = if model.protocol == Protocol::OpenAI { - model.openai_thinking_mode().map(|enabled| { + let additional_params = match model.protocol { + Protocol::OpenAI => model.thinking_mode().map(|enabled| { serde_json::json!({ "thinking": { "type": if enabled { "enabled" } else { "disabled" }, } }) - }) - } else { - None + }), + Protocol::DeepSeek => { + let mut params = serde_json::Map::new(); + if let Some(enabled) = model.thinking_mode() { + params.insert( + "thinking".into(), + serde_json::json!({ + "type": if enabled { "enabled" } else { "disabled" }, + }), + ); + } + // Rig 0.36's native DeepSeek adapter does not project the generic + // CompletionRequest max_tokens field, so preserve the declared + // model limit through its flattened provider parameters. + if let Some(limit) = &model.limit { + params.insert("max_tokens".into(), limit.output.into()); + } + (!params.is_empty()).then_some(serde_json::Value::Object(params)) + } + Protocol::Anthropic | Protocol::Gemini => None, }; Ok(CompletionRequest { diff --git a/crates/machine/src/model.rs b/crates/machine/src/model.rs index 4f433585..2d092559 100644 --- a/crates/machine/src/model.rs +++ b/crates/machine/src/model.rs @@ -3,7 +3,7 @@ use serde_json::Value; use std::collections::HashMap; pub const DEFAULT_MODEL_TIMEOUT_SECS: u64 = 1_800; -const OPENAI_THINKING_MODE_EXTRA: &str = "openai_thinking_mode"; +const THINKING_MODE_EXTRA: &str = "openai_thinking_mode"; /// LLM configuration. /// @@ -62,41 +62,66 @@ impl Default for Model { } impl Model { + /// Configure an explicit provider thinking mode. + /// + /// The stored key retains its historical spelling for serialized-model + /// compatibility. Request construction applies it only to protocols that + /// support the `thinking.type` extension. + pub fn set_thinking_mode(&mut self, enabled: bool) { + self.extra + .insert(THINKING_MODE_EXTRA.to_string(), Value::Bool(enabled)); + } + + /// Return an explicitly configured provider thinking mode. + pub fn thinking_mode(&self) -> Option<bool> { + self.extra.get(THINKING_MODE_EXTRA).and_then(Value::as_bool) + } + /// Configure the OpenAI-compatible `thinking.type` request parameter. /// /// This is opt-in so generic OpenAI-compatible providers that do not /// recognize the extension keep receiving the historical request shape. pub fn set_openai_thinking_mode(&mut self, enabled: bool) { - self.extra - .insert(OPENAI_THINKING_MODE_EXTRA.to_string(), Value::Bool(enabled)); + self.set_thinking_mode(enabled); } /// Return an explicitly configured OpenAI-compatible thinking mode. pub fn openai_thinking_mode(&self) -> Option<bool> { - self.extra - .get(OPENAI_THINKING_MODE_EXTRA) - .and_then(Value::as_bool) + self.thinking_mode() } } #[cfg(test)] mod tests { - use super::Model; + use super::{Model, Protocol}; #[test] fn model_default_timeout_allows_thirty_minute_requests() { assert_eq!(Model::default().timeout, 1_800); } + + #[test] + fn deepseek_protocol_round_trips_through_serde() { + let serialized = serde_json::to_string(&Protocol::DeepSeek).expect("serializes"); + assert_eq!(serialized, "\"deepseek\""); + assert_eq!( + serde_json::from_str::<Protocol>(&serialized).expect("deserializes"), + Protocol::DeepSeek + ); + } } /// Wire protocol. /// -/// Three protocols are supported. Most providers are OpenAI-compatible; -/// use the `OpenAI` variant with a custom `endpoint`. +/// Four protocols are supported. Most providers are OpenAI-compatible; +/// use the `OpenAI` variant with a custom `endpoint`. DeepSeek has an +/// explicit variant because its assistant-message schema differs at the +/// thinking/tool-history boundary. /// /// | Protocol | Examples | /// |----------|---------| -/// | `OpenAI` | OpenAI, DeepSeek, Groq, Mistral, xAI, Ollama, OpenRouter ... | +/// | `OpenAI` | OpenAI, Groq, Mistral, xAI, Ollama, OpenRouter ... | +/// | `DeepSeek` | DeepSeek Chat Completions API | /// | `Anthropic` | Anthropic Claude | /// | `Gemini` | Google Gemini | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -105,6 +130,8 @@ pub enum Protocol { /// OpenAI Chat Completions API. #[default] OpenAI, + /// DeepSeek Chat Completions API. + DeepSeek, /// Anthropic Messages API. Anthropic, /// Google Gemini API. diff --git a/crates/machine/tests/completion.rs b/crates/machine/tests/completion.rs index 0e7cf572..a32fa71b 100644 --- a/crates/machine/tests/completion.rs +++ b/crates/machine/tests/completion.rs @@ -1,13 +1,16 @@ use machine::completion::{ - build_request, decode, encode, encode_context, request_shape_diagnostics, + build_request, complete, decode, encode, encode_context, request_shape_diagnostics, }; -use machine::{Content, Fragment, Limit, Model, Protocol, Role}; +use machine::{Content, Context, Fragment, Limit, Model, Overlay, Protocol, Resources, Role}; use rig::completion::message::{Reasoning, Text as RigText, ToolCall as RigToolCall, ToolFunction}; use rig::completion::{AssistantContent, Message}; use rig::providers::openai::completion::{ CompletionRequest as OpenAICompletionRequest, OpenAIRequestParams, }; use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; fn tool_call_fragment() -> Fragment { Fragment::tool_call("call_123", "shell", json!({"command": "ls"})) @@ -322,6 +325,135 @@ fn thinking_tool_call_replay_keeps_openai_content_field() { ); } +async fn deepseek_gateway() -> (String, JoinHandle<String>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test gateway should bind"); + let address = listener + .local_addr() + .expect("test gateway should expose its address"); + let handle = tokio::spawn(async move { + let (mut socket, _) = listener + .accept() + .await + .expect("test gateway should accept a request"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let count = socket + .read(&mut buffer) + .await + .expect("test gateway should read a request"); + assert!( + count > 0, + "completion request ended before its body arrived" + ); + request.extend_from_slice(&buffer[..count]); + let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(str::trim) + .and_then(|value| value.parse::<usize>().ok()) + }) + .expect("completion request should declare content length"); + break (header_end, content_length); + }; + let expected_length = header_end + 4 + content_length; + while request.len() < expected_length { + let count = socket + .read(&mut buffer) + .await + .expect("test gateway should finish reading the request"); + assert!(count > 0, "completion request body ended early"); + request.extend_from_slice(&buffer[..count]); + } + + let response_body = r#"{"choices":[{"index":0,"message":{"role":"assistant","content":"done","tool_calls":[],"reasoning_content":null},"logprobs":null,"finish_reason":"stop"}],"usage":{"completion_tokens":1,"prompt_tokens":1,"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":0,"total_tokens":2}}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + socket + .write_all(response.as_bytes()) + .await + .expect("test gateway should return a response"); + String::from_utf8(request).expect("completion request should be UTF-8") + }); + (format!("http://{address}"), handle) +} + +#[tokio::test] +async fn deepseek_transport_uses_exact_thinking_tool_history_shape() { + let (endpoint, received_request) = deepseek_gateway().await; + let mut model = Model { + name: "deepseek-v4-flash".into(), + protocol: Protocol::DeepSeek, + endpoint: Some(endpoint), + credentials: Some("test-secret".into()), + limit: Some(Limit { + context: 100_000, + input: None, + output: 4096, + }), + timeout: 5, + thinking: true, + ..Default::default() + }; + model.set_thinking_mode(true); + let mut resources = Resources::new().with_model(model); + resources + .use_model("deepseek-v4-flash") + .expect("test model should activate"); + let mut context = Context::new(); + context.append( + Fragment::tool_call("call_x", "shell", json!({"command": "ls"})) + .with_reasoning("the user asked for a directory listing"), + ); + context.append(Fragment::tool_result("call_x", "file.txt", None)); + + let (fragments, _) = complete(&context, &resources, &Overlay::default()).await; + assert_eq!(fragments[0].as_text(), Some("done")); + + let request = tokio::time::timeout(std::time::Duration::from_secs(2), received_request) + .await + .expect("test gateway should receive the request") + .expect("test gateway should finish cleanly"); + assert!(request.starts_with("POST /chat/completions HTTP/1.1")); + assert!( + request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: bearer test-secret")) + ); + let (_, body) = request + .split_once("\r\n\r\n") + .expect("completion request should contain an HTTP body"); + let payload: serde_json::Value = + serde_json::from_str(body).expect("completion request body should be JSON"); + + assert_eq!(payload["messages"][0]["content"], ""); + assert_eq!( + payload["messages"][0]["reasoning_content"], + "the user asked for a directory listing" + ); + assert_eq!(payload["messages"][0]["tool_calls"][0]["id"], "call_x"); + assert_eq!( + payload["messages"][0]["tool_calls"][0]["function"]["arguments"], + json!({"command": "ls"}).to_string() + ); + assert_eq!(payload["messages"][1]["role"], "tool"); + assert_eq!(payload["messages"][1]["tool_call_id"], "call_x"); + assert_eq!(payload["messages"][1]["content"], "file.txt"); + assert_eq!(payload["thinking"]["type"], "enabled"); + assert_eq!(payload["max_tokens"], 4096); +} + /// Symmetric: text-only assistant fragments never carry reasoning, even when /// `thinking=true`. The placeholder is tool-call-only. #[test] @@ -547,6 +679,40 @@ fn build_request_omits_openai_thinking_mode_when_not_configured() { assert_eq!(request.additional_params, None); } +#[test] +fn build_request_sends_deepseek_thinking_mode_and_output_limit() { + for (enabled, expected) in [(true, "enabled"), (false, "disabled")] { + let mut model = dummy_model(); + model.protocol = Protocol::DeepSeek; + model.limit = Some(Limit { + context: 100_000, + input: None, + output: 4096, + }); + model.set_thinking_mode(enabled); + + let request = build_request(&[Message::user("hi")], &[], &model).expect("builds"); + + assert_eq!( + request.additional_params, + Some(json!({ + "thinking": {"type": expected}, + "max_tokens": 4096, + })), + ); + } +} + +#[test] +fn build_request_omits_unconfigured_deepseek_extensions() { + let mut model = dummy_model(); + model.protocol = Protocol::DeepSeek; + + let request = build_request(&[Message::user("hi")], &[], &model).expect("builds"); + + assert_eq!(request.additional_params, None); +} + #[test] fn build_request_does_not_send_openai_thinking_extension_to_other_protocols() { let mut model = dummy_model(); diff --git a/crates/server/src/decode.rs b/crates/server/src/decode.rs index 2a511436..52e5bd23 100644 --- a/crates/server/src/decode.rs +++ b/crates/server/src/decode.rs @@ -137,6 +137,7 @@ pub fn build_model(spec: &ModelSpec) -> Result<machine::Model, Status> { use machine::Protocol; let protocol = match spec.protocol.as_str() { "openai" => Protocol::OpenAI, + "deepseek" => Protocol::DeepSeek, "anthropic" => Protocol::Anthropic, "gemini" => Protocol::Gemini, other => { @@ -226,4 +227,16 @@ mod tests { assert_eq!(model.timeout, 1_800); } + + #[test] + fn decoded_model_accepts_deepseek_protocol() { + let model = build_model(&ModelSpec { + name: "deepseek-test".to_string(), + protocol: "deepseek".to_string(), + ..Default::default() + }) + .expect("DeepSeek model should decode"); + + assert_eq!(model.protocol, machine::Protocol::DeepSeek); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 63f79895..6b3e002f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,6 +6,8 @@ The Rust workspace is composed of six crates: machine owns context, model, polic Cross-crate seams are the machine API, accelerator graph and tool registries, storage snapshots, the CLI parser/compiler boundary, the protobuf service in proto/rcm.proto, and the SDK generated bindings. +Model protocol selection is explicit at the machine boundary. OpenAI-compatible gateways use the generic `openai` transport, while DeepSeek uses the provider-native `deepseek` transport because its thinking/tool-history assistant schema requires string content; endpoint and model names never select a protocol implicitly. + ## Conventions - New behavior goes on documented extension points; a decision record is required when a core-flow change chooses among meaningful alternatives. diff --git a/docs/decisions/0006-preserve-thinking-tool-call-content.md b/docs/decisions/0006-preserve-thinking-tool-call-content.md index 3134ace2..ef0c22bb 100644 --- a/docs/decisions/0006-preserve-thinking-tool-call-content.md +++ b/docs/decisions/0006-preserve-thinking-tool-call-content.md @@ -1,7 +1,7 @@ # Preserve content in thinking tool-call history ## Status -Accepted +Superseded by [0007](0007-use-provider-native-deepseek-transport.md) Class: bug-fix ## Context and Problem Statement @@ -61,3 +61,4 @@ This beats an immediate Rig upgrade because the narrow compatibility correction - [DeepSeek thinking-mode tool-call contract](https://api-docs.deepseek.com/guides/thinking_mode/) - [DeepSeek Chat Completions assistant message](https://api-docs.deepseek.com/api/create-chat-completion/) - [Thinking tool-call content specification](../specs/0003-thinking-tool-call-content.md) +- [Provider-native successor decision](0007-use-provider-native-deepseek-transport.md) diff --git a/docs/decisions/0007-use-provider-native-deepseek-transport.md b/docs/decisions/0007-use-provider-native-deepseek-transport.md new file mode 100644 index 00000000..67cb5643 --- /dev/null +++ b/docs/decisions/0007-use-provider-native-deepseek-transport.md @@ -0,0 +1,66 @@ +# Use provider-native DeepSeek transport + +## Status +Accepted +Class: bug-fix + +## Context and Problem Statement + +DeepSeek accepts an OpenAI-like Chat Completions API, but its assistant `content` field is a string or null. RCM's generic OpenAI path through Rig 0.36 represents assistant content as parts; the v0.2.23 compatibility item made a missing field present as a text-parts array rather than an empty string. A release canary exposed the mismatch when the model produced a pure tool-call turn, while earlier canaries happened to include visible assistant text and therefore did not exercise the invalid shape. + +## Decision Drivers + +- The serialized request must follow DeepSeek's documented thinking/tool-history contract exactly. +- The permanent regression must inspect the actual provider wire request, not only provider-neutral messages. +- DeepSeek-specific behavior must be explicit and must not depend on endpoint or model-name inspection. +- Existing generic OpenAI-compatible callers must remain unchanged. +- Explicit thinking selection and configured output limits must survive transport conversion. +- The solution must stay within the pinned Rig release rather than combining the incident fix with a broad dependency upgrade. + +## Considered Options + +- Add a first-class DeepSeek protocol backed by Rig's native DeepSeek adapter. +- Keep the generic OpenAI adapter and change the compatibility value again. +- Detect DeepSeek endpoints and selectively rewrite the OpenAI JSON body. +- Upgrade Rig and depend on a changed generic OpenAI conversion. + +## Decision Outcome + +RCM adds an explicit `deepseek` protocol and routes it through Rig's provider-native DeepSeek adapter. That adapter converts provider-neutral assistant content to the documented string field and retains reasoning and tool calls. RCM supplies `thinking` and `max_tokens` through additional parameters because those are owned configuration values and the pinned adapter does not project the generic output limit. + +This beats another generic OpenAI workaround because the provider adapter now owns the provider schema and the regression observes its real HTTP body. It beats endpoint detection because provider identity remains a declared contract. It gives up treating DeepSeek as interchangeable with every OpenAI-compatible gateway and adds one protocol branch that must remain covered. + +## Pros and Cons of the Options + +### Provider-native DeepSeek protocol + +- Good: serializes assistant content using DeepSeek's string schema. +- Good: makes provider selection explicit in source configuration and inventory. +- Good: supports custom DeepSeek-compatible endpoints without hostname logic. +- Trade-off: adds one protocol variant and transport branch. +- Trade-off: RCM must preserve `max_tokens` through additional parameters until the pinned adapter does so. + +### Another generic OpenAI workaround + +- Good: minimizes protocol-surface changes. +- Bad: continues translating a provider-specific string contract through a content-parts schema. +- Bad: a fix for DeepSeek could alter unrelated OpenAI-compatible providers. + +### Endpoint-based JSON rewriting + +- Good: could produce the required field without a public protocol value. +- Bad: alternate gateways, proxies, and self-hosted endpoints make identity inference incomplete. +- Bad: handwritten body mutation duplicates provider-adapter responsibility. + +### Upgrade Rig + +- Good: may contain broader provider improvements. +- Bad: expands the release and regression surface during a production compatibility incident. +- Bad: does not remove the need for an explicit exact-wire test or prove generic OpenAI is the right DeepSeek schema. + +## Links + +- [DeepSeek thinking-mode tool-call contract](https://api-docs.deepseek.com/guides/thinking_mode/) +- [DeepSeek Chat Completions assistant message schema](https://api-docs.deepseek.com/api/create-chat-completion/) +- [DeepSeek native transport specification](../specs/0004-deepseek-native-transport.md) +- [Escaped request-shape postmortem](../postmortems/0003-content-presence-did-not-prove-deepseek-shape.md) diff --git a/docs/postmortems/0003-content-presence-did-not-prove-deepseek-shape.md b/docs/postmortems/0003-content-presence-did-not-prove-deepseek-shape.md new file mode 100644 index 00000000..51022f4f --- /dev/null +++ b/docs/postmortems/0003-content-presence-did-not-prove-deepseek-shape.md @@ -0,0 +1,31 @@ +# Content presence did not prove DeepSeek shape + +Artifact-Version: 1 + +## Executive summary + +RCM v0.2.23 made a missing thinking tool-call `content` field present, but the generic OpenAI adapter serialized the compatibility value as a text-parts array while DeepSeek documents a string-or-null field. The regression checked only presence, and the live canary was nondeterministic because some tool turns contained visible text; the permanent correction is an explicit native DeepSeek transport plus an exact local wire-body test. + +## Summary + +The downstream production deployment canary performed sequential filesystem tool calls. Earlier runs passed when the model included visible assistant text beside the call. A later run produced a pure tool call, replayed it as `content: [{"type":"text","text":""}]`, and received HTTP 400 classified by content-free diagnostics as `thinking_tool_history`. + +## Timeline + +- RCM v0.2.23 shipped an explicit empty provider-neutral text item so Rig's generic OpenAI serializer would not omit assistant `content`. +- Its regression asserted only that serialized `content` was non-null. +- A downstream image pinned v0.2.23 and passed an initial live multi-turn DeepSeek canary. +- A later immutable-image deployment hit a pure tool-call response and failed safely before any production stack update. +- The captured content-free request shape and the provider's published schema narrowed the defect to JSON type rather than field presence. +- An exact assertion reproduced the parts-array/string mismatch locally without credentials or user content. + +## Root cause + +The previous fix treated OpenAI compatibility as equivalent to the DeepSeek wire schema. Rig's generic OpenAI request accepts multimodal assistant content parts, so inserting an empty text item produced a present but wrongly typed DeepSeek field. The test encoded the same assumption by checking presence rather than the exact JSON type and sibling fields. The live canary could not deterministically force a pure tool-call response, so successful runs did not prove the edge shape. + +## Guardrails + +- [DeepSeek wire regression](../../crates/machine/tests/completion.rs) captures the actual native-adapter HTTP request and asserts exact string content, reasoning, tool calls, thinking control, and token limit. +- [DeepSeek native transport specification](../specs/0004-deepseek-native-transport.md) owns the compatibility and N-1 contract. +- [Provider-native transport decision](../decisions/0007-use-provider-native-deepseek-transport.md) prevents provider identity from returning to hostname inference or generic JSON rewriting. +- The downstream release workflow continues to run the live multi-turn provider canary before stack mutation, as a complement to the deterministic local wire contract. diff --git a/docs/postmortems/README.md b/docs/postmortems/README.md index 40b1931d..072fe3fb 100644 --- a/docs/postmortems/README.md +++ b/docs/postmortems/README.md @@ -25,3 +25,4 @@ Name files `NNNN-short-title.md` (sequential number) and put `Artifact-Version: - [Provider 400 observability gap](0000-provider-400-observability-gap.md) - [Explicit thinking mode was not sent](0001-explicit-thinking-mode-was-not-sent.md) - [Thinking tool-call content was omitted](0002-thinking-tool-call-content-was-omitted.md) +- [Content presence did not prove DeepSeek shape](0003-content-presence-did-not-prove-deepseek-shape.md) diff --git a/docs/specs/0003-thinking-tool-call-content.md b/docs/specs/0003-thinking-tool-call-content.md index 9ca79b79..b35dd384 100644 --- a/docs/specs/0003-thinking-tool-call-content.md +++ b/docs/specs/0003-thinking-tool-call-content.md @@ -1,7 +1,7 @@ # Thinking tool-call assistant content Artifact-Version: 1 -Status: Implemented +Status: Superseded by [0004](0004-deepseek-native-transport.md) ## Intent diff --git a/docs/specs/0004-deepseek-native-transport.md b/docs/specs/0004-deepseek-native-transport.md new file mode 100644 index 00000000..6dd115a9 --- /dev/null +++ b/docs/specs/0004-deepseek-native-transport.md @@ -0,0 +1,40 @@ +# DeepSeek native transport + +Artifact-Version: 1 +Status: Implemented + +## Intent + +DeepSeek workflows must use a provider-native transport whose serialized request follows DeepSeek's own assistant-message schema instead of relying on the broader OpenAI-compatible content-parts representation. The change must preserve existing generic OpenAI behavior, explicit thinking control, request diagnostics, custom endpoints, and model limits without selecting a provider from a hostname. + +## Contract + +- `deepseek` is an accepted RCM model protocol in the CLI, server adapter, serialized model configuration, and built-in provider registry. +- The release artifact carrying this contract is versioned `0.2.24`; downstream pins must use its published archive checksum. +- A `deepseek` model uses Rig's DeepSeek client and sends requests to its configured endpoint or the provider default. +- A replayed pure assistant tool-call turn serializes `content` as the empty JSON string, preserves the original `reasoning_content` unchanged, and preserves its tool calls and following tool results. +- Explicit `thinking = "true"` or `thinking = "false"` produces the matching DeepSeek `thinking.type` request object; omission leaves the parameter absent. +- A configured output-token limit remains present as `max_tokens` on the DeepSeek wire request even though the pinned Rig adapter does not project the generic field itself. +- Existing `openai`, `anthropic`, and `gemini` declarations retain their public behavior. RCM does not infer DeepSeek from an endpoint or model name. +- Existing programmatic OpenAI thinking-mode methods remain source compatible while additive provider-neutral aliases support the DeepSeek path. + +## Plan + +Add the protocol variant at the model boundary, route it through Rig's DeepSeek client, project explicit thinking and output-token limits into DeepSeek additional parameters, update compiler/server/provider mappings, and replace the loose OpenAI conversion assertion with a local wire-level DeepSeek transport regression. + +## Verification + +- A local HTTP fixture captures the actual DeepSeek adapter request and asserts the exact `content`, `reasoning_content`, `tool_calls`, `thinking`, and `max_tokens` JSON shape. +- Compiler, server, provider-registry, serde, and legacy OpenAI tests cover the additive protocol and N-1-compatible behavior. +- Machine, CLI, server, and accelerator tests pass together with workspace nextest, doc tests, formatting, Clippy, and repository governance gates. +- A downstream live DeepSeek multi-turn tool canary succeeds before production adoption. + +## Evidence + +- [Exact DeepSeek wire regression and request-parameter tests](../../crates/machine/tests/completion.rs) +- [Protocol model and serde contract](../../crates/machine/src/model.rs) +- [CLI protocol compiler](../../crates/cli/src/rcm/compile.rs) +- [Server protocol adapter](../../crates/server/src/decode.rs) +- [Built-in provider registry](../../crates/accelerator/src/provider.rs) +- [Provider-native transport decision](../decisions/0007-use-provider-native-deepseek-transport.md) +- [Escaped request-shape postmortem](../postmortems/0003-content-presence-did-not-prove-deepseek-shape.md) From c0b8abb19a66f62defdbe734a1784c1f64fc0f36 Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:46:17 +0800 Subject: [PATCH 10/12] fix(deepseek): preserve trailing tool reasoning (#200) Associate reasoning with the complete assistant turn instead of relying on provider-normalized content order. Add the escaped-order regression, postmortem, and compatibility contract. --- .repo-seed/manifest.json | 2 +- crates/machine/src/completion.rs | 33 ++++++++++++------- crates/machine/tests/completion.rs | 19 +++++++++++ ...r-normalization-order-dropped-reasoning.md | 33 +++++++++++++++++++ docs/postmortems/README.md | 1 + docs/specs/0004-deepseek-native-transport.md | 5 ++- 6 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 docs/postmortems/0004-provider-normalization-order-dropped-reasoning.md diff --git a/.repo-seed/manifest.json b/.repo-seed/manifest.json index c8a509d3..dd985989 100644 --- a/.repo-seed/manifest.json +++ b/.repo-seed/manifest.json @@ -258,7 +258,7 @@ }, { "path": "docs/postmortems/README.md", - "sha256": "f6b4879a58d15e90f9c899073554673d616be160795cfa2004c0c2145e859a87", + "sha256": "d24c314f3d100d022d47e1d7716522522740d025d73b1cfee7ec4fcea4751a79", "category": "docs", "capability": "postmortems" }, diff --git a/crates/machine/src/completion.rs b/crates/machine/src/completion.rs index c7d37874..899298ca 100644 --- a/crates/machine/src/completion.rs +++ b/crates/machine/src/completion.rs @@ -766,6 +766,7 @@ fn merge_adjacent_system_messages(messages: &[Message]) -> Vec<Message> { pub fn decode<'a>(choice: impl Iterator<Item = &'a AssistantContent>) -> Vec<Fragment> { use rig::completion::message::MimeType; + let choice = choice.collect::<Vec<_>>(); let mut fragments = Vec::new(); // Thinking-mode providers (DeepSeek, Kimi) emit one reasoning block per // assistant turn and require it to be echoed back on every assistant @@ -773,26 +774,34 @@ pub fn decode<'a>(choice: impl Iterator<Item = &'a AssistantContent>) -> Vec<Fra // tool_calls — we replicate the same reasoning onto each of them, so the // turn survives being split into one Fragment per call. // - // A completion response is one assistant turn. Providers may emit visible - // text before a tool call in that same turn, so its reasoning must remain - // available until every tool call in the response has been decoded. - let mut pending_reasoning: Vec<String> = Vec::new(); + // A completion response is one assistant turn, but provider adapters do + // not agree on normalized content order. In particular, Rig 0.36 emits + // DeepSeek tool calls before their shared reasoning block. Collect the + // turn's reasoning first so replay is independent of that adapter order. + let shared_reasoning = choice + .iter() + .filter_map(|content| match content { + AssistantContent::Reasoning(reasoning) => { + let text = reasoning.display_text(); + (!text.is_empty()).then_some(text) + } + _ => None, + }) + .collect::<Vec<_>>() + .join("\n"); + let shared_reasoning = (!shared_reasoning.is_empty()).then_some(shared_reasoning); + for content in choice { match content { AssistantContent::Text(text) => { fragments.push(Fragment::assistant(&text.text)); } - AssistantContent::Reasoning(reasoning) => { - let text = reasoning.display_text(); - if !text.is_empty() { - pending_reasoning.push(text); - } - } + AssistantContent::Reasoning(_) => {} AssistantContent::ToolCall(tc) => { let mut frag = Fragment::tool_call(&tc.id, &tc.function.name, tc.function.arguments.clone()); - if !pending_reasoning.is_empty() { - frag = frag.with_reasoning(pending_reasoning.join("\n")); + if let Some(reasoning) = shared_reasoning.as_deref() { + frag = frag.with_reasoning(reasoning); } fragments.push(frag); } diff --git a/crates/machine/tests/completion.rs b/crates/machine/tests/completion.rs index a32fa71b..bdbc3a87 100644 --- a/crates/machine/tests/completion.rs +++ b/crates/machine/tests/completion.rs @@ -805,6 +805,25 @@ fn decode_attaches_reasoning_to_following_tool_call() { assert_eq!(tc.reasoning.as_deref(), Some("I should list the files")); } +/// Rig 0.36's provider-native DeepSeek adapter normalizes the response as +/// tool calls followed by the shared reasoning block. The block still belongs +/// to that one assistant turn and must be retained for the next request. +#[test] +fn decode_attaches_trailing_reasoning_to_preceding_tool_call() { + let response = [ + assistant_tool_call("call_1", "shell"), + assistant_reasoning("I should list the files"), + ]; + + let fragments = decode(response.iter()); + + assert_eq!(fragments.len(), 1, "reasoning is folded into the tool call"); + let Content::ToolCall(tc) = &fragments[0].content else { + panic!("expected ToolCall content"); + }; + assert_eq!(tc.reasoning.as_deref(), Some("I should list the files")); +} + #[test] fn decode_concatenates_multi_block_reasoning_before_tool_call() { let response = [ diff --git a/docs/postmortems/0004-provider-normalization-order-dropped-reasoning.md b/docs/postmortems/0004-provider-normalization-order-dropped-reasoning.md new file mode 100644 index 00000000..80872b38 --- /dev/null +++ b/docs/postmortems/0004-provider-normalization-order-dropped-reasoning.md @@ -0,0 +1,33 @@ +# Provider normalization order dropped reasoning + +Artifact-Version: 1 + +## Executive summary + +RCM v0.2.24 selected DeepSeek's provider-native transport and produced the correct replay request shape, but the pinned adapter normalized response tool calls before their shared reasoning block. RCM only attached reasoning already seen while decoding, so it lost the original block and replayed a placeholder that DeepSeek rejected. The durable correction treats every completion as one assistant turn and associates its reasoning with all tool calls regardless of normalized content order. + +## Summary + +A downstream production release canary still received HTTP 400 on the second request of a fixed multi-turn tool workflow. Content-free diagnostics proved that assistant content was a string, every tool call had one result, and reasoning was present in the rejected request. The remaining mismatch was semantic: the value was RCM's legacy placeholder rather than the reasoning returned by DeepSeek. + +## Timeline + +- RCM v0.2.24 replaced the generic OpenAI transport with Rig 0.36's native DeepSeek adapter. +- Local wire tests proved the outgoing request shape when reasoning was already stored on a tool-call fragment. +- Scholight adopted v0.2.24 and its hermetic provider contract passed. +- The live production canary returned HTTP 400 before stack mutation with a balanced thinking/tool-history diagnostic. +- Inspection of the pinned adapter showed that it emits normalized response content in text, tool-call, reasoning order. +- A regression reproduced the loss by decoding a tool call followed by reasoning. + +## Root cause + +RCM assumed normalized assistant content preserved the provider's logical reasoning-before-tool order. Its decoder accumulated reasoning while iterating and attached only the blocks seen before each tool call. Rig's native DeepSeek adapter returned the same assistant turn in a different internal order, so the tool-call fragment retained no reasoning. The next request satisfied the structural checks because the legacy thinking path synthesized a placeholder, but DeepSeek requires the original reasoning content to be replayed unchanged. + +The exact-wire regression did not catch this because it began with a manually constructed tool-call fragment whose reasoning was already attached. The hermetic downstream model returned reasoning in the order the regression expected instead of the order produced by the pinned native adapter. + +## Guardrails + +- [Order-independent decode regression](../../crates/machine/tests/completion.rs) covers the pinned adapter's tool-before-reasoning order as well as reasoning-before-tool and parallel calls. +- [Completion decoder](../../crates/machine/src/completion.rs) collects reasoning across the complete assistant turn before creating tool-call fragments. +- [DeepSeek native transport specification](../specs/0004-deepseek-native-transport.md) now makes response-order independence an explicit compatibility contract. +- The downstream live multi-turn provider canary remains a release gate before any production stack mutation. diff --git a/docs/postmortems/README.md b/docs/postmortems/README.md index 072fe3fb..61039599 100644 --- a/docs/postmortems/README.md +++ b/docs/postmortems/README.md @@ -26,3 +26,4 @@ Name files `NNNN-short-title.md` (sequential number) and put `Artifact-Version: - [Explicit thinking mode was not sent](0001-explicit-thinking-mode-was-not-sent.md) - [Thinking tool-call content was omitted](0002-thinking-tool-call-content-was-omitted.md) - [Content presence did not prove DeepSeek shape](0003-content-presence-did-not-prove-deepseek-shape.md) +- [Provider normalization order dropped reasoning](0004-provider-normalization-order-dropped-reasoning.md) diff --git a/docs/specs/0004-deepseek-native-transport.md b/docs/specs/0004-deepseek-native-transport.md index 6dd115a9..5bb5217a 100644 --- a/docs/specs/0004-deepseek-native-transport.md +++ b/docs/specs/0004-deepseek-native-transport.md @@ -10,9 +10,10 @@ DeepSeek workflows must use a provider-native transport whose serialized request ## Contract - `deepseek` is an accepted RCM model protocol in the CLI, server adapter, serialized model configuration, and built-in provider registry. -- The release artifact carrying this contract is versioned `0.2.24`; downstream pins must use its published archive checksum. +- The native transport was introduced in `0.2.24`; order-independent reasoning replay is carried by `0.2.25`. Downstream pins must use the selected release's published archive checksum. - A `deepseek` model uses Rig's DeepSeek client and sends requests to its configured endpoint or the provider default. - A replayed pure assistant tool-call turn serializes `content` as the empty JSON string, preserves the original `reasoning_content` unchanged, and preserves its tool calls and following tool results. +- Reasoning belongs to the complete assistant turn. Response normalization order must not affect replay: reasoning emitted before or after tool-call content is attached to every tool call from that turn. - Explicit `thinking = "true"` or `thinking = "false"` produces the matching DeepSeek `thinking.type` request object; omission leaves the parameter absent. - A configured output-token limit remains present as `max_tokens` on the DeepSeek wire request even though the pinned Rig adapter does not project the generic field itself. - Existing `openai`, `anthropic`, and `gemini` declarations retain their public behavior. RCM does not infer DeepSeek from an endpoint or model name. @@ -25,6 +26,7 @@ Add the protocol variant at the model boundary, route it through Rig's DeepSeek ## Verification - A local HTTP fixture captures the actual DeepSeek adapter request and asserts the exact `content`, `reasoning_content`, `tool_calls`, `thinking`, and `max_tokens` JSON shape. +- Decode regressions cover both reasoning-before-tool and the pinned adapter's tool-before-reasoning normalization order, including parallel tool calls. - Compiler, server, provider-registry, serde, and legacy OpenAI tests cover the additive protocol and N-1-compatible behavior. - Machine, CLI, server, and accelerator tests pass together with workspace nextest, doc tests, formatting, Clippy, and repository governance gates. - A downstream live DeepSeek multi-turn tool canary succeeds before production adoption. @@ -38,3 +40,4 @@ Add the protocol variant at the model boundary, route it through Rig's DeepSeek - [Built-in provider registry](../../crates/accelerator/src/provider.rs) - [Provider-native transport decision](../decisions/0007-use-provider-native-deepseek-transport.md) - [Escaped request-shape postmortem](../postmortems/0003-content-presence-did-not-prove-deepseek-shape.md) +- [Adapter ordering postmortem](../postmortems/0004-provider-normalization-order-dropped-reasoning.md) From 62850da9714309bdb3c550612dfde6b6de0c66c4 Mon Sep 17 00:00:00 2001 From: SII-EricSanchez <115814526+EricSanchezok@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:58:35 +0800 Subject: [PATCH 11/12] chore(release): prepare v0.2.25 (#201) --- Cargo.lock | 2 +- crates/cli/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 442178bd..5180f272 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,7 +415,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli" -version = "0.2.24" +version = "0.2.25" dependencies = [ "accelerator", "anyhow", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3bd15a4d..df1f29d2 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cli" -version = "0.2.24" +version = "0.2.25" edition = "2024" publish = false From c8b0e20d967f875a6fb2b6fa1c5d035e41c34548 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:06:12 +0000 Subject: [PATCH 12/12] chore(release): update formula for v0.2.25 --- Formula/rcm.rb | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 Formula/rcm.rb diff --git a/Formula/rcm.rb b/Formula/rcm.rb new file mode 100644 index 00000000..77cb1530 --- /dev/null +++ b/Formula/rcm.rb @@ -0,0 +1,40 @@ +class Rcm < Formula + desc "Composable AI pipelines with .rcm files" + homepage "https://github.com/EricSanchezok/recursive-context-machine" + version "0.2.25" + license "MIT" + + livecheck do + url "https://github.com/EricSanchezok/recursive-context-machine/releases/latest" + regex(%r{href=.*?/tag/v?(\d+(?:\.\d+)+)}i) + strategy :page_match + end + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-aarch64-darwin.tar.gz" + sha256 "de6c60a533467d5a88f7d1364f31373b8ec68061ec2e4bcc11c7da11d2cf05f9" + else + url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-x86_64-darwin.tar.gz" + sha256 "3ae9b877df2c7a1e2bd5d055c392cbbf1332755d2d02a0815a18f26680b108d4" + end + end + + on_linux do + if Hardware::CPU.arm? + url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-aarch64-linux.tar.gz" + sha256 "d69533927be1c8cc3d59f9488d87a65be969fd231189a21393d62768bc57645f" + else + url "https://github.com/EricSanchezok/recursive-context-machine/releases/download/v#{version}/accelerate-x86_64-linux.tar.gz" + sha256 "727299dd97c638c967a039711632a2ee7810eac990104d23a76b1489ab5bf75f" + end + end + + def install + bin.install "accelerate" + end + + test do + system "#{bin}/accelerate", "--version" + end +end