swarm repositories / source
aboutsummaryrefslogtreecommitdiff
path: root/crates/fidget-spinner-cli/src/mcp/service.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fidget-spinner-cli/src/mcp/service.rs')
-rw-r--r--crates/fidget-spinner-cli/src/mcp/service.rs925
1 files changed, 842 insertions, 83 deletions
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,
}