diff --git a/Cargo.toml b/Cargo.toml index 72caa4314..fe4af238b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "aimux-providers", "aimux-stream", "tools/aimux-cli", + "tools/aimux-codegen", "tools/aimux-replay", "tools/aimux-web", ] diff --git a/tools/aimux-codegen/Cargo.toml b/tools/aimux-codegen/Cargo.toml new file mode 100644 index 000000000..178e18919 --- /dev/null +++ b/tools/aimux-codegen/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "aimux-codegen" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Generator PoC (phase 1 unit 1.8): one type spec -> TypeScript + Go" +publish = false + +[[bin]] +name = "aimux-codegen" +path = "src/main.rs" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true + +[dev-dependencies] +aimux-core = { workspace = true } diff --git a/tools/aimux-codegen/generated/go/tool_call.go b/tools/aimux-codegen/generated/go/tool_call.go new file mode 100644 index 000000000..bc789ac0d --- /dev/null +++ b/tools/aimux-codegen/generated/go/tool_call.go @@ -0,0 +1,27 @@ +// Code generated by aimux-codegen (tools/aimux-codegen). DO NOT EDIT. + +package aimuxgen + +import "encoding/json" + +// A tool call requested by the model. +type ToolCall struct { + // Provider-assigned call id. + ToolCallID string `json:"toolCallId"` + // Tool name. + ToolName string `json:"toolName"` + // Parsed arguments, or the original string when `invalid` is true and the + // provider input was not valid JSON. + Input json.RawMessage `json:"input"` + // Whether the tool call will be executed by the provider. + ProviderExecuted *bool `json:"providerExecuted,omitempty"` + // Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + Dynamic *bool `json:"dynamic,omitempty"` + // Additional provider-specific metadata associated with this call. + ProviderMetadata json.RawMessage `json:"providerMetadata,omitempty"` + // Set when lookup, JSON parsing, or schema validation still failed after + // the optional repair attempt. + Invalid *bool `json:"invalid,omitempty"` + // Typed failure associated with an invalid tool call. + Error json.RawMessage `json:"error,omitempty"` +} diff --git a/tools/aimux-codegen/generated/ts/ToolCall.ts b/tools/aimux-codegen/generated/ts/ToolCall.ts new file mode 100644 index 000000000..efc128252 --- /dev/null +++ b/tools/aimux-codegen/generated/ts/ToolCall.ts @@ -0,0 +1,42 @@ +// This file was generated by aimux-codegen (tools/aimux-codegen). Do not edit this file manually. +import type { AiMuxError } from "./AiMuxError"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * A tool call requested by the model. + */ +export type ToolCall = { +/** + * Provider-assigned call id. + */ +toolCallId: string, +/** + * Tool name. + */ +toolName: string, +/** + * Parsed arguments, or the original string when `invalid` is true and the + * provider input was not valid JSON. + */ +input: JsonValue, +/** + * Whether the tool call will be executed by the provider. + */ +providerExecuted?: boolean, +/** + * Whether the tool is dynamic (defined at runtime, e.g. MCP tools). + */ +dynamic?: boolean, +/** + * Additional provider-specific metadata associated with this call. + */ +providerMetadata?: { [key in string]: { [key in string]: JsonValue } }, +/** + * Set when lookup, JSON parsing, or schema validation still failed after + * the optional repair attempt. + */ +invalid?: boolean, +/** + * Typed failure associated with an invalid tool call. + */ +error?: AiMuxError, }; diff --git a/tools/aimux-codegen/spec/ToolCall.json b/tools/aimux-codegen/spec/ToolCall.json new file mode 100644 index 000000000..52611a348 --- /dev/null +++ b/tools/aimux-codegen/spec/ToolCall.json @@ -0,0 +1,14 @@ +{ + "name": "ToolCall", + "doc": ["A tool call requested by the model."], + "fields": [ + { "name": "toolCallId", "type": "string", "optional": false, "doc": ["Provider-assigned call id."] }, + { "name": "toolName", "type": "string", "optional": false, "doc": ["Tool name."] }, + { "name": "input", "type": "json", "optional": false, "doc": ["Parsed arguments, or the original string when `invalid` is true and the", "provider input was not valid JSON."] }, + { "name": "providerExecuted", "type": "bool", "optional": true, "doc": ["Whether the tool call will be executed by the provider."] }, + { "name": "dynamic", "type": "bool", "optional": true, "doc": ["Whether the tool is dynamic (defined at runtime, e.g. MCP tools)."] }, + { "name": "providerMetadata", "type": "provider_metadata", "optional": true, "doc": ["Additional provider-specific metadata associated with this call."] }, + { "name": "invalid", "type": "bool", "optional": true, "doc": ["Set when lookup, JSON parsing, or schema validation still failed after", "the optional repair attempt."] }, + { "name": "error", "type": { "ref": "AiMuxError" }, "optional": true, "doc": ["Typed failure associated with an invalid tool call."] } + ] +} diff --git a/tools/aimux-codegen/src/lib.rs b/tools/aimux-codegen/src/lib.rs new file mode 100644 index 000000000..f60fc346f --- /dev/null +++ b/tools/aimux-codegen/src/lib.rs @@ -0,0 +1,267 @@ +//! Generator PoC (phase 1 unit 1.8; #200:1509, RFC-0042 T5). +//! +//! One data-only type spec (`spec/.json`) produces a TypeScript type +//! and a Go struct with the same wire shape. Outputs are committed under +//! `generated/`; `--check` regenerates in memory and fails on any +//! difference, so a hand edit (tamper) is caught. No `build.rs`, no second +//! source of truth (#200 ยง6.5). + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +/// A type spec. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TypeSpec { + pub name: String, + pub doc: Vec, + pub fields: Vec, +} + +/// One field: wire name, type, optionality, doc lines. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FieldSpec { + pub name: String, + #[serde(rename = "type")] + pub ty: FieldType, + pub optional: bool, + pub doc: Vec, +} + +/// The field types this PoC supports. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FieldType { + String, + Bool, + /// Any JSON value. + Json, + /// `{ [provider]: { [key]: JsonValue } }`. + ProviderMetadata, + /// Another generated/declared type, by name. + #[serde(untagged)] + Ref { + r#ref: String, + }, +} + +/// One generated file: path relative to the output root, and its content. +#[derive(Debug, PartialEq, Eq)] +pub struct Output { + pub path: PathBuf, + pub content: String, +} + +/// Header line written at the top of every generated TypeScript file. +pub const TS_HEADER: &str = "// This file was generated by aimux-codegen (tools/aimux-codegen). Do not edit this file manually."; + +/// Header line written at the top of every generated Go file. +pub const GO_HEADER: &str = + "// Code generated by aimux-codegen (tools/aimux-codegen). DO NOT EDIT."; + +/// Parse a spec. +/// +/// # Errors +/// +/// Returns the JSON error for a malformed or unknown-field spec. +pub fn parse_spec(text: &str) -> Result { + serde_json::from_str(text) +} + +/// TypeScript in the layout of the current ts-rs output +/// (`bindings/node/src/types/.ts`), so the two can be compared line by +/// line below the header. +#[must_use] +pub fn render_ts(spec: &TypeSpec) -> String { + let mut out = String::new(); + let _ = writeln!(out, "{TS_HEADER}"); + let mut refs: Vec<&str> = spec + .fields + .iter() + .filter_map(|f| match &f.ty { + FieldType::Ref { r#ref } => Some(r#ref.as_str()), + _ => None, + }) + .collect(); + refs.sort_unstable(); + refs.dedup(); + for r in refs { + let _ = writeln!(out, "import type {{ {r} }} from \"./{r}\";"); + } + if spec + .fields + .iter() + .any(|f| matches!(f.ty, FieldType::Json | FieldType::ProviderMetadata)) + { + let _ = writeln!( + out, + "import type {{ JsonValue }} from \"./serde_json/JsonValue\";" + ); + } + out.push('\n'); + write_ts_doc(&mut out, &spec.doc); + let _ = write!(out, "export type {} = {{ ", spec.name); + for field in &spec.fields { + out.push('\n'); + write_ts_doc(&mut out, &field.doc); + let ty = match &field.ty { + FieldType::String => "string".to_string(), + FieldType::Bool => "boolean".to_string(), + FieldType::Json => "JsonValue".to_string(), + FieldType::ProviderMetadata => { + "{ [key in string]: { [key in string]: JsonValue } }".to_string() + } + FieldType::Ref { r#ref } => r#ref.clone(), + }; + let q = if field.optional { "?" } else { "" }; + let _ = write!(out, "{}{q}: {ty}, ", field.name); + } + out.push_str("};\n"); + out +} + +fn write_ts_doc(out: &mut String, doc: &[String]) { + out.push_str("/**\n"); + for line in doc { + let _ = writeln!(out, " * {line}"); + } + out.push_str(" */\n"); +} + +fn go_field_name(wire: &str) -> String { + let mut name = String::new(); + let mut chars = wire.chars(); + if let Some(first) = chars.next() { + name.extend(first.to_uppercase()); + } + name.extend(chars); + // Go initialism convention for a trailing `Id`. + if let Some(stem) = name.strip_suffix("Id") { + return format!("{stem}ID"); + } + name +} + +/// A Go struct with `encoding/json` tags carrying the same wire names and +/// optionality (`omitempty` + pointer/raw for optional fields). +#[must_use] +pub fn render_go(spec: &TypeSpec, package: &str) -> String { + let mut out = String::new(); + let _ = writeln!(out, "{GO_HEADER}\n"); + let _ = writeln!(out, "package {package}\n"); + let _ = writeln!(out, "import \"encoding/json\"\n"); + for line in &spec.doc { + let _ = writeln!(out, "// {line}"); + } + let _ = writeln!(out, "type {} struct {{", spec.name); + for field in &spec.fields { + for line in &field.doc { + let _ = writeln!(out, "\t// {line}"); + } + let ty = match (&field.ty, field.optional) { + (FieldType::String, false) => "string", + (FieldType::String, true) => "*string", + (FieldType::Bool, false) => "bool", + (FieldType::Bool, true) => "*bool", + // Raw JSON keeps the value byte-for-byte; absent stays absent. + (FieldType::Json | FieldType::ProviderMetadata | FieldType::Ref { .. }, _) => { + "json.RawMessage" + } + }; + let omit = if field.optional { ",omitempty" } else { "" }; + let _ = writeln!( + out, + "\t{} {ty} `json:\"{}{omit}\"`", + go_field_name(&field.name), + field.name + ); + } + out.push_str("}\n"); + out +} + +/// Every output for the specs in `spec_dir`, sorted by path. +/// +/// # Errors +/// +/// Returns a message for an unreadable or malformed spec. +pub fn generate(spec_dir: &Path) -> Result, String> { + let mut entries: Vec = std::fs::read_dir(spec_dir) + .map_err(|e| format!("{}: {e}", spec_dir.display()))? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == "json")) + .collect(); + entries.sort(); + let mut outputs = Vec::new(); + for path in entries { + let text = + std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let spec = parse_spec(&text).map_err(|e| format!("{}: {e}", path.display()))?; + outputs.push(Output { + path: PathBuf::from("ts").join(format!("{}.ts", spec.name)), + content: render_ts(&spec), + }); + outputs.push(Output { + path: PathBuf::from("go").join(format!("{}.go", snake(&spec.name))), + content: render_go(&spec, "aimuxgen"), + }); + } + Ok(outputs) +} + +fn snake(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_uppercase() { + if i > 0 { + out.push('_'); + } + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} + +/// Write every output under `out_dir`. +/// +/// # Errors +/// +/// Returns a message for a spec or I/O failure. +pub fn write(spec_dir: &Path, out_dir: &Path) -> Result<(), String> { + for output in generate(spec_dir)? { + let path = out_dir.join(&output.path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + std::fs::write(&path, output.content).map_err(|e| format!("{}: {e}", path.display()))?; + } + Ok(()) +} + +/// Compare the committed outputs under `out_dir` with a fresh generation. +/// +/// # Errors +/// +/// Returns one message per missing or differing file. +pub fn check(spec_dir: &Path, out_dir: &Path) -> Result<(), Vec> { + let outputs = generate(spec_dir).map_err(|e| vec![e])?; + let mut problems = Vec::new(); + for output in outputs { + let path = out_dir.join(&output.path); + match std::fs::read_to_string(&path) { + Ok(text) if text == output.content => {} + Ok(_) => problems.push(format!("{} is out of date", path.display())), + Err(e) => problems.push(format!("{}: {e}", path.display())), + } + } + if problems.is_empty() { + Ok(()) + } else { + Err(problems) + } +} diff --git a/tools/aimux-codegen/src/main.rs b/tools/aimux-codegen/src/main.rs new file mode 100644 index 000000000..ad13f1ca9 --- /dev/null +++ b/tools/aimux-codegen/src/main.rs @@ -0,0 +1,48 @@ +//! `aimux-codegen [--check]`: regenerate (or verify) `generated/` from `spec/`. + +use std::path::PathBuf; +use std::process::ExitCode; + +fn main() -> ExitCode { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mut spec = root.join("spec"); + let mut out = root.join("generated"); + let mut check = false; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--check" => check = true, + "--spec" => spec = args.next().map_or(spec, PathBuf::from), + "--out" => out = args.next().map_or(out, PathBuf::from), + other => { + eprintln!( + "unknown argument {other}; usage: aimux-codegen [--check] [--spec DIR] [--out DIR]" + ); + return ExitCode::from(2); + } + } + } + if check { + match aimux_codegen::check(&spec, &out) { + Ok(()) => { + println!("{}: up to date", out.display()); + ExitCode::SUCCESS + } + Err(problems) => { + for p in problems { + eprintln!("{p}"); + } + eprintln!("run `cargo run -p aimux-codegen` to regenerate"); + ExitCode::FAILURE + } + } + } else { + match aimux_codegen::write(&spec, &out) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } + } +} diff --git a/tools/aimux-codegen/tests/codegen_test.rs b/tools/aimux-codegen/tests/codegen_test.rs new file mode 100644 index 000000000..3e54196d0 --- /dev/null +++ b/tools/aimux-codegen/tests/codegen_test.rs @@ -0,0 +1,206 @@ +//! Phase 1 unit 1.8 acceptance: the generated ToolCall TypeScript has the +//! same wire as the current ts-rs file, the Go struct round-trips the same +//! JSON, and `--check` reports tampering. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn tmp(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "aimux-codegen-{label}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn copy_tree(from: &Path, to: &Path) { + for entry in std::fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + let target = to.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + std::fs::create_dir_all(&target).unwrap(); + copy_tree(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), target).unwrap(); + } + } +} + +#[test] +fn committed_outputs_are_up_to_date() { + aimux_codegen::check(&root().join("spec"), &root().join("generated")).unwrap(); +} + +/// Below the one-line generator header, the generated TypeScript is the +/// current `bindings/node/src/types/ToolCall.ts` line for line (same field +/// names, optionality, types, imports, docs). +#[test] +fn typescript_matches_the_current_binding_type() { + let generated = std::fs::read_to_string(root().join("generated/ts/ToolCall.ts")).unwrap(); + let current = + std::fs::read_to_string(root().join("../../bindings/node/src/types/ToolCall.ts")).unwrap(); + let body = |text: &str| text.split_once('\n').unwrap().1.to_string(); + assert!(current.starts_with("// This file was generated by [ts-rs]")); + assert!(generated.starts_with(aimux_codegen::TS_HEADER)); + assert_eq!(body(&generated), body(¤t)); +} + +fn assert_tamper_caught(relative: &str, edit: impl Fn(String) -> String) { + let out = tmp(&relative.replace(['/', '.'], "_")); + copy_tree(&root().join("generated"), &out); + let file = out.join(relative); + let text = std::fs::read_to_string(&file).unwrap(); + std::fs::write(&file, edit(text)).unwrap(); + + let problems = aimux_codegen::check(&root().join("spec"), &out).unwrap_err(); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains(relative), "{problems:?}"); + + let status = Command::new(env!("CARGO_BIN_EXE_aimux-codegen")) + .args(["--check", "--out"]) + .arg(&out) + .output() + .unwrap(); + assert!( + !status.status.success(), + "--check accepted a tampered {relative}" + ); + assert!(String::from_utf8_lossy(&status.stderr).contains("out of date")); + let _ = std::fs::remove_dir_all(&out); +} + +#[test] +fn check_rejects_tampered_typescript() { + assert_tamper_caught("ts/ToolCall.ts", |t| { + t.replace("dynamic?: boolean", "dynamic: boolean") + }); +} + +#[test] +fn check_rejects_tampered_go() { + assert_tamper_caught("go/tool_call.go", |t| { + t.replace("\"toolName\"", "\"tool_name\"") + }); +} + +#[test] +fn check_rejects_a_missing_file() { + let out = tmp("missing"); + copy_tree(&root().join("generated"), &out); + std::fs::remove_file(out.join("go/tool_call.go")).unwrap(); + assert!(aimux_codegen::check(&root().join("spec"), &out).is_err()); + let _ = std::fs::remove_dir_all(&out); +} + +#[test] +fn check_passes_on_an_untouched_copy() { + let out = tmp("clean"); + copy_tree(&root().join("generated"), &out); + let status = Command::new(env!("CARGO_BIN_EXE_aimux-codegen")) + .args(["--check", "--out"]) + .arg(&out) + .status() + .unwrap(); + assert!(status.success()); + let _ = std::fs::remove_dir_all(&out); +} + +#[test] +fn spec_rejects_unknown_fields() { + let err = aimux_codegen::parse_spec(r#"{"name":"X","doc":[],"fields":[],"extra":1}"#); + assert!(err.is_err()); +} + +/// Wire samples serialized by aimux-core's `ToolCall`, decoded and +/// re-encoded by the generated Go struct, give the same JSON value. Needs a +/// local `go`; without one the test says so and returns. +#[test] +fn go_struct_round_trips_core_tool_call_json() { + let samples = [ + r#"{"toolCallId":"c1","toolName":"weather","input":{"city":"Tokyo"}}"#, + r#"{"toolCallId":"c2","toolName":"t","input":"Tokyo","providerExecuted":true,"dynamic":true}"#, + r#"{"toolCallId":"c3","toolName":"t","input":{},"providerMetadata":{"openai":{"k":1}}}"#, + r#"{"toolCallId":"c4","toolName":"t","input":"{\"a\":","dynamic":true,"invalid":true,"error":{"name":"AI_InvalidToolInputError","toolName":"t","toolInput":"{\"a\":","cause":"eof"}}"#, + ]; + let core: Vec = samples + .iter() + .map(|s| { + let call: aimux_core::tool::ToolCall = serde_json::from_str(s).unwrap(); + serde_json::to_string(&call).unwrap() + }) + .collect(); + + if Command::new("go").arg("version").output().is_err() { + eprintln!("SKIPPED: no `go` on PATH"); + return; + } + let dir = tmp("go"); + std::fs::copy( + root().join("generated/go/tool_call.go"), + dir.join("tool_call.go"), + ) + .unwrap(); + std::fs::write(dir.join("go.mod"), "module aimuxgen\n\ngo 1.21\n").unwrap(); + let cases: String = core.iter().map(|s| format!("\t{s:?},\n")).collect(); + std::fs::write( + dir.join("roundtrip_test.go"), + format!( + r#"package aimuxgen + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestRoundTrip(t *testing.T) {{ + for _, s := range []string{{ +{cases} }} {{ + var call ToolCall + if err := json.Unmarshal([]byte(s), &call); err != nil {{ + t.Fatalf("%s: %v", s, err) + }} + out, err := json.Marshal(call) + if err != nil {{ + t.Fatal(err) + }} + var a, b any + _ = json.Unmarshal([]byte(s), &a) + _ = json.Unmarshal(out, &b) + if !reflect.DeepEqual(a, b) {{ + t.Fatalf("in %s\nout %s", s, out) + }} + }} +}} +"# + ), + ) + .unwrap(); + let gofmt = Command::new("gofmt") + .arg("-l") + .arg(dir.join("tool_call.go")) + .output() + .unwrap(); + assert!(gofmt.stdout.is_empty(), "generated Go is not gofmt-clean"); + let test = Command::new("go") + .args(["test", "./..."]) + .current_dir(&dir) + .env("GOFLAGS", "-mod=mod") + .env("GOTOOLCHAIN", "local") + .output() + .unwrap(); + assert!( + test.status.success(), + "{}\n{}", + String::from_utf8_lossy(&test.stdout), + String::from_utf8_lossy(&test.stderr) + ); + let _ = std::fs::remove_dir_all(&dir); +}