diff options
| author | main <main@swarm.moe> | 2026-03-19 22:28:01 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-19 22:28:01 -0400 |
| commit | f706910944ee8abe7b27a248596f7705059969d9 (patch) | |
| tree | 6a071e88b59146e10117f562fd28496bb821fc65 /crates/fidget-spinner-cli/src | |
| parent | 352fb5f089e74bf47b60c6221594b9c22defe251 (diff) | |
| download | fidget_spinner-f706910944ee8abe7b27a248596f7705059969d9.zip | |
Polish MCP ingest and schema surfaces
Diffstat (limited to 'crates/fidget-spinner-cli/src')
| -rw-r--r-- | crates/fidget-spinner-cli/src/main.rs | 533 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/catalog.rs | 209 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/host/runtime.rs | 22 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/service.rs | 925 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/telemetry.rs | 1 |
5 files changed, 1528 insertions, 162 deletions
diff --git a/crates/fidget-spinner-cli/src/main.rs b/crates/fidget-spinner-cli/src/main.rs index fe4cb5f..3ad9534 100644 --- a/crates/fidget-spinner-cli/src/main.rs +++ b/crates/fidget-spinner-cli/src/main.rs @@ -10,13 +10,16 @@ use std::path::{Path, PathBuf}; use camino::{Utf8Path, Utf8PathBuf}; use clap::{Args, Parser, Subcommand, ValueEnum}; use fidget_spinner_core::{ - AnnotationVisibility, CodeSnapshotRef, CommandRecipe, ExecutionBackend, FrontierContract, - FrontierNote, FrontierVerdict, GitCommitHash, MetricObservation, MetricSpec, MetricUnit, - NodeAnnotation, NodeClass, NodePayload, NonEmptyText, OptimizationObjective, TagName, + AnnotationVisibility, CodeSnapshotRef, CommandRecipe, DiagnosticSeverity, ExecutionBackend, + FieldPresence, FieldRole, FieldValueType, FrontierContract, FrontierNote, FrontierVerdict, + GitCommitHash, InferencePolicy, MetricSpec, MetricUnit, MetricValue, NodeAnnotation, NodeClass, + NodePayload, NonEmptyText, OptimizationObjective, ProjectFieldSpec, TagName, }; use fidget_spinner_store_sqlite::{ - CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, EdgeAttachment, - EdgeAttachmentDirection, ListNodesQuery, ProjectStore, StoreError, + CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, DefineMetricRequest, + DefineRunDimensionRequest, EdgeAttachment, EdgeAttachmentDirection, ListNodesQuery, + MetricBestQuery, MetricFieldSource, MetricKeyQuery, MetricRankOrder, ProjectStore, + RemoveSchemaFieldRequest, StoreError, UpsertSchemaFieldRequest, }; use serde::Serialize; use serde_json::{Map, Value, json}; @@ -61,6 +64,16 @@ enum Command { }, /// Record off-path research and enabling work. Research(ResearchCommand), + /// Inspect rankable metrics across closed experiments. + Metric { + #[command(subcommand)] + command: MetricCommand, + }, + /// Define and inspect run dimensions used to slice experiment metrics. + Dimension { + #[command(subcommand)] + command: DimensionCommand, + }, /// Close a core-path experiment atomically. Experiment { #[command(subcommand)] @@ -100,6 +113,10 @@ struct InitArgs { enum SchemaCommand { /// Show the current project schema as JSON. Show(ProjectArg), + /// Add or replace one project schema field definition. + UpsertField(SchemaFieldUpsertArgs), + /// Remove one project schema field definition. + RemoveField(SchemaFieldRemoveArgs), } #[derive(Subcommand)] @@ -169,8 +186,10 @@ struct NodeAddArgs { #[arg(long)] title: String, #[arg(long)] + /// Required for `note` and `research` nodes. summary: Option<String>, #[arg(long = "payload-json")] + /// JSON object payload. `note` and `research` nodes require a non-empty `body` string. payload_json: Option<String>, #[arg(long = "payload-file")] payload_file: Option<PathBuf>, @@ -270,6 +289,74 @@ enum ResearchSubcommand { Add(QuickResearchArgs), } +#[derive(Subcommand)] +enum MetricCommand { + /// Register a project-level metric definition. + Define(MetricDefineArgs), + /// List rankable numeric keys observed in completed experiments. + Keys(MetricKeysArgs), + /// Rank completed experiments by one numeric key. + Best(MetricBestArgs), + /// Re-run the idempotent legacy metric-plane normalization. + Migrate(ProjectArg), +} + +#[derive(Subcommand)] +enum DimensionCommand { + /// Register a project-level run dimension definition. + Define(DimensionDefineArgs), + /// List run dimensions and sample values observed in completed runs. + List(ProjectArg), +} + +#[derive(Args)] +struct MetricDefineArgs { + #[command(flatten)] + project: ProjectArg, + /// Metric key used in experiment closure and ranking. + #[arg(long)] + key: String, + /// Canonical unit for this metric key. + #[arg(long, value_enum)] + unit: CliMetricUnit, + /// Optimization direction for this metric key. + #[arg(long, value_enum)] + objective: CliOptimizationObjective, + /// Optional human description shown in metric listings. + #[arg(long)] + description: Option<String>, +} + +#[derive(Args)] +struct MetricKeysArgs { + #[command(flatten)] + project: ProjectArg, + /// Restrict results to one frontier. + #[arg(long)] + frontier: Option<String>, + /// Restrict results to one metric source. + #[arg(long, value_enum)] + source: Option<CliMetricSource>, + /// Exact run-dimension filter in the form `key=value`. + #[arg(long = "dimension")] + dimensions: Vec<String>, +} + +#[derive(Args)] +struct DimensionDefineArgs { + #[command(flatten)] + project: ProjectArg, + /// Run-dimension key used to slice experiments. + #[arg(long)] + key: String, + /// Canonical value type for this run dimension. + #[arg(long = "type", value_enum)] + value_type: CliFieldValueType, + /// Optional human description shown in dimension listings. + #[arg(long)] + description: Option<String>, +} + #[derive(Args)] struct QuickNoteArgs { #[command(flatten)] @@ -279,6 +366,8 @@ struct QuickNoteArgs { #[arg(long)] title: String, #[arg(long)] + summary: String, + #[arg(long)] body: String, #[command(flatten)] tag_selection: ExplicitTagSelectionArgs, @@ -305,13 +394,69 @@ struct QuickResearchArgs { #[arg(long)] title: String, #[arg(long)] - body: String, + summary: String, #[arg(long)] - summary: Option<String>, + body: String, + #[command(flatten)] + tag_selection: ExplicitTagSelectionArgs, #[arg(long = "parent")] parents: Vec<String>, } +#[derive(Args)] +struct SchemaFieldUpsertArgs { + #[command(flatten)] + project: ProjectArg, + #[arg(long)] + name: String, + #[arg(long = "class", value_enum)] + classes: Vec<CliNodeClass>, + #[arg(long, value_enum)] + presence: CliFieldPresence, + #[arg(long, value_enum)] + severity: CliDiagnosticSeverity, + #[arg(long, value_enum)] + role: CliFieldRole, + #[arg(long = "inference", value_enum)] + inference_policy: CliInferencePolicy, + #[arg(long = "type", value_enum)] + value_type: Option<CliFieldValueType>, +} + +#[derive(Args)] +struct SchemaFieldRemoveArgs { + #[command(flatten)] + project: ProjectArg, + #[arg(long)] + name: String, + #[arg(long = "class", value_enum)] + classes: Vec<CliNodeClass>, +} + +#[derive(Args)] +struct MetricBestArgs { + #[command(flatten)] + project: ProjectArg, + /// Metric key to rank on. + #[arg(long)] + key: String, + /// Restrict results to one frontier. + #[arg(long)] + frontier: Option<String>, + /// Restrict results to one metric source. + #[arg(long, value_enum)] + source: Option<CliMetricSource>, + /// Explicit ordering for sources whose objective cannot be inferred. + #[arg(long, value_enum)] + order: Option<CliMetricOrder>, + /// Exact run-dimension filter in the form `key=value`. + #[arg(long = "dimension")] + dimensions: Vec<String>, + /// Maximum number of ranked experiments to return. + #[arg(long, default_value_t = 10)] + limit: u32, +} + #[derive(Subcommand)] enum ExperimentCommand { /// Close a core-path experiment with checkpoint, run, note, and verdict. @@ -348,24 +493,23 @@ struct ExperimentCloseArgs { run_title: String, #[arg(long = "run-summary")] run_summary: Option<String>, - #[arg(long = "benchmark-suite")] - benchmark_suite: String, + /// Repeat for each run dimension as `key=value`. + #[arg(long = "dimension")] + dimensions: Vec<String>, #[arg(long = "backend", value_enum, default_value_t = CliExecutionBackend::Worktree)] backend: CliExecutionBackend, #[arg(long = "cwd")] working_directory: Option<PathBuf>, + /// Repeat for each argv token passed to the recorded command. #[arg(long = "argv")] argv: Vec<String>, + /// Repeat for each environment override as `KEY=VALUE`. #[arg(long = "env")] env: Vec<String>, - #[arg(long = "primary-metric-key")] - primary_metric_key: String, - #[arg(long = "primary-metric-unit", value_enum)] - primary_metric_unit: CliMetricUnit, - #[arg(long = "primary-metric-objective", value_enum)] - primary_metric_objective: CliOptimizationObjective, - #[arg(long = "primary-metric-value")] - primary_metric_value: f64, + /// Primary metric in the form `key=value`; key must be preregistered. + #[arg(long = "primary-metric")] + primary_metric: String, + /// Supporting metric in the form `key=value`; repeat as needed. #[arg(long = "metric")] metrics: Vec<String>, #[arg(long)] @@ -475,6 +619,57 @@ enum CliExecutionBackend { } #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum CliMetricSource { + RunMetric, + ChangePayload, + RunPayload, + AnalysisPayload, + DecisionPayload, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum CliMetricOrder { + Asc, + Desc, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum CliFieldValueType { + String, + Numeric, + Boolean, + Timestamp, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum CliDiagnosticSeverity { + Error, + Warning, + Info, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum CliFieldPresence { + Required, + Recommended, + Optional, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum CliFieldRole { + Index, + ProjectionGate, + RenderOnly, + Opaque, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum CliInferencePolicy { + ManualOnly, + ModelMayInfer, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] enum CliFrontierVerdict { PromoteToChampion, KeepOnFrontier, @@ -499,6 +694,8 @@ fn run() -> Result<(), StoreError> { let store = open_store(&project.project)?; print_json(store.schema()) } + SchemaCommand::UpsertField(args) => run_schema_field_upsert(args), + SchemaCommand::RemoveField(args) => run_schema_field_remove(args), }, Command::Frontier { command } => match command { FrontierCommand::Init(args) => run_frontier_init(args), @@ -521,6 +718,16 @@ fn run() -> Result<(), StoreError> { Command::Research(command) => match command.command { ResearchSubcommand::Add(args) => run_quick_research(args), }, + Command::Metric { command } => match command { + MetricCommand::Define(args) => run_metric_define(args), + MetricCommand::Keys(args) => run_metric_keys(args), + MetricCommand::Best(args) => run_metric_best(args), + MetricCommand::Migrate(project) => run_metric_migrate(project), + }, + Command::Dimension { command } => match command { + DimensionCommand::Define(args) => run_dimension_define(args), + DimensionCommand::List(project) => run_dimension_list(project), + }, Command::Experiment { command } => match command { ExperimentCommand::Close(args) => run_experiment_close(args), }, @@ -597,27 +804,58 @@ fn run_frontier_status(args: FrontierStatusArgs) -> Result<(), StoreError> { print_json(&frontiers) } +fn run_schema_field_upsert(args: SchemaFieldUpsertArgs) -> Result<(), StoreError> { + let mut store = open_store(&args.project.project)?; + let field = store.upsert_schema_field(UpsertSchemaFieldRequest { + name: NonEmptyText::new(args.name)?, + node_classes: parse_node_class_set(args.classes), + presence: args.presence.into(), + severity: args.severity.into(), + role: args.role.into(), + inference_policy: args.inference_policy.into(), + value_type: args.value_type.map(Into::into), + })?; + print_json(&json!({ + "schema": store.schema().schema_ref(), + "field": schema_field_json(&field), + })) +} + +fn run_schema_field_remove(args: SchemaFieldRemoveArgs) -> Result<(), StoreError> { + let mut store = open_store(&args.project.project)?; + let removed_count = store.remove_schema_field(RemoveSchemaFieldRequest { + name: NonEmptyText::new(args.name)?, + node_classes: (!args.classes.is_empty()).then(|| parse_node_class_set(args.classes)), + })?; + print_json(&json!({ + "schema": store.schema().schema_ref(), + "removed_count": removed_count, + })) +} + fn run_node_add(args: NodeAddArgs) -> Result<(), StoreError> { let mut store = open_store(&args.project.project)?; + let class: NodeClass = args.class.into(); let frontier_id = args .frontier .as_deref() .map(parse_frontier_id) .transpose()?; - let tags = optional_cli_tags(args.tag_selection, args.class == CliNodeClass::Note)?; + let tags = optional_cli_tags(args.tag_selection, class == NodeClass::Note)?; let payload = load_payload( store.schema().schema_ref(), args.payload_json, args.payload_file, args.fields, )?; + validate_cli_prose_payload(class, args.summary.as_deref(), &payload)?; let annotations = args .annotations .into_iter() .map(|body| Ok(NodeAnnotation::hidden(NonEmptyText::new(body)?))) .collect::<Result<Vec<_>, StoreError>>()?; let node = store.add_node(CreateNodeRequest { - class: args.class.into(), + class, frontier_id, title: NonEmptyText::new(args.title)?, summary: args.summary.map(NonEmptyText::new).transpose()?, @@ -693,7 +931,7 @@ fn run_quick_note(args: QuickNoteArgs) -> Result<(), StoreError> { .map(parse_frontier_id) .transpose()?, title: NonEmptyText::new(args.title)?, - summary: None, + summary: Some(NonEmptyText::new(args.summary)?), tags: Some(explicit_cli_tags(args.tag_selection)?), payload, annotations: Vec::new(), @@ -730,8 +968,8 @@ fn run_quick_research(args: QuickResearchArgs) -> Result<(), StoreError> { .map(parse_frontier_id) .transpose()?, title: NonEmptyText::new(args.title)?, - summary: args.summary.map(NonEmptyText::new).transpose()?, - tags: None, + summary: Some(NonEmptyText::new(args.summary)?), + tags: optional_cli_tags(args.tag_selection, false)?, payload, annotations: Vec::new(), attachments: lineage_attachments(args.parents)?, @@ -739,6 +977,69 @@ fn run_quick_research(args: QuickResearchArgs) -> Result<(), StoreError> { print_json(&node) } +fn run_metric_define(args: MetricDefineArgs) -> Result<(), StoreError> { + let mut store = open_store(&args.project.project)?; + let record = store.define_metric(DefineMetricRequest { + key: NonEmptyText::new(args.key)?, + unit: args.unit.into(), + objective: args.objective.into(), + description: args.description.map(NonEmptyText::new).transpose()?, + })?; + print_json(&record) +} + +fn run_metric_keys(args: MetricKeysArgs) -> Result<(), StoreError> { + let store = open_store(&args.project.project)?; + print_json( + &store.list_metric_keys_filtered(MetricKeyQuery { + frontier_id: args + .frontier + .as_deref() + .map(parse_frontier_id) + .transpose()?, + source: args.source.map(Into::into), + dimensions: coerce_cli_dimension_filters(&store, args.dimensions)?, + })?, + ) +} + +fn run_metric_best(args: MetricBestArgs) -> Result<(), StoreError> { + let store = open_store(&args.project.project)?; + let entries = store.best_metrics(MetricBestQuery { + key: NonEmptyText::new(args.key)?, + frontier_id: args + .frontier + .as_deref() + .map(parse_frontier_id) + .transpose()?, + source: args.source.map(Into::into), + dimensions: coerce_cli_dimension_filters(&store, args.dimensions)?, + order: args.order.map(Into::into), + limit: args.limit, + })?; + print_json(&entries) +} + +fn run_metric_migrate(args: ProjectArg) -> Result<(), StoreError> { + let mut store = open_store(&args.project)?; + print_json(&store.migrate_metric_plane()?) +} + +fn run_dimension_define(args: DimensionDefineArgs) -> Result<(), StoreError> { + let mut store = open_store(&args.project.project)?; + let record = store.define_run_dimension(DefineRunDimensionRequest { + key: NonEmptyText::new(args.key)?, + value_type: args.value_type.into(), + description: args.description.map(NonEmptyText::new).transpose()?, + })?; + print_json(&record) +} + +fn run_dimension_list(args: ProjectArg) -> Result<(), StoreError> { + let store = open_store(&args.project)?; + print_json(&store.list_run_dimensions()?) +} + fn run_experiment_close(args: ExperimentCloseArgs) -> Result<(), StoreError> { let mut store = open_store(&args.project.project)?; let frontier_id = parse_frontier_id(&args.frontier)?; @@ -764,19 +1065,14 @@ fn run_experiment_close(args: ExperimentCloseArgs) -> Result<(), StoreError> { run_title: NonEmptyText::new(args.run_title)?, run_summary: args.run_summary.map(NonEmptyText::new).transpose()?, backend: args.backend.into(), - benchmark_suite: NonEmptyText::new(args.benchmark_suite)?, + dimensions: coerce_cli_dimension_filters(&store, args.dimensions)?, command, code_snapshot: Some(capture_code_snapshot(store.project_root())?), - primary_metric: MetricObservation { - metric_key: NonEmptyText::new(args.primary_metric_key)?, - unit: args.primary_metric_unit.into(), - objective: args.primary_metric_objective.into(), - value: args.primary_metric_value, - }, + primary_metric: parse_metric_value(args.primary_metric)?, supporting_metrics: args .metrics .into_iter() - .map(parse_metric_observation) + .map(parse_metric_value) .collect::<Result<Vec<_>, _>>()?, note: FrontierNote { summary: NonEmptyText::new(args.note)?, @@ -1011,6 +1307,23 @@ fn load_payload( Ok(NodePayload::with_schema(schema, map)) } +fn validate_cli_prose_payload( + class: NodeClass, + summary: Option<&str>, + payload: &NodePayload, +) -> Result<(), StoreError> { + if !matches!(class, NodeClass::Note | NodeClass::Research) { + return Ok(()); + } + if summary.is_none() { + return Err(StoreError::ProseSummaryRequired(class)); + } + match payload.field("body") { + Some(Value::String(body)) if !body.trim().is_empty() => Ok(()), + _ => Err(StoreError::ProseBodyRequired(class)), + } +} + fn json_object(value: Value) -> Result<Map<String, Value>, StoreError> { match value { Value::Object(map) => Ok(map), @@ -1020,6 +1333,22 @@ fn json_object(value: Value) -> Result<Map<String, Value>, StoreError> { } } +fn schema_field_json(field: &ProjectFieldSpec) -> Value { + json!({ + "name": field.name, + "node_classes": field.node_classes.iter().map(ToString::to_string).collect::<Vec<_>>(), + "presence": field.presence.as_str(), + "severity": field.severity.as_str(), + "role": field.role.as_str(), + "inference_policy": field.inference_policy.as_str(), + "value_type": field.value_type.map(FieldValueType::as_str), + }) +} + +fn parse_node_class_set(classes: Vec<CliNodeClass>) -> BTreeSet<NodeClass> { + classes.into_iter().map(Into::into).collect() +} + fn capture_code_snapshot(project_root: &Utf8Path) -> Result<CodeSnapshotRef, StoreError> { let head_commit = run_git(project_root, &["rev-parse", "HEAD"])?; let dirty_paths = run_git(project_root, &["status", "--porcelain"])? @@ -1084,23 +1413,71 @@ fn maybe_print_gitignore_hint(project_root: &Utf8Path) -> Result<(), StoreError> } } -fn parse_metric_observation(raw: String) -> Result<MetricObservation, StoreError> { - let parts = raw.split(':').collect::<Vec<_>>(); - if parts.len() != 4 { - return Err(invalid_input( - "metrics must look like key:unit:objective:value", - )); - } - Ok(MetricObservation { - metric_key: NonEmptyText::new(parts[0])?, - unit: parse_metric_unit(parts[1])?, - objective: parse_optimization_objective(parts[2])?, - value: parts[3] +fn parse_metric_value(raw: String) -> Result<MetricValue, StoreError> { + let Some((key, value)) = raw.split_once('=') else { + return Err(invalid_input("metrics must look like key=value")); + }; + Ok(MetricValue { + key: NonEmptyText::new(key)?, + value: value .parse::<f64>() .map_err(|error| invalid_input(format!("invalid metric value: {error}")))?, }) } +fn coerce_cli_dimension_filters( + store: &ProjectStore, + raw_dimensions: Vec<String>, +) -> Result<BTreeMap<NonEmptyText, fidget_spinner_core::RunDimensionValue>, StoreError> { + let definitions = store + .list_run_dimensions()? + .into_iter() + .map(|summary| (summary.key.to_string(), summary.value_type)) + .collect::<BTreeMap<_, _>>(); + let raw_dimensions = parse_dimension_assignments(raw_dimensions)? + .into_iter() + .map(|(key, raw_value)| { + let Some(value_type) = definitions.get(&key) else { + return Err(invalid_input(format!( + "unknown run dimension `{key}`; register it first" + ))); + }; + Ok((key, parse_cli_dimension_value(*value_type, &raw_value)?)) + }) + .collect::<Result<BTreeMap<_, _>, StoreError>>()?; + store.coerce_run_dimensions(raw_dimensions) +} + +fn parse_dimension_assignments( + raw_dimensions: Vec<String>, +) -> Result<BTreeMap<String, String>, StoreError> { + raw_dimensions + .into_iter() + .map(|raw| { + let Some((key, value)) = raw.split_once('=') else { + return Err(invalid_input("dimensions must look like key=value")); + }; + Ok((key.to_owned(), value.to_owned())) + }) + .collect() +} + +fn parse_cli_dimension_value(value_type: FieldValueType, raw: &str) -> Result<Value, StoreError> { + match value_type { + FieldValueType::String | FieldValueType::Timestamp => Ok(Value::String(raw.to_owned())), + FieldValueType::Numeric => Ok(json!(raw.parse::<f64>().map_err(|error| { + invalid_input(format!("invalid numeric dimension value: {error}")) + })?)), + FieldValueType::Boolean => match raw { + "true" => Ok(Value::Bool(true)), + "false" => Ok(Value::Bool(false)), + other => Err(invalid_input(format!( + "invalid boolean dimension value `{other}`" + ))), + }, + } +} + fn parse_metric_unit(raw: &str) -> Result<MetricUnit, StoreError> { match raw { "seconds" => Ok(MetricUnit::Seconds), @@ -1204,6 +1581,78 @@ impl From<CliExecutionBackend> for ExecutionBackend { } } +impl From<CliMetricSource> for MetricFieldSource { + fn from(value: CliMetricSource) -> Self { + match value { + CliMetricSource::RunMetric => Self::RunMetric, + CliMetricSource::ChangePayload => Self::ChangePayload, + CliMetricSource::RunPayload => Self::RunPayload, + CliMetricSource::AnalysisPayload => Self::AnalysisPayload, + CliMetricSource::DecisionPayload => Self::DecisionPayload, + } + } +} + +impl From<CliMetricOrder> for MetricRankOrder { + fn from(value: CliMetricOrder) -> Self { + match value { + CliMetricOrder::Asc => Self::Asc, + CliMetricOrder::Desc => Self::Desc, + } + } +} + +impl From<CliFieldValueType> for FieldValueType { + fn from(value: CliFieldValueType) -> Self { + match value { + CliFieldValueType::String => Self::String, + CliFieldValueType::Numeric => Self::Numeric, + CliFieldValueType::Boolean => Self::Boolean, + CliFieldValueType::Timestamp => Self::Timestamp, + } + } +} + +impl From<CliDiagnosticSeverity> for DiagnosticSeverity { + fn from(value: CliDiagnosticSeverity) -> Self { + match value { + CliDiagnosticSeverity::Error => Self::Error, + CliDiagnosticSeverity::Warning => Self::Warning, + CliDiagnosticSeverity::Info => Self::Info, + } + } +} + +impl From<CliFieldPresence> for FieldPresence { + fn from(value: CliFieldPresence) -> Self { + match value { + CliFieldPresence::Required => Self::Required, + CliFieldPresence::Recommended => Self::Recommended, + CliFieldPresence::Optional => Self::Optional, + } + } +} + +impl From<CliFieldRole> for FieldRole { + fn from(value: CliFieldRole) -> Self { + match value { + CliFieldRole::Index => Self::Index, + CliFieldRole::ProjectionGate => Self::ProjectionGate, + CliFieldRole::RenderOnly => Self::RenderOnly, + CliFieldRole::Opaque => Self::Opaque, + } + } +} + +impl From<CliInferencePolicy> for InferencePolicy { + fn from(value: CliInferencePolicy) -> Self { + match value { + CliInferencePolicy::ManualOnly => Self::ManualOnly, + CliInferencePolicy::ModelMayInfer => Self::ModelMayInfer, + } + } +} + impl From<CliFrontierVerdict> for FrontierVerdict { fn from(value: CliFrontierVerdict) -> Self { match value { diff --git a/crates/fidget-spinner-cli/src/mcp/catalog.rs b/crates/fidget-spinner-cli/src/mcp/catalog.rs index b23cb31..0831ba4 100644 --- a/crates/fidget-spinner-cli/src/mcp/catalog.rs +++ b/crates/fidget-spinner-cli/src/mcp/catalog.rs @@ -67,6 +67,18 @@ pub(crate) fn tool_spec(name: &str) -> Option<ToolSpec> { dispatch: DispatchTarget::Worker, replay: ReplayContract::Convergent, }), + "schema.field.upsert" => Some(ToolSpec { + name: "schema.field.upsert", + description: "Add or replace one project-local payload schema field definition.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::NeverReplay, + }), + "schema.field.remove" => Some(ToolSpec { + name: "schema.field.remove", + description: "Remove one project-local payload schema field definition, optionally narrowed by node-class set.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::NeverReplay, + }), "tag.add" => Some(ToolSpec { name: "tag.add", description: "Register one repo-local tag with a required description. Notes may only reference tags from this registry.", @@ -145,9 +157,45 @@ pub(crate) fn tool_spec(name: &str) -> Option<ToolSpec> { dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }), + "metric.define" => Some(ToolSpec { + name: "metric.define", + description: "Register one project-level metric definition so experiment ingestion only has to send key/value observations.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::NeverReplay, + }), + "run.dimension.define" => Some(ToolSpec { + name: "run.dimension.define", + description: "Register one project-level run dimension used to slice metrics across scenarios, budgets, and flags.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::NeverReplay, + }), + "run.dimension.list" => Some(ToolSpec { + name: "run.dimension.list", + description: "List registered run dimensions together with observed value counts and sample values.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::Convergent, + }), + "metric.keys" => Some(ToolSpec { + name: "metric.keys", + description: "List rankable metric keys, including registered run metrics and observed payload-derived numeric fields.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::Convergent, + }), + "metric.best" => Some(ToolSpec { + name: "metric.best", + description: "Rank completed experiments by one numeric key, with optional run-dimension filters and candidate commit surfacing.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::Convergent, + }), + "metric.migrate" => Some(ToolSpec { + name: "metric.migrate", + description: "Re-run the idempotent legacy metric-plane normalization that registers canonical metrics and backfills benchmark_suite dimensions.", + dispatch: DispatchTarget::Worker, + replay: ReplayContract::NeverReplay, + }), "experiment.close" => Some(ToolSpec { name: "experiment.close", - description: "Atomically close a core-path experiment with candidate checkpoint capture, measured result, note, and verdict.", + description: "Atomically close a core-path experiment with typed run dimensions, preregistered metric observations, candidate checkpoint capture, note, and verdict.", dispatch: DispatchTarget::Worker, replay: ReplayContract::NeverReplay, }), @@ -212,6 +260,8 @@ pub(crate) fn tool_definitions() -> Vec<Value> { "project.bind", "project.status", "project.schema", + "schema.field.upsert", + "schema.field.remove", "tag.add", "tag.list", "frontier.list", @@ -225,6 +275,12 @@ pub(crate) fn tool_definitions() -> Vec<Value> { "node.archive", "note.quick", "research.record", + "metric.define", + "run.dimension.define", + "run.dimension.list", + "metric.keys", + "metric.best", + "metric.migrate", "experiment.close", "skill.list", "skill.show", @@ -277,7 +333,32 @@ pub(crate) fn list_resources() -> Vec<Value> { fn input_schema(name: &str) -> Value { match name { "project.status" | "project.schema" | "tag.list" | "skill.list" | "system.health" - | "system.telemetry" => json!({"type":"object","additionalProperties":false}), + | "system.telemetry" | "run.dimension.list" | "metric.migrate" => { + json!({"type":"object","additionalProperties":false}) + } + "schema.field.upsert" => json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Project payload field name." }, + "node_classes": { "type": "array", "items": node_class_schema(), "description": "Optional node-class scope. Omit or pass [] for all classes." }, + "presence": field_presence_schema(), + "severity": diagnostic_severity_schema(), + "role": field_role_schema(), + "inference_policy": inference_policy_schema(), + "value_type": field_value_type_schema(), + }, + "required": ["name", "presence", "severity", "role", "inference_policy"], + "additionalProperties": false + }), + "schema.field.remove" => json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "Project payload field name." }, + "node_classes": { "type": "array", "items": node_class_schema(), "description": "Optional exact node-class scope to remove." } + }, + "required": ["name"], + "additionalProperties": false + }), "project.bind" => json!({ "type": "object", "properties": { @@ -333,9 +414,9 @@ fn input_schema(name: &str) -> Value { "class": node_class_schema(), "frontier_id": { "type": "string" }, "title": { "type": "string" }, - "summary": { "type": "string" }, - "tags": { "type": "array", "items": tag_name_schema() }, - "payload": { "type": "object" }, + "summary": { "type": "string", "description": "Required for `note` and `research` nodes." }, + "tags": { "type": "array", "items": tag_name_schema(), "description": "Required for `note` nodes; optional for other classes." }, + "payload": { "type": "object", "description": "`note` and `research` nodes require a non-empty string `body` field." }, "annotations": { "type": "array", "items": annotation_schema() }, "parents": { "type": "array", "items": { "type": "string" } } }, @@ -393,12 +474,13 @@ fn input_schema(name: &str) -> Value { "properties": { "frontier_id": { "type": "string" }, "title": { "type": "string" }, + "summary": { "type": "string" }, "body": { "type": "string" }, "tags": { "type": "array", "items": tag_name_schema() }, "annotations": { "type": "array", "items": annotation_schema() }, "parents": { "type": "array", "items": { "type": "string" } } }, - "required": ["title", "body", "tags"], + "required": ["title", "summary", "body", "tags"], "additionalProperties": false }), "research.record" => json!({ @@ -408,10 +490,54 @@ fn input_schema(name: &str) -> Value { "title": { "type": "string" }, "summary": { "type": "string" }, "body": { "type": "string" }, + "tags": { "type": "array", "items": tag_name_schema() }, "annotations": { "type": "array", "items": annotation_schema() }, "parents": { "type": "array", "items": { "type": "string" } } }, - "required": ["title", "body"], + "required": ["title", "summary", "body"], + "additionalProperties": false + }), + "metric.define" => json!({ + "type": "object", + "properties": { + "key": { "type": "string" }, + "unit": metric_unit_schema(), + "objective": optimization_objective_schema(), + "description": { "type": "string" } + }, + "required": ["key", "unit", "objective"], + "additionalProperties": false + }), + "run.dimension.define" => json!({ + "type": "object", + "properties": { + "key": { "type": "string" }, + "value_type": field_value_type_schema(), + "description": { "type": "string" } + }, + "required": ["key", "value_type"], + "additionalProperties": false + }), + "metric.keys" => json!({ + "type": "object", + "properties": { + "frontier_id": { "type": "string" }, + "source": metric_source_schema(), + "dimensions": { "type": "object" } + }, + "additionalProperties": false + }), + "metric.best" => json!({ + "type": "object", + "properties": { + "key": { "type": "string" }, + "frontier_id": { "type": "string" }, + "source": metric_source_schema(), + "dimensions": { "type": "object" }, + "order": metric_order_schema(), + "limit": { "type": "integer", "minimum": 1, "maximum": 500 } + }, + "required": ["key"], "additionalProperties": false }), "experiment.close" => json!({ @@ -422,8 +548,8 @@ fn input_schema(name: &str) -> Value { "change_node_id": { "type": "string" }, "candidate_summary": { "type": "string" }, "run": run_schema(), - "primary_metric": metric_observation_schema(), - "supporting_metrics": { "type": "array", "items": metric_observation_schema() }, + "primary_metric": metric_value_schema(), + "supporting_metrics": { "type": "array", "items": metric_value_schema() }, "note": note_schema(), "verdict": verdict_schema(), "decision_title": { "type": "string" }, @@ -461,16 +587,14 @@ fn metric_spec_schema() -> Value { }) } -fn metric_observation_schema() -> Value { +fn metric_value_schema() -> Value { json!({ "type": "object", "properties": { "key": { "type": "string" }, - "unit": metric_unit_schema(), - "objective": optimization_objective_schema(), "value": { "type": "number" } }, - "required": ["key", "unit", "objective", "value"], + "required": ["key", "value"], "additionalProperties": false }) } @@ -509,6 +633,61 @@ fn metric_unit_schema() -> Value { }) } +fn metric_source_schema() -> Value { + json!({ + "type": "string", + "enum": [ + "run_metric", + "change_payload", + "run_payload", + "analysis_payload", + "decision_payload" + ] + }) +} + +fn metric_order_schema() -> Value { + json!({ + "type": "string", + "enum": ["asc", "desc"] + }) +} + +fn field_value_type_schema() -> Value { + json!({ + "type": "string", + "enum": ["string", "numeric", "boolean", "timestamp"] + }) +} + +fn diagnostic_severity_schema() -> Value { + json!({ + "type": "string", + "enum": ["error", "warning", "info"] + }) +} + +fn field_presence_schema() -> Value { + json!({ + "type": "string", + "enum": ["required", "recommended", "optional"] + }) +} + +fn field_role_schema() -> Value { + json!({ + "type": "string", + "enum": ["index", "projection_gate", "render_only", "opaque"] + }) +} + +fn inference_policy_schema() -> Value { + json!({ + "type": "string", + "enum": ["manual_only", "model_may_infer"] + }) +} + fn optimization_objective_schema() -> Value { json!({ "type": "string", @@ -539,7 +718,7 @@ fn run_schema() -> Value { "type": "string", "enum": ["local_process", "worktree_process", "ssh_process"] }, - "benchmark_suite": { "type": "string" }, + "dimensions": { "type": "object" }, "command": { "type": "object", "properties": { @@ -554,7 +733,7 @@ fn run_schema() -> Value { "additionalProperties": false } }, - "required": ["title", "backend", "benchmark_suite", "command"], + "required": ["title", "backend", "dimensions", "command"], "additionalProperties": false }) } diff --git a/crates/fidget-spinner-cli/src/mcp/host/runtime.rs b/crates/fidget-spinner-cli/src/mcp/host/runtime.rs index f84f604..d57a21e 100644 --- a/crates/fidget-spinner-cli/src/mcp/host/runtime.rs +++ b/crates/fidget-spinner-cli/src/mcp/host/runtime.rs @@ -838,20 +838,6 @@ fn system_health_output(health: &HealthSnapshot) -> Result<ToolOutput, FaultReco "rollout_pending".to_owned(), json!(health.binary.rollout_pending), ); - if let Some(fault) = health.last_fault.as_ref() { - let _ = concise.insert( - "last_fault".to_owned(), - json!({ - "kind": format!("{:?}", fault.kind).to_ascii_lowercase(), - "stage": format!("{:?}", fault.stage).to_ascii_lowercase(), - "operation": fault.operation, - "message": fault.message, - "retryable": fault.retryable, - "retried": fault.retried, - }), - ); - } - let mut lines = vec![format!( "{} | {}", if health.initialization.ready && health.initialization.seed_captured { @@ -886,14 +872,6 @@ fn system_health_output(health: &HealthSnapshot) -> Result<ToolOutput, FaultReco "" } )); - if let Some(fault) = health.last_fault.as_ref() { - lines.push(format!( - "fault: {} {} {}", - format!("{:?}", fault.kind).to_ascii_lowercase(), - fault.operation, - fault.message, - )); - } detailed_tool_output( &Value::Object(concise), health, diff --git a/crates/fidget-spinner-cli/src/mcp/service.rs b/crates/fidget-spinner-cli/src/mcp/service.rs index aee53e0..62e3641 100644 --- a/crates/fidget-spinner-cli/src/mcp/service.rs +++ b/crates/fidget-spinner-cli/src/mcp/service.rs @@ -3,15 +3,18 @@ use std::fs; use camino::{Utf8Path, Utf8PathBuf}; use fidget_spinner_core::{ - AdmissionState, AnnotationVisibility, CodeSnapshotRef, CommandRecipe, ExecutionBackend, - FrontierContract, FrontierNote, FrontierProjection, FrontierRecord, FrontierVerdict, - MetricObservation, MetricSpec, MetricUnit, NodeAnnotation, NodeClass, NodePayload, - NonEmptyText, ProjectSchema, TagName, TagRecord, + AdmissionState, AnnotationVisibility, CodeSnapshotRef, CommandRecipe, DiagnosticSeverity, + ExecutionBackend, FieldPresence, FieldRole, FieldValueType, FrontierContract, FrontierNote, + FrontierProjection, FrontierRecord, FrontierVerdict, InferencePolicy, MetricSpec, MetricUnit, + MetricValue, NodeAnnotation, NodeClass, NodePayload, NonEmptyText, ProjectFieldSpec, + ProjectSchema, RunDimensionValue, TagName, TagRecord, }; use fidget_spinner_store_sqlite::{ - CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, EdgeAttachment, - EdgeAttachmentDirection, ExperimentReceipt, ListNodesQuery, NodeSummary, ProjectStore, - StoreError, + CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, DefineMetricRequest, + DefineRunDimensionRequest, EdgeAttachment, EdgeAttachmentDirection, ExperimentReceipt, + ListNodesQuery, MetricBestQuery, MetricFieldSource, MetricKeyQuery, MetricKeySummary, + MetricRankOrder, NodeSummary, ProjectStore, RemoveSchemaFieldRequest, StoreError, + UpsertSchemaFieldRequest, }; use serde::Deserialize; use serde_json::{Map, Value, json}; @@ -74,6 +77,73 @@ impl WorkerService { FaultStage::Worker, "tools/call:project.schema", ), + "schema.field.upsert" => { + let args = deserialize::<SchemaFieldUpsertToolArgs>(arguments)?; + let field = self + .store + .upsert_schema_field(UpsertSchemaFieldRequest { + name: NonEmptyText::new(args.name) + .map_err(store_fault("tools/call:schema.field.upsert"))?, + node_classes: args + .node_classes + .unwrap_or_default() + .into_iter() + .map(|class| { + parse_node_class_name(&class) + .map_err(store_fault("tools/call:schema.field.upsert")) + }) + .collect::<Result<_, _>>()?, + presence: parse_field_presence_name(&args.presence) + .map_err(store_fault("tools/call:schema.field.upsert"))?, + severity: parse_diagnostic_severity_name(&args.severity) + .map_err(store_fault("tools/call:schema.field.upsert"))?, + role: parse_field_role_name(&args.role) + .map_err(store_fault("tools/call:schema.field.upsert"))?, + inference_policy: parse_inference_policy_name(&args.inference_policy) + .map_err(store_fault("tools/call:schema.field.upsert"))?, + value_type: args + .value_type + .as_deref() + .map(parse_field_value_type_name) + .transpose() + .map_err(store_fault("tools/call:schema.field.upsert"))?, + }) + .map_err(store_fault("tools/call:schema.field.upsert"))?; + tool_success( + schema_field_upsert_output(self.store.schema(), &field)?, + presentation, + FaultStage::Worker, + "tools/call:schema.field.upsert", + ) + } + "schema.field.remove" => { + let args = deserialize::<SchemaFieldRemoveToolArgs>(arguments)?; + let removed_count = self + .store + .remove_schema_field(RemoveSchemaFieldRequest { + name: NonEmptyText::new(args.name) + .map_err(store_fault("tools/call:schema.field.remove"))?, + node_classes: args + .node_classes + .map(|node_classes| { + node_classes + .into_iter() + .map(|class| { + parse_node_class_name(&class) + .map_err(store_fault("tools/call:schema.field.remove")) + }) + .collect::<Result<_, _>>() + }) + .transpose()?, + }) + .map_err(store_fault("tools/call:schema.field.remove"))?; + tool_success( + schema_field_remove_output(self.store.schema(), removed_count)?, + presentation, + FaultStage::Worker, + "tools/call:schema.field.remove", + ) + } "tag.add" => { let args = deserialize::<TagAddToolArgs>(arguments)?; let tag = self @@ -402,7 +472,10 @@ impl WorkerService { .map_err(store_fault("tools/call:note.quick"))?, title: NonEmptyText::new(args.title) .map_err(store_fault("tools/call:note.quick"))?, - summary: None, + summary: Some( + NonEmptyText::new(args.summary) + .map_err(store_fault("tools/call:note.quick"))?, + ), tags: Some( parse_tag_set(args.tags) .map_err(store_fault("tools/call:note.quick"))?, @@ -439,12 +512,14 @@ impl WorkerService { .map_err(store_fault("tools/call:research.record"))?, title: NonEmptyText::new(args.title) .map_err(store_fault("tools/call:research.record"))?, - summary: args - .summary - .map(NonEmptyText::new) - .transpose() - .map_err(store_fault("tools/call:research.record"))?, - tags: None, + summary: Some( + NonEmptyText::new(args.summary) + .map_err(store_fault("tools/call:research.record"))?, + ), + tags: Some( + parse_tag_set(args.tags) + .map_err(store_fault("tools/call:research.record"))?, + ), payload: NodePayload::with_schema( self.store.schema().schema_ref(), crate::json_object(json!({ "body": args.body })) @@ -463,6 +538,170 @@ impl WorkerService { "tools/call:research.record", ) } + "metric.define" => { + let args = deserialize::<MetricDefineToolArgs>(arguments)?; + let metric = self + .store + .define_metric(DefineMetricRequest { + key: NonEmptyText::new(args.key) + .map_err(store_fault("tools/call:metric.define"))?, + unit: parse_metric_unit_name(&args.unit) + .map_err(store_fault("tools/call:metric.define"))?, + objective: crate::parse_optimization_objective(&args.objective) + .map_err(store_fault("tools/call:metric.define"))?, + description: args + .description + .map(NonEmptyText::new) + .transpose() + .map_err(store_fault("tools/call:metric.define"))?, + }) + .map_err(store_fault("tools/call:metric.define"))?; + tool_success( + json_created_output( + "registered metric", + json!({ + "key": metric.key, + "unit": metric_unit_name(metric.unit), + "objective": metric_objective_name(metric.objective), + "description": metric.description, + }), + "tools/call:metric.define", + )?, + presentation, + FaultStage::Worker, + "tools/call:metric.define", + ) + } + "run.dimension.define" => { + let args = deserialize::<RunDimensionDefineToolArgs>(arguments)?; + let dimension = self + .store + .define_run_dimension(DefineRunDimensionRequest { + key: NonEmptyText::new(args.key) + .map_err(store_fault("tools/call:run.dimension.define"))?, + value_type: parse_field_value_type_name(&args.value_type) + .map_err(store_fault("tools/call:run.dimension.define"))?, + description: args + .description + .map(NonEmptyText::new) + .transpose() + .map_err(store_fault("tools/call:run.dimension.define"))?, + }) + .map_err(store_fault("tools/call:run.dimension.define"))?; + tool_success( + json_created_output( + "registered run dimension", + json!({ + "key": dimension.key, + "value_type": dimension.value_type.as_str(), + "description": dimension.description, + }), + "tools/call:run.dimension.define", + )?, + presentation, + FaultStage::Worker, + "tools/call:run.dimension.define", + ) + } + "run.dimension.list" => { + let items = self + .store + .list_run_dimensions() + .map_err(store_fault("tools/call:run.dimension.list"))?; + tool_success( + run_dimension_list_output(items.as_slice())?, + presentation, + FaultStage::Worker, + "tools/call:run.dimension.list", + ) + } + "metric.keys" => { + let args = deserialize::<MetricKeysToolArgs>(arguments)?; + let keys = self + .store + .list_metric_keys_filtered(MetricKeyQuery { + frontier_id: args + .frontier_id + .as_deref() + .map(crate::parse_frontier_id) + .transpose() + .map_err(store_fault("tools/call:metric.keys"))?, + source: args + .source + .as_deref() + .map(parse_metric_source_name) + .transpose() + .map_err(store_fault("tools/call:metric.keys"))?, + dimensions: coerce_tool_dimensions( + &self.store, + args.dimensions.unwrap_or_default(), + "tools/call:metric.keys", + )?, + }) + .map_err(store_fault("tools/call:metric.keys"))?; + tool_success( + metric_keys_output(keys.as_slice())?, + presentation, + FaultStage::Worker, + "tools/call:metric.keys", + ) + } + "metric.best" => { + let args = deserialize::<MetricBestToolArgs>(arguments)?; + let items = self + .store + .best_metrics(MetricBestQuery { + key: NonEmptyText::new(args.key) + .map_err(store_fault("tools/call:metric.best"))?, + frontier_id: args + .frontier_id + .as_deref() + .map(crate::parse_frontier_id) + .transpose() + .map_err(store_fault("tools/call:metric.best"))?, + source: args + .source + .as_deref() + .map(parse_metric_source_name) + .transpose() + .map_err(store_fault("tools/call:metric.best"))?, + dimensions: coerce_tool_dimensions( + &self.store, + args.dimensions.unwrap_or_default(), + "tools/call:metric.best", + )?, + order: args + .order + .as_deref() + .map(parse_metric_order_name) + .transpose() + .map_err(store_fault("tools/call:metric.best"))?, + limit: args.limit.unwrap_or(10), + }) + .map_err(store_fault("tools/call:metric.best"))?; + tool_success( + metric_best_output(items.as_slice())?, + presentation, + FaultStage::Worker, + "tools/call:metric.best", + ) + } + "metric.migrate" => { + let report = self + .store + .migrate_metric_plane() + .map_err(store_fault("tools/call:metric.migrate"))?; + tool_success( + json_created_output( + "normalized legacy metric plane", + json!(report), + "tools/call:metric.migrate", + )?, + presentation, + FaultStage::Worker, + "tools/call:metric.migrate", + ) + } "experiment.close" => { let args = deserialize::<ExperimentCloseToolArgs>(arguments)?; let frontier_id = crate::parse_frontier_id(&args.frontier_id) @@ -507,8 +746,11 @@ impl WorkerService { .map_err(store_fault("tools/call:experiment.close"))?, backend: parse_backend_name(&args.run.backend) .map_err(store_fault("tools/call:experiment.close"))?, - benchmark_suite: NonEmptyText::new(args.run.benchmark_suite) - .map_err(store_fault("tools/call:experiment.close"))?, + dimensions: coerce_tool_dimensions( + &self.store, + args.run.dimensions, + "tools/call:experiment.close", + )?, command: command_recipe_from_wire( args.run.command, self.store.project_root(), @@ -518,12 +760,12 @@ impl WorkerService { capture_code_snapshot(self.store.project_root()) .map_err(store_fault("tools/call:experiment.close"))?, ), - primary_metric: metric_observation_from_wire(args.primary_metric) + primary_metric: metric_value_from_wire(args.primary_metric) .map_err(store_fault("tools/call:experiment.close"))?, supporting_metrics: args .supporting_metrics .into_iter() - .map(metric_observation_from_wire) + .map(metric_value_from_wire) .collect::<Result<Vec<_>, _>>() .map_err(store_fault("tools/call:experiment.close"))?, note: FrontierNote { @@ -547,7 +789,7 @@ impl WorkerService { }) .map_err(store_fault("tools/call:experiment.close"))?; tool_success( - experiment_close_output(&receipt)?, + experiment_close_output(&self.store, &receipt)?, presentation, FaultStage::Worker, "tools/call:experiment.close", @@ -692,8 +934,8 @@ fn project_schema_output(schema: &ProjectSchema) -> Result<ToolOutput, FaultReco .collect::<Vec<_>>() .join(",") }, - format!("{:?}", field.presence).to_ascii_lowercase(), - format!("{:?}", field.role).to_ascii_lowercase(), + field.presence.as_str(), + field.role.as_str(), )); } if schema.fields.len() > 8 { @@ -709,6 +951,59 @@ fn project_schema_output(schema: &ProjectSchema) -> Result<ToolOutput, FaultReco ) } +fn schema_field_upsert_output( + schema: &ProjectSchema, + field: &ProjectFieldSpec, +) -> Result<ToolOutput, FaultRecord> { + let concise = json!({ + "schema": schema.schema_ref(), + "field": project_schema_field_value(field), + }); + detailed_tool_output( + &concise, + &concise, + format!( + "upserted schema field {}\nschema: {}\nclasses: {}\npresence: {}\nseverity: {}\nrole: {}\ninference: {}{}", + field.name, + schema_label(schema), + render_schema_node_classes(&field.node_classes), + field.presence.as_str(), + field.severity.as_str(), + field.role.as_str(), + field.inference_policy.as_str(), + field + .value_type + .map(|value_type| format!("\nvalue_type: {}", value_type.as_str())) + .unwrap_or_default(), + ), + None, + FaultStage::Worker, + "tools/call:schema.field.upsert", + ) +} + +fn schema_field_remove_output( + schema: &ProjectSchema, + removed_count: u64, +) -> Result<ToolOutput, FaultRecord> { + let concise = json!({ + "schema": schema.schema_ref(), + "removed_count": removed_count, + }); + detailed_tool_output( + &concise, + &concise, + format!( + "removed {} schema field definition(s)\nschema: {}", + removed_count, + schema_label(schema), + ), + None, + FaultStage::Worker, + "tools/call:schema.field.remove", + ) +} + fn tag_add_output(tag: &TagRecord) -> Result<ToolOutput, FaultRecord> { let concise = json!({ "name": tag.name, @@ -892,14 +1187,33 @@ fn node_read_output(node: &fidget_spinner_core::DagNode) -> Result<ToolOutput, F ); } if !node.payload.fields.is_empty() { - let _ = concise.insert( - "payload_field_count".to_owned(), - json!(node.payload.fields.len()), - ); - let _ = concise.insert( - "payload_preview".to_owned(), - payload_preview_value(&node.payload.fields), - ); + let filtered_fields = + filtered_payload_fields(node.class, &node.payload.fields).collect::<Vec<_>>(); + if !filtered_fields.is_empty() { + let _ = concise.insert( + "payload_field_count".to_owned(), + json!(filtered_fields.len()), + ); + if is_prose_node(node.class) { + let _ = concise.insert( + "payload_fields".to_owned(), + json!( + filtered_fields + .iter() + .take(6) + .map(|(name, _)| (*name).clone()) + .collect::<Vec<_>>() + ), + ); + } else { + let payload_preview = payload_preview_value(node.class, &node.payload.fields); + if let Value::Object(object) = &payload_preview + && !object.is_empty() + { + let _ = concise.insert("payload_preview".to_owned(), payload_preview); + } + } + } } if !node.diagnostics.items.is_empty() { let _ = concise.insert( @@ -930,7 +1244,7 @@ fn node_read_output(node: &fidget_spinner_core::DagNode) -> Result<ToolOutput, F if !node.tags.is_empty() { lines.push(format!("tags: {}", format_tags(&node.tags))); } - lines.extend(payload_preview_lines(&node.payload.fields)); + lines.extend(payload_preview_lines(node.class, &node.payload.fields)); if !node.diagnostics.items.is_empty() { lines.push(format!( "diagnostics: {}", @@ -972,7 +1286,10 @@ fn node_read_output(node: &fidget_spinner_core::DagNode) -> Result<ToolOutput, F ) } -fn experiment_close_output(receipt: &ExperimentReceipt) -> Result<ToolOutput, FaultRecord> { +fn experiment_close_output( + store: &ProjectStore, + receipt: &ExperimentReceipt, +) -> Result<ToolOutput, FaultRecord> { let concise = json!({ "experiment_id": receipt.experiment.id, "frontier_id": receipt.experiment.frontier_id, @@ -980,7 +1297,8 @@ fn experiment_close_output(receipt: &ExperimentReceipt) -> Result<ToolOutput, Fa "verdict": format!("{:?}", receipt.experiment.verdict).to_ascii_lowercase(), "run_id": receipt.run.run_id, "decision_node_id": receipt.decision_node.id, - "primary_metric": metric_value(&receipt.experiment.result.primary_metric), + "dimensions": run_dimensions_value(&receipt.experiment.result.dimensions), + "primary_metric": metric_value(store, &receipt.experiment.result.primary_metric)?, }); detailed_tool_output( &concise, @@ -997,7 +1315,11 @@ fn experiment_close_output(receipt: &ExperimentReceipt) -> Result<ToolOutput, Fa ), format!( "primary metric: {}", - metric_text(&receipt.experiment.result.primary_metric) + metric_text(store, &receipt.experiment.result.primary_metric)? + ), + format!( + "dimensions: {}", + render_dimension_kv(&receipt.experiment.result.dimensions) ), format!("run: {}", receipt.run.run_id), ] @@ -1008,7 +1330,181 @@ fn experiment_close_output(receipt: &ExperimentReceipt) -> Result<ToolOutput, Fa ) } -fn project_schema_field_value(field: &fidget_spinner_core::ProjectFieldSpec) -> Value { +fn metric_keys_output(keys: &[MetricKeySummary]) -> Result<ToolOutput, FaultRecord> { + let concise = keys + .iter() + .map(|key| { + json!({ + "key": key.key, + "source": key.source.as_str(), + "experiment_count": key.experiment_count, + "unit": key.unit.map(metric_unit_name), + "objective": key.objective.map(metric_objective_name), + "description": key.description, + "requires_order": key.requires_order, + }) + }) + .collect::<Vec<_>>(); + let mut lines = vec![format!("{} metric key(s)", keys.len())]; + lines.extend(keys.iter().map(|key| { + let mut line = format!( + "{} [{}] experiments={}", + key.key, + key.source.as_str(), + key.experiment_count + ); + if let Some(unit) = key.unit { + line.push_str(format!(" unit={}", metric_unit_name(unit)).as_str()); + } + if let Some(objective) = key.objective { + line.push_str(format!(" objective={}", metric_objective_name(objective)).as_str()); + } + if let Some(description) = key.description.as_ref() { + line.push_str(format!(" | {description}").as_str()); + } + if key.requires_order { + line.push_str(" order=required"); + } + line + })); + detailed_tool_output( + &concise, + &keys, + lines.join("\n"), + None, + FaultStage::Worker, + "tools/call:metric.keys", + ) +} + +fn metric_best_output( + items: &[fidget_spinner_store_sqlite::MetricBestEntry], +) -> Result<ToolOutput, FaultRecord> { + let concise = items + .iter() + .enumerate() + .map(|(index, item)| { + json!({ + "rank": index + 1, + "key": item.key, + "source": item.source.as_str(), + "value": item.value, + "order": item.order.as_str(), + "experiment_id": item.experiment_id, + "frontier_id": item.frontier_id, + "change_node_id": item.change_node_id, + "change_title": item.change_title, + "verdict": metric_verdict_name(item.verdict), + "candidate_checkpoint_id": item.candidate_checkpoint_id, + "candidate_commit_hash": item.candidate_commit_hash, + "run_id": item.run_id, + "unit": item.unit.map(metric_unit_name), + "objective": item.objective.map(metric_objective_name), + "dimensions": run_dimensions_value(&item.dimensions), + }) + }) + .collect::<Vec<_>>(); + let mut lines = vec![format!("{} ranked experiment(s)", items.len())]; + lines.extend(items.iter().enumerate().map(|(index, item)| { + format!( + "{}. {}={} [{}] {} | verdict={} | commit={} | checkpoint={}", + index + 1, + item.key, + item.value, + item.source.as_str(), + item.change_title, + metric_verdict_name(item.verdict), + item.candidate_commit_hash, + item.candidate_checkpoint_id, + ) + })); + lines.extend( + items + .iter() + .map(|item| format!(" dims: {}", render_dimension_kv(&item.dimensions))), + ); + detailed_tool_output( + &concise, + &items, + lines.join("\n"), + None, + FaultStage::Worker, + "tools/call:metric.best", + ) +} + +fn run_dimension_list_output( + items: &[fidget_spinner_store_sqlite::RunDimensionSummary], +) -> Result<ToolOutput, FaultRecord> { + let concise = items + .iter() + .map(|item| { + json!({ + "key": item.key, + "value_type": item.value_type.as_str(), + "description": item.description, + "observed_run_count": item.observed_run_count, + "distinct_value_count": item.distinct_value_count, + "sample_values": item.sample_values, + }) + }) + .collect::<Vec<_>>(); + let mut lines = vec![format!("{} run dimension(s)", items.len())]; + lines.extend(items.iter().map(|item| { + let mut line = format!( + "{} [{}] runs={} distinct={}", + item.key, + item.value_type.as_str(), + item.observed_run_count, + item.distinct_value_count + ); + if let Some(description) = item.description.as_ref() { + line.push_str(format!(" | {description}").as_str()); + } + if !item.sample_values.is_empty() { + line.push_str( + format!( + " | samples={}", + item.sample_values + .iter() + .map(value_summary) + .collect::<Vec<_>>() + .join(", ") + ) + .as_str(), + ); + } + line + })); + detailed_tool_output( + &concise, + &items, + lines.join("\n"), + None, + FaultStage::Worker, + "tools/call:run.dimension.list", + ) +} + +fn json_created_output( + headline: &str, + structured: Value, + operation: &'static str, +) -> Result<ToolOutput, FaultRecord> { + detailed_tool_output( + &structured, + &structured, + format!( + "{headline}\n{}", + crate::to_pretty_json(&structured).map_err(store_fault(operation))? + ), + None, + FaultStage::Worker, + operation, + ) +} + +fn project_schema_field_value(field: &ProjectFieldSpec) -> Value { let mut value = Map::new(); let _ = value.insert("name".to_owned(), json!(field.name)); if !field.node_classes.is_empty() { @@ -1023,21 +1519,12 @@ fn project_schema_field_value(field: &fidget_spinner_core::ProjectFieldSpec) -> ), ); } - let _ = value.insert( - "presence".to_owned(), - json!(format!("{:?}", field.presence).to_ascii_lowercase()), - ); - let _ = value.insert( - "severity".to_owned(), - json!(format!("{:?}", field.severity).to_ascii_lowercase()), - ); - let _ = value.insert( - "role".to_owned(), - json!(format!("{:?}", field.role).to_ascii_lowercase()), - ); + let _ = value.insert("presence".to_owned(), json!(field.presence.as_str())); + let _ = value.insert("severity".to_owned(), json!(field.severity.as_str())); + let _ = value.insert("role".to_owned(), json!(field.role.as_str())); let _ = value.insert( "inference_policy".to_owned(), - json!(format!("{:?}", field.inference_policy).to_ascii_lowercase()), + json!(field.inference_policy.as_str()), ); if let Some(value_type) = field.value_type { let _ = value.insert("value_type".to_owned(), json!(value_type.as_str())); @@ -1045,6 +1532,17 @@ fn project_schema_field_value(field: &fidget_spinner_core::ProjectFieldSpec) -> Value::Object(value) } +fn render_schema_node_classes(node_classes: &BTreeSet<NodeClass>) -> String { + if node_classes.is_empty() { + return "any".to_owned(); + } + node_classes + .iter() + .map(ToString::to_string) + .collect::<Vec<_>>() + .join(", ") +} + fn frontier_projection_summary_value(projection: &FrontierProjection) -> Value { json!({ "frontier_id": projection.frontier.id, @@ -1211,17 +1709,17 @@ fn diagnostic_tally(diagnostics: &fidget_spinner_core::NodeDiagnostics) -> Diagn .fold(DiagnosticTally::default(), |mut tally, item| { tally.total += 1; match item.severity { - fidget_spinner_core::DiagnosticSeverity::Error => tally.errors += 1, - fidget_spinner_core::DiagnosticSeverity::Warning => tally.warnings += 1, - fidget_spinner_core::DiagnosticSeverity::Info => tally.infos += 1, + DiagnosticSeverity::Error => tally.errors += 1, + DiagnosticSeverity::Warning => tally.warnings += 1, + DiagnosticSeverity::Info => tally.infos += 1, } tally }) } -fn payload_preview_value(fields: &Map<String, Value>) -> Value { +fn payload_preview_value(class: NodeClass, fields: &Map<String, Value>) -> Value { let mut preview = Map::new(); - for (index, (name, value)) in fields.iter().enumerate() { + for (index, (name, value)) in filtered_payload_fields(class, fields).enumerate() { if index == 6 { let _ = preview.insert( "...".to_owned(), @@ -1234,14 +1732,33 @@ fn payload_preview_value(fields: &Map<String, Value>) -> Value { Value::Object(preview) } -fn payload_preview_lines(fields: &Map<String, Value>) -> Vec<String> { - if fields.is_empty() { +fn payload_preview_lines(class: NodeClass, fields: &Map<String, Value>) -> Vec<String> { + let filtered = filtered_payload_fields(class, fields).collect::<Vec<_>>(); + if filtered.is_empty() { return Vec::new(); } - let mut lines = vec![format!("payload fields: {}", fields.len())]; - for (index, (name, value)) in fields.iter().enumerate() { + if is_prose_node(class) { + let preview_names = filtered + .iter() + .take(6) + .map(|(name, _)| (*name).clone()) + .collect::<Vec<_>>(); + let mut lines = vec![format!("payload fields: {}", preview_names.join(", "))]; + if filtered.len() > preview_names.len() { + lines.push(format!( + "payload fields: +{} more field(s)", + filtered.len() - preview_names.len() + )); + } + return lines; + } + let mut lines = vec![format!("payload fields: {}", filtered.len())]; + for (index, (name, value)) in filtered.iter().enumerate() { if index == 6 { - lines.push(format!("payload: +{} more field(s)", fields.len() - index)); + lines.push(format!( + "payload: +{} more field(s)", + filtered.len() - index + )); break; } lines.push(format!( @@ -1253,10 +1770,19 @@ fn payload_preview_lines(fields: &Map<String, Value>) -> Vec<String> { lines } +fn filtered_payload_fields( + class: NodeClass, + fields: &Map<String, Value>, +) -> impl Iterator<Item = (&String, &Value)> + '_ { + fields.iter().filter(move |(name, _)| { + !matches!(class, NodeClass::Note | NodeClass::Research) || name.as_str() != "body" + }) +} + fn payload_value_preview(value: &Value) -> Value { match value { Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(), - Value::String(text) => Value::String(libmcp::collapse_inline_whitespace(text)), + Value::String(text) => Value::String(truncated_inline_preview(text, 96)), Value::Array(items) => { let preview = items .iter() @@ -1290,25 +1816,89 @@ fn payload_value_preview(value: &Value) -> Value { } } -fn metric_value(metric: &MetricObservation) -> Value { - json!({ - "key": metric.metric_key, +fn is_prose_node(class: NodeClass) -> bool { + matches!(class, NodeClass::Note | NodeClass::Research) +} + +fn truncated_inline_preview(text: &str, limit: usize) -> String { + let collapsed = libmcp::collapse_inline_whitespace(text); + let truncated = libmcp::render::truncate_chars(&collapsed, Some(limit)); + if truncated.truncated { + format!("{}...", truncated.text) + } else { + truncated.text + } +} + +fn metric_value(store: &ProjectStore, metric: &MetricValue) -> Result<Value, FaultRecord> { + let definition = metric_definition(store, &metric.key)?; + Ok(json!({ + "key": metric.key, "value": metric.value, - "unit": format!("{:?}", metric.unit).to_ascii_lowercase(), - "objective": format!("{:?}", metric.objective).to_ascii_lowercase(), - }) + "unit": metric_unit_name(definition.unit), + "objective": metric_objective_name(definition.objective), + })) } -fn metric_text(metric: &MetricObservation) -> String { - format!( +fn metric_text(store: &ProjectStore, metric: &MetricValue) -> Result<String, FaultRecord> { + let definition = metric_definition(store, &metric.key)?; + Ok(format!( "{}={} {} ({})", - metric.metric_key, + metric.key, metric.value, - format!("{:?}", metric.unit).to_ascii_lowercase(), - format!("{:?}", metric.objective).to_ascii_lowercase(), + metric_unit_name(definition.unit), + metric_objective_name(definition.objective), + )) +} + +fn metric_unit_name(unit: MetricUnit) -> &'static str { + match unit { + MetricUnit::Seconds => "seconds", + MetricUnit::Bytes => "bytes", + MetricUnit::Count => "count", + MetricUnit::Ratio => "ratio", + MetricUnit::Custom => "custom", + } +} + +fn metric_objective_name(objective: fidget_spinner_core::OptimizationObjective) -> &'static str { + match objective { + fidget_spinner_core::OptimizationObjective::Minimize => "minimize", + fidget_spinner_core::OptimizationObjective::Maximize => "maximize", + fidget_spinner_core::OptimizationObjective::Target => "target", + } +} + +fn metric_verdict_name(verdict: FrontierVerdict) -> &'static str { + match verdict { + FrontierVerdict::PromoteToChampion => "promote_to_champion", + FrontierVerdict::KeepOnFrontier => "keep_on_frontier", + FrontierVerdict::RevertToChampion => "revert_to_champion", + FrontierVerdict::ArchiveDeadEnd => "archive_dead_end", + FrontierVerdict::NeedsMoreEvidence => "needs_more_evidence", + } +} + +fn run_dimensions_value(dimensions: &BTreeMap<NonEmptyText, RunDimensionValue>) -> Value { + Value::Object( + dimensions + .iter() + .map(|(key, value)| (key.to_string(), value.as_json())) + .collect::<Map<String, Value>>(), ) } +fn render_dimension_kv(dimensions: &BTreeMap<NonEmptyText, RunDimensionValue>) -> String { + if dimensions.is_empty() { + return "none".to_owned(); + } + dimensions + .iter() + .map(|(key, value)| format!("{key}={}", value_summary(&value.as_json()))) + .collect::<Vec<_>>() + .join(", ") +} + fn format_tags(tags: &BTreeSet<TagName>) -> String { tags.iter() .map(ToString::to_string) @@ -1360,6 +1950,12 @@ fn classify_fault_kind(message: &str) -> FaultKind { || message.contains("empty") || message.contains("already exists") || message.contains("require an explicit tag list") + || message.contains("requires a non-empty summary") + || message.contains("requires a non-empty string payload field `body`") + || message.contains("requires an explicit order") + || message.contains("is ambiguous across sources") + || message.contains("has conflicting semantics") + || message.contains("conflicts with existing definition") { FaultKind::InvalidInput } else { @@ -1414,17 +2010,44 @@ fn metric_spec_from_wire(raw: WireMetricSpec) -> Result<MetricSpec, StoreError> }) } -fn metric_observation_from_wire( - raw: WireMetricObservation, -) -> Result<MetricObservation, StoreError> { - Ok(MetricObservation { - metric_key: NonEmptyText::new(raw.key)?, - unit: parse_metric_unit_name(&raw.unit)?, - objective: crate::parse_optimization_objective(&raw.objective)?, +fn metric_value_from_wire(raw: WireMetricValue) -> Result<MetricValue, StoreError> { + Ok(MetricValue { + key: NonEmptyText::new(raw.key)?, value: raw.value, }) } +fn metric_definition(store: &ProjectStore, key: &NonEmptyText) -> Result<MetricSpec, FaultRecord> { + store + .list_metric_definitions() + .map_err(store_fault("tools/call:experiment.close"))? + .into_iter() + .find(|definition| definition.key == *key) + .map(|definition| MetricSpec { + metric_key: definition.key, + unit: definition.unit, + objective: definition.objective, + }) + .ok_or_else(|| { + FaultRecord::new( + FaultKind::InvalidInput, + FaultStage::Store, + "tools/call:experiment.close", + format!("metric `{key}` is not registered"), + ) + }) +} + +fn coerce_tool_dimensions( + store: &ProjectStore, + raw_dimensions: BTreeMap<String, Value>, + operation: &'static str, +) -> Result<BTreeMap<NonEmptyText, RunDimensionValue>, FaultRecord> { + store + .coerce_run_dimensions(raw_dimensions) + .map_err(store_fault(operation)) +} + fn command_recipe_from_wire( raw: WireRunCommand, project_root: &Utf8Path, @@ -1465,6 +2088,91 @@ fn parse_metric_unit_name(raw: &str) -> Result<MetricUnit, StoreError> { crate::parse_metric_unit(raw) } +fn parse_metric_source_name(raw: &str) -> Result<MetricFieldSource, StoreError> { + match raw { + "run_metric" => Ok(MetricFieldSource::RunMetric), + "change_payload" => Ok(MetricFieldSource::ChangePayload), + "run_payload" => Ok(MetricFieldSource::RunPayload), + "analysis_payload" => Ok(MetricFieldSource::AnalysisPayload), + "decision_payload" => Ok(MetricFieldSource::DecisionPayload), + other => Err(StoreError::Json(serde_json::Error::io( + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown metric source `{other}`"), + ), + ))), + } +} + +fn parse_metric_order_name(raw: &str) -> Result<MetricRankOrder, StoreError> { + match raw { + "asc" => Ok(MetricRankOrder::Asc), + "desc" => Ok(MetricRankOrder::Desc), + other => Err(StoreError::Json(serde_json::Error::io( + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown metric order `{other}`"), + ), + ))), + } +} + +fn parse_field_value_type_name(raw: &str) -> Result<FieldValueType, StoreError> { + match raw { + "string" => Ok(FieldValueType::String), + "numeric" => Ok(FieldValueType::Numeric), + "boolean" => Ok(FieldValueType::Boolean), + "timestamp" => Ok(FieldValueType::Timestamp), + other => Err(crate::invalid_input(format!( + "unknown field value type `{other}`" + ))), + } +} + +fn parse_diagnostic_severity_name(raw: &str) -> Result<DiagnosticSeverity, StoreError> { + match raw { + "error" => Ok(DiagnosticSeverity::Error), + "warning" => Ok(DiagnosticSeverity::Warning), + "info" => Ok(DiagnosticSeverity::Info), + other => Err(crate::invalid_input(format!( + "unknown diagnostic severity `{other}`" + ))), + } +} + +fn parse_field_presence_name(raw: &str) -> Result<FieldPresence, StoreError> { + match raw { + "required" => Ok(FieldPresence::Required), + "recommended" => Ok(FieldPresence::Recommended), + "optional" => Ok(FieldPresence::Optional), + other => Err(crate::invalid_input(format!( + "unknown field presence `{other}`" + ))), + } +} + +fn parse_field_role_name(raw: &str) -> Result<FieldRole, StoreError> { + match raw { + "index" => Ok(FieldRole::Index), + "projection_gate" => Ok(FieldRole::ProjectionGate), + "render_only" => Ok(FieldRole::RenderOnly), + "opaque" => Ok(FieldRole::Opaque), + other => Err(crate::invalid_input(format!( + "unknown field role `{other}`" + ))), + } +} + +fn parse_inference_policy_name(raw: &str) -> Result<InferencePolicy, StoreError> { + match raw { + "manual_only" => Ok(InferencePolicy::ManualOnly), + "model_may_infer" => Ok(InferencePolicy::ModelMayInfer), + other => Err(crate::invalid_input(format!( + "unknown inference policy `{other}`" + ))), + } +} + fn parse_backend_name(raw: &str) -> Result<ExecutionBackend, StoreError> { match raw { "local_process" => Ok(ExecutionBackend::LocalProcess), @@ -1574,6 +2282,7 @@ struct NodeArchiveToolArgs { struct QuickNoteToolArgs { frontier_id: Option<String>, title: String, + summary: String, body: String, tags: Vec<String>, #[serde(default)] @@ -1586,24 +2295,75 @@ struct QuickNoteToolArgs { struct ResearchRecordToolArgs { frontier_id: Option<String>, title: String, - summary: Option<String>, + summary: String, body: String, #[serde(default)] + tags: Vec<String>, + #[serde(default)] annotations: Vec<WireAnnotation>, #[serde(default)] parents: Vec<String>, } #[derive(Debug, Deserialize)] +struct SchemaFieldUpsertToolArgs { + name: String, + node_classes: Option<Vec<String>>, + presence: String, + severity: String, + role: String, + inference_policy: String, + value_type: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct SchemaFieldRemoveToolArgs { + name: String, + node_classes: Option<Vec<String>>, +} + +#[derive(Debug, Deserialize)] +struct MetricDefineToolArgs { + key: String, + unit: String, + objective: String, + description: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct RunDimensionDefineToolArgs { + key: String, + value_type: String, + description: Option<String>, +} + +#[derive(Debug, Deserialize, Default)] +struct MetricKeysToolArgs { + frontier_id: Option<String>, + source: Option<String>, + dimensions: Option<BTreeMap<String, Value>>, +} + +#[derive(Debug, Deserialize)] +struct MetricBestToolArgs { + key: String, + frontier_id: Option<String>, + source: Option<String>, + dimensions: Option<BTreeMap<String, Value>>, + order: Option<String>, + limit: Option<u32>, +} + +#[derive(Debug, Deserialize)] struct ExperimentCloseToolArgs { frontier_id: String, base_checkpoint_id: String, change_node_id: String, candidate_summary: String, run: WireRun, - primary_metric: WireMetricObservation, + primary_metric: WireMetricValue, #[serde(default)] - supporting_metrics: Vec<WireMetricObservation>, + supporting_metrics: Vec<WireMetricValue>, note: WireFrontierNote, verdict: String, decision_title: String, @@ -1627,10 +2387,8 @@ struct WireMetricSpec { } #[derive(Debug, Deserialize)] -struct WireMetricObservation { +struct WireMetricValue { key: String, - unit: String, - objective: String, value: f64, } @@ -1639,7 +2397,8 @@ struct WireRun { title: String, summary: Option<String>, backend: String, - benchmark_suite: String, + #[serde(default)] + dimensions: BTreeMap<String, Value>, command: WireRunCommand, } diff --git a/crates/fidget-spinner-cli/src/mcp/telemetry.rs b/crates/fidget-spinner-cli/src/mcp/telemetry.rs index 7206f76..93fbe71 100644 --- a/crates/fidget-spinner-cli/src/mcp/telemetry.rs +++ b/crates/fidget-spinner-cli/src/mcp/telemetry.rs @@ -36,6 +36,7 @@ impl ServerTelemetry { pub fn record_success(&mut self, operation: &str, latency_ms: u128) { self.successes += 1; + self.last_fault = None; let entry = self.operations.entry(operation.to_owned()).or_default(); entry.successes += 1; entry.last_latency_ms = Some(latency_ms); |