From b4a3150c3e98bf67e86861e0d6c49c9bef9b3c0a Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 00:13:03 +0200 Subject: [PATCH 01/28] =?UTF-8?q?feat(fh-agent):=20site.spec.yaml=20?= =?UTF-8?q?=E2=86=92=20deployable=20edge-agent=20bundle=20compiler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go CLI under go/cmd/fh-agent that turns a high-level site.spec.yaml into a Docker Compose stack ready to scp + 'docker compose up' on a Raspberry Pi 5, NVIDIA Jetson Orin Nano, x86 NUC, STM32MP25, or Bosch Rexroth ctrlX CORE. Pipeline: spec → plan → validate → build. spec authoring & schema-export (read/write spec only) targets introspection over 5 embedded hardware profiles models suggest --target X --capability Y, RAM-filtered plan deterministic compile of spec × target into 6 artifacts (workflow, mapping, resources, manifest, local-models, meta) validate cross-check the build dir (channels↔mapping↔resources↔manifest, model RAM vs target) build render Compose stack (engine + llama-server + optional mosquitto) + README + .env.example, optional --tar Designed for agents: - JSON-first I/O, identical diagnostic shape across commands - Documented exit codes (0/1/2/64) - Deterministic plan (sorted keys, hashed node ids) so an iterating agent sees only meaningful diffs - 'targets describe' and 'spec schema' double as documentation so Claude doesn't need to load 900 lines of contract YAML Bundle uses the official ghcr.io engine image with configs mounted as volumes — no per-customer Docker build, no AGPL-derivative-work question. v1 limits documented in the README: no contract-schema validation (separately run fh-workflow validate), no provisioning/OTA/HIL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + go/cmd/fh-agent/README.md | 145 ++++ go/cmd/fh-agent/bundle.go | 281 +++++++ go/cmd/fh-agent/diag.go | 53 ++ .../examples/building-automation.site.yaml | 90 +++ go/cmd/fh-agent/main.go | 272 +++++++ go/cmd/fh-agent/plan.go | 707 ++++++++++++++++++ go/cmd/fh-agent/plan_test.go | 165 ++++ go/cmd/fh-agent/spec.go | 405 ++++++++++ go/cmd/fh-agent/targets.go | 211 ++++++ go/cmd/fh-agent/targets/ctrlx-core-arm64.yaml | 68 ++ .../targets/jetson-orin-nano-8gb.yaml | 67 ++ go/cmd/fh-agent/targets/rpi5-8gb.yaml | 64 ++ go/cmd/fh-agent/targets/stm32mp25-1gb.yaml | 63 ++ go/cmd/fh-agent/targets/x86-nuc-16gb.yaml | 59 ++ go/cmd/fh-agent/templates/compose.yml.tmpl | 83 ++ go/cmd/fh-agent/templates/env.example.tmpl | 12 + go/cmd/fh-agent/templates/mosquitto.conf.tmpl | 4 + go/cmd/fh-agent/templates/readme.md.tmpl | 60 ++ go/cmd/fh-agent/validate.go | 202 +++++ 20 files changed, 3014 insertions(+) create mode 100644 go/cmd/fh-agent/README.md create mode 100644 go/cmd/fh-agent/bundle.go create mode 100644 go/cmd/fh-agent/diag.go create mode 100644 go/cmd/fh-agent/examples/building-automation.site.yaml create mode 100644 go/cmd/fh-agent/main.go create mode 100644 go/cmd/fh-agent/plan.go create mode 100644 go/cmd/fh-agent/plan_test.go create mode 100644 go/cmd/fh-agent/spec.go create mode 100644 go/cmd/fh-agent/targets.go create mode 100644 go/cmd/fh-agent/targets/ctrlx-core-arm64.yaml create mode 100644 go/cmd/fh-agent/targets/jetson-orin-nano-8gb.yaml create mode 100644 go/cmd/fh-agent/targets/rpi5-8gb.yaml create mode 100644 go/cmd/fh-agent/targets/stm32mp25-1gb.yaml create mode 100644 go/cmd/fh-agent/targets/x86-nuc-16gb.yaml create mode 100644 go/cmd/fh-agent/templates/compose.yml.tmpl create mode 100644 go/cmd/fh-agent/templates/env.example.tmpl create mode 100644 go/cmd/fh-agent/templates/mosquitto.conf.tmpl create mode 100644 go/cmd/fh-agent/templates/readme.md.tmpl create mode 100644 go/cmd/fh-agent/validate.go diff --git a/.gitignore b/.gitignore index 59cbdfd..ccf5965 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ ts/**/dist/ # Go *.test *.out +go/fh-agent +go/build/ +go/dist/ # Engine deploy build artifacts (image tarball produced by `docker save`) *.tar diff --git a/go/cmd/fh-agent/README.md b/go/cmd/fh-agent/README.md new file mode 100644 index 0000000..a73eb24 --- /dev/null +++ b/go/cmd/fh-agent/README.md @@ -0,0 +1,145 @@ +# fh-agent + +Compile a `site.spec.yaml` into a deploy-ready edge-agent bundle for +Raspberry Pi, NVIDIA Jetson, x86 NUC, ST STM32MP25, or Bosch Rexroth +ctrlX CORE. + +Mental model: this is a **compiler** for edge-AI sites. Source is +`site.spec.yaml`; target is a Docker Compose stack you `scp` to a device +and `docker compose up`. + +``` +site.spec.yaml ─ plan ─▶ build/ ─ validate ─▶ build/ ─ build ─▶ dist// + (durable) (compiled graph, (cross-checks) (compose + mapping, manifest, stack) + local-models) +``` + +## Install + +```sh +cd go +go install ./cmd/fh-agent +``` + +## The four passes + +| Pass | Reads | Writes | Network? | Determ? | +| --- | --- | --- | --- | --- | +| `spec` | site.spec.yaml | (echo + lint) | no | — | +| `plan` | site.spec.yaml + target | build/ | no | **yes** | +| `validate` | build/ | diagnostics | no | yes | +| `build` | build/ | dist/\/ | no | yes | + +`plan` is the heart. It MUST be byte-deterministic for the same spec + +target — otherwise an iterating agent sees diff noise. Stable node ids +(SHA256 of `name|type|disc`), sorted keys, no timestamps. + +## Subcommands + +### Authoring + +- `fh-agent spec init [--out site.spec.yaml]` — emit a commented template +- `fh-agent spec schema [--json]` — emit JSON Schema (draft 2020-12) for + programmatic priming of an LLM agent +- `fh-agent spec validate ` — required fields, dangling refs, + bus/device consistency + +### Introspection (the CLI doubles as docs for agents) + +- `fh-agent targets list` — embedded hardware profiles +- `fh-agent targets describe ` — RAM, GPIO/serial map, supported SLMs + with estimated tok/s +- `fh-agent capabilities` — bus types, device kinds, LLM capabilities +- `fh-agent models suggest --target --capability ` — best-fit + models for the target, RAM-filtered, sorted by smallest fit + +### Compilation + +- `fh-agent plan --target [--out build/]` +- `fh-agent validate ` +- `fh-agent build --name [--out dist/] [--tar]` + +## Hardware targets (v1) + +| ID | Arch | RAM | Accel | Notes | +| --- | --- | --- | --- | --- | +| `rpi5-8gb` | arm64 | 8 GB | none | General-purpose SBC | +| `jetson-orin-nano-8gb` | arm64 | 8 GB | CUDA (40 TOPS) | Vision-language / 7B models | +| `x86-nuc-16gb` | amd64 | 16 GB | Iris Xe iGPU | RAG / heavy reasoning | +| `stm32mp25-1gb` | arm64 | 1 GB | Neural-ART NPU | Sensor fusion + classification | +| `ctrlx-core-arm64` | arm64 | 2 GB | i.MX 8M Plus NPU | Industrial controller, ctrlX OS | + +Profiles live in `targets/*.yaml` and are embedded into the binary. Edit +those files to add models, change estimates, or add targets. + +## Bundle layout + +``` +dist// + compose.yml # engine + llama-server + (optional) mosquitto + agent.workflow.json # binding-free graph + site.mapping.json # channels → platform resources + site.resources.yaml # mqtt brokers etc. + device.manifest.json # gpios/serials of this device + local-models.yaml # local SLM registry + bundle.meta.json # fh-agent metadata + .env.example # API-key template + models/ # weights live here (download separately) + README.md # auto-generated, run instructions +``` + +## How an agent uses this + +``` +$ fh-agent spec init --out site.spec.yaml +$ fh-agent spec schema --json # prime on the format +# (agent interviews user, edits site.spec.yaml) +$ fh-agent spec validate site.spec.yaml # exit 1 + JSON diags +# (agent fixes) +$ fh-agent targets list --json +$ fh-agent targets describe rpi5-8gb --json # RAM, SLMs available +$ fh-agent plan site.spec.yaml --target rpi5-8gb --out build/ +$ fh-agent validate build/ +$ fh-agent build build/ --name muellers-haus --tar +# → dist/muellers-haus/ + dist/muellers-haus.tar.gz +``` + +## Output contract for agents + +- **All commands emit JSON** on stdout when `--json` (default for + introspection/diagnostic commands). Status messages go to stderr. +- **Diagnostics shape** is identical across commands: + ```json + {"severity": "error|warn|info", "category": "spec|plan|validate|build", + "message": "...", "location": "$.devices[2].bus.topic", "nodeId": "..."} + ``` +- **Exit codes**: + - `0` ok + - `1` diagnostics (user-correctable) + - `2` infrastructure (IO error, embedded data corrupted) + - `64` usage error +- **Stable output**: same spec + target ⇒ byte-identical artifacts. Map + keys sorted, node ids hashed from inputs, no timestamps. + +## v1 known limits (documented; not bugs) + +- **Workflow schema** is structurally compiled but **not** validated + against `contract/workflow.yaml`. Run `fh-workflow validate` (from the + TypeScript CLI) for that. Plan is to call it as a subprocess in v1.1. +- **Bus types** in spec: `mqtt`, `gpio`, `serial`. I2C, SPI, HTTP, and + ctrlX Data-Layer adapters are deferred. +- **No provisioning, no OTA, no HIL**. v1 stops at the bundle. v2 adds + `fh-agent deploy --to pi@host`. +- **Model weights** are not downloaded — `models/` is empty; the README + in the bundle has a hint pointing at Hugging Face for common models. + +## Testing + +```sh +go test ./cmd/fh-agent/... +``` + +Three suites: determinism (same input → same bytes across all 5 +targets), target-fit (chosen model fits target RAM), and validate +catches a mangled bundle. diff --git a/go/cmd/fh-agent/bundle.go b/go/cmd/fh-agent/bundle.go new file mode 100644 index 0000000..38f96dc --- /dev/null +++ b/go/cmd/fh-agent/bundle.go @@ -0,0 +1,281 @@ +package main + +import ( + "archive/tar" + "compress/gzip" + "embed" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/template" +) + +//go:embed templates/*.tmpl +var tmplFS embed.FS + +// runBuild assembles a deployable bundle directory from a planned build/. +// Renders compose.yml + README.md + .env.example from templates, copies the +// build artifacts in place, optionally tars the result. +func runBuild(args []string) { + fs := flag.NewFlagSet("build", flag.ExitOnError) + name := fs.String("name", "", "site/bundle name (used as dist subdirectory)") + out := fs.String("out", "dist", "dist directory") + tarOut := fs.Bool("tar", false, "additionally write a .tar.gz of the bundle next to it") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent build --name [--out dist/] [--tar]") + } + if *name == "" { + die(exitUsage, "--name is required") + } + buildDir := pos[0] + + meta, mdiags := loadMetadata(filepath.Join(buildDir, "bundle.meta.json")) + if hasError(mdiags) { + emitDiags(os.Stderr, mdiags) + os.Exit(exitDiagnostics) + } + + target, err := getTarget(meta.TargetID) + if err != nil { + die(exitDiagnostics, "%v", err) + } + + // Inspect workflow to decide which sidecars compose needs. + wf, wfdiags := loadWorkflow(filepath.Join(buildDir, "agent.workflow.json")) + if hasError(wfdiags) { + emitDiags(os.Stderr, wfdiags) + os.Exit(exitDiagnostics) + } + needs := inspectWorkflowNeeds(wf) + + bundleDir := filepath.Join(*out, *name) + if err := os.MkdirAll(bundleDir, 0o755); err != nil { + die(exitInfra, "mkdir %s: %v", bundleDir, err) + } + + // 1. Copy generated artifacts as-is. + artifacts := []string{ + "agent.workflow.json", + "site.mapping.json", + "site.resources.yaml", + "device.manifest.json", + "local-models.yaml", + "bundle.meta.json", + } + for _, f := range artifacts { + src := filepath.Join(buildDir, f) + dst := filepath.Join(bundleDir, f) + if err := copyFile(src, dst); err != nil { + die(exitInfra, "copy %s: %v", f, err) + } + } + + // 2. Render compose.yml, README.md, .env.example from templates. + ctx := composeContext{ + Name: *name, + Arch: target.Arch, + TargetID: target.ID, + TargetArch: target.Arch, + AccelType: target.Accel.Type, + AccelDevice: target.Accel.Device, + SLMImage: target.SLMRuntime.Image, + SLMPort: target.SLMRuntime.ServePort, + GPURequest: target.SLMRuntime.GPURequest, + ModelID: meta.ChosenModel.ID, + ModelRAMMB: meta.ChosenModel.RAMMB, + ModelDownloadHint: modelDownloadHint(meta.ChosenModel.ID), + NeedsGPIO: needs.gpio, + NeedsSerial: needs.serial, + NeedsMQTT: needs.mqtt, + } + if err := renderTemplate("templates/compose.yml.tmpl", filepath.Join(bundleDir, "compose.yml"), ctx); err != nil { + die(exitInfra, "render compose.yml: %v", err) + } + if err := renderTemplate("templates/readme.md.tmpl", filepath.Join(bundleDir, "README.md"), ctx); err != nil { + die(exitInfra, "render README.md: %v", err) + } + if err := renderTemplate("templates/env.example.tmpl", filepath.Join(bundleDir, ".env.example"), ctx); err != nil { + die(exitInfra, "render .env.example: %v", err) + } + if needs.mqtt { + if err := renderTemplate("templates/mosquitto.conf.tmpl", filepath.Join(bundleDir, "mosquitto.conf"), ctx); err != nil { + die(exitInfra, "render mosquitto.conf: %v", err) + } + } + if err := os.MkdirAll(filepath.Join(bundleDir, "models"), 0o755); err != nil { + die(exitInfra, "mkdir models/: %v", err) + } + keepPath := filepath.Join(bundleDir, "models", ".gitkeep") + _ = os.WriteFile(keepPath, []byte{}, 0o644) + + fmt.Fprintf(os.Stderr, "built bundle at %s\n", bundleDir) + + if *tarOut { + tarPath := bundleDir + ".tar.gz" + if err := tarGz(bundleDir, tarPath); err != nil { + die(exitInfra, "tar: %v", err) + } + fmt.Fprintf(os.Stderr, "wrote %s\n", tarPath) + } + + // Echo summary on stdout as JSON so an agent can parse it. + summary := map[string]any{ + "bundleDir": bundleDir, + "name": *name, + "target": target.ID, + "arch": target.Arch, + "model": meta.ChosenModel.ID, + "sidecars": sidecarList(needs), + "tar": *tarOut, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + _ = enc.Encode(summary) +} + +type composeContext struct { + Name string + Arch string + TargetID string + TargetArch string + AccelType string + AccelDevice string + SLMImage string + SLMPort int + GPURequest string + ModelID string + ModelRAMMB int + ModelDownloadHint string + NeedsGPIO bool + NeedsSerial bool + NeedsMQTT bool +} + +type workflowNeeds struct { + gpio, serial, mqtt bool +} + +func inspectWorkflowNeeds(wf Workflow) workflowNeeds { + var n workflowNeeds + for _, ch := range wf.Channels { + switch ch["type"] { + case "GPIOIN", "GPIOOUT", "PWM", "ADC", "DAC": + n.gpio = true + case "UART": + n.serial = true + case "MQTT": + n.mqtt = true + } + } + return n +} + +func sidecarList(n workflowNeeds) []string { + out := []string{"engine", "llm"} + if n.mqtt { + out = append(out, "mosquitto") + } + return out +} + +// modelDownloadHint returns a best-guess pointer for where to fetch the +// model weights. Best-effort only — a curated registry is a v2 improvement. +func modelDownloadHint(id string) string { + id = strings.ToLower(id) + switch { + case strings.Contains(id, "llama-3.2-1b"): + return "huggingface.co/lmstudio-community/Llama-3.2-1B-Instruct-GGUF" + case strings.Contains(id, "llama-3.2-3b"): + return "huggingface.co/lmstudio-community/Llama-3.2-3B-Instruct-GGUF" + case strings.Contains(id, "llama-3.1-8b"): + return "huggingface.co/lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF" + case strings.Contains(id, "qwen2.5"): + return "huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF" + case strings.Contains(id, "tinyllama"): + return "huggingface.co/TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF" + case strings.Contains(id, "nomic-embed"): + return "huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF" + } + return "" +} + +func renderTemplate(srcRel, dst string, ctx any) error { + raw, err := tmplFS.ReadFile(srcRel) + if err != nil { + return fmt.Errorf("read template %s: %w", srcRel, err) + } + t, err := template.New(filepath.Base(srcRel)).Parse(string(raw)) + if err != nil { + return fmt.Errorf("parse template %s: %w", srcRel, err) + } + f, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create %s: %w", dst, err) + } + defer f.Close() + if err := t.Execute(f, ctx); err != nil { + return fmt.Errorf("execute template %s: %w", srcRel, err) + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} + +func tarGz(srcDir, dstPath string) error { + out, err := os.Create(dstPath) + if err != nil { + return err + } + defer out.Close() + gz := gzip.NewWriter(out) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + + root := filepath.Clean(srcDir) + base := filepath.Base(root) + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(root, path) + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + hdr.Name = filepath.Join(base, rel) + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if info.Mode().IsRegular() { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(tw, f); err != nil { + return err + } + } + return nil + }) +} diff --git a/go/cmd/fh-agent/diag.go b/go/cmd/fh-agent/diag.go new file mode 100644 index 0000000..f339f7d --- /dev/null +++ b/go/cmd/fh-agent/diag.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +// Diagnostic is the structured-error shape every fh-agent subcommand emits. +// Same fields, same JSON tags as fh-workflow validate, so agents only need +// one parser across the whole CLI surface. +type Diagnostic struct { + Severity string `json:"severity"` // error | warn | info + Category string `json:"category"` // spec | plan | validate | build + Message string `json:"message"` + Location string `json:"location,omitempty"` // JSONPath into the offending file + NodeID string `json:"nodeId,omitempty"` // workflow node id when relevant +} + +// hasError returns true if any diagnostic is severity=error. +func hasError(ds []Diagnostic) bool { + for _, d := range ds { + if d.Severity == "error" { + return true + } + } + return false +} + +// emitDiags writes diagnostics as a JSON array on stderr. Caller picks the +// exit code — usually 1 if hasError, 0 otherwise. +func emitDiags(w io.Writer, ds []Diagnostic) { + if ds == nil { + ds = []Diagnostic{} + } + buf, _ := json.MarshalIndent(ds, "", " ") + fmt.Fprintln(w, string(buf)) +} + +// Exit codes are part of the CLI contract — agents key off them. Document +// any change in the README. +const ( + exitOK = 0 + exitDiagnostics = 1 // user-correctable: spec error, validation finding + exitInfra = 2 // engine unreachable, missing toolchain, IO failure + exitUsage = 64 +) + +func die(code int, format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(code) +} diff --git a/go/cmd/fh-agent/examples/building-automation.site.yaml b/go/cmd/fh-agent/examples/building-automation.site.yaml new file mode 100644 index 0000000..e917263 --- /dev/null +++ b/go/cmd/fh-agent/examples/building-automation.site.yaml @@ -0,0 +1,90 @@ +schemaVersion: 1 +name: muellers-haus +target: rpi5-8gb +description: | + Single-family home, 2 zones. MQTT broker on local network exposes + temperature sensors and a heating-setpoint topic. One GPIO line drives a + hallway light. + +goals: + - id: comfort + description: Keep occupied rooms comfortable. + - id: energy + description: Reduce energy use when nobody is around. + +constraints: + - id: heating-min + description: Heating setpoint must never drop below 5 °C (frost protection). + - id: heating-max + description: Heating setpoint must never exceed 24 °C. + +zones: + - id: livingroom + name: Wohnzimmer + - id: bedroom + name: Schlafzimmer + +buses: + - id: home-mqtt + type: mqtt + brokerUrl: tcp://localhost:1883 + - id: gpio + type: gpio + +devices: + - id: temp-livingroom + zone: livingroom + kind: sensor + measures: temperature + unit: C + bus: + ref: home-mqtt + topic: home/livingroom/temperature + - id: temp-bedroom + zone: bedroom + kind: sensor + measures: temperature + unit: C + bus: + ref: home-mqtt + topic: home/bedroom/temperature + - id: presence-livingroom + zone: livingroom + kind: sensor + measures: presence + bus: + ref: home-mqtt + topic: home/livingroom/presence + - id: heating-livingroom + zone: livingroom + kind: actuator + controls: heating + unit: C + bus: + ref: home-mqtt + topic: home/livingroom/heating/setpoint + writable: true + - id: heating-bedroom + zone: bedroom + kind: actuator + controls: heating + unit: C + bus: + ref: home-mqtt + topic: home/bedroom/heating/setpoint + writable: true + - id: hallway-light + kind: actuator + controls: light + bus: + ref: gpio + line: 17 + writable: true + +agent: + reasoning: + capability: chat + promptHint: | + Be conservative — when in doubt, leave the setpoint where it is. + Only change actuators when sensor readings clearly call for it. + evaluationEvery: 60s diff --git a/go/cmd/fh-agent/main.go b/go/cmd/fh-agent/main.go new file mode 100644 index 0000000..af7d482 --- /dev/null +++ b/go/cmd/fh-agent/main.go @@ -0,0 +1,272 @@ +// Command fh-agent compiles a site.spec.yaml into a deploy-ready +// edge-agents bundle (workflow + mapping + resources + device manifest + +// local-models config + compose stack). +// +// Pipeline: spec → plan → validate → build. +// +// All subcommands emit structured JSON on --json and exit non-zero on +// diagnostics. The contract is documented in this command's README. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" +) + +const usage = `fh-agent — compile a site.spec.yaml into a deployable edge-agent bundle. + +Authoring (read/write files only): + fh-agent spec init [--out site.spec.yaml] + fh-agent spec schema [--json] + fh-agent spec validate [--json] + +Introspection (read-only, embedded data): + fh-agent targets list [--json] + fh-agent targets describe [--json] + fh-agent capabilities [--json] + fh-agent models suggest --target --capability [--max-ram-mb N] [--json] + +Compilation: + fh-agent plan --target --out + fh-agent validate [--json] + fh-agent build --name --out [--tar] + +Exit codes: + 0 ok + 1 diagnostics (spec/plan/validate finding the user must fix) + 2 infrastructure (IO error, embedded data corrupted) + 64 usage error +` + +func main() { + if len(os.Args) < 2 { + fmt.Fprint(os.Stderr, usage) + os.Exit(exitUsage) + } + cmd := os.Args[1] + args := os.Args[2:] + switch cmd { + case "spec": + runSpec(args) + case "targets": + runTargets(args) + case "capabilities": + runCapabilities(args) + case "models": + runModels(args) + case "plan": + runPlan(args) + case "validate": + runValidate(args) + case "build": + runBuild(args) + case "-h", "--help", "help": + fmt.Print(usage) + case "version", "--version", "-v": + fmt.Println("fh-agent v0.1.0") + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n\n%s", cmd, usage) + os.Exit(exitUsage) + } +} + +// ----- spec subcommand ----- + +func runSpec(args []string) { + if len(args) == 0 { + die(exitUsage, "spec subcommand required (init|schema|validate)") + } + switch args[0] { + case "init": + runSpecInit(args[1:]) + case "schema": + runSpecSchema(args[1:]) + case "validate": + runSpecValidate(args[1:]) + default: + die(exitUsage, "unknown spec subcommand: %s", args[0]) + } +} + +func runSpecInit(args []string) { + fs := flag.NewFlagSet("spec init", flag.ExitOnError) + out := fs.String("out", "site.spec.yaml", "destination path") + _ = fs.Parse(args) + if _, err := os.Stat(*out); err == nil { + die(exitInfra, "%s already exists — refusing to overwrite", *out) + } + if err := os.WriteFile(*out, []byte(specInitTemplate), 0o644); err != nil { + die(exitInfra, "write %s: %v", *out, err) + } + fmt.Fprintf(os.Stderr, "wrote %s\n", *out) +} + +func runSpecSchema(args []string) { + fs := flag.NewFlagSet("spec schema", flag.ExitOnError) + asJSON := fs.Bool("json", true, "emit JSON (currently the only format)") + _ = fs.Parse(args) + _ = asJSON // schema is JSON by definition; flag kept for symmetry + os.Stdout.Write(SpecSchema()) +} + +func runSpecValidate(args []string) { + fs := flag.NewFlagSet("spec validate", flag.ExitOnError) + _ = fs.Bool("json", true, "emit JSON diagnostics") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent spec validate ") + } + s, err := LoadSpec(pos[0]) + if err != nil { + emitDiags(os.Stderr, []Diagnostic{{Severity: "error", Category: "spec", Message: err.Error()}}) + os.Exit(exitDiagnostics) + } + diags := ValidateSpec(s) + emitDiags(os.Stderr, diags) + if hasError(diags) { + os.Exit(exitDiagnostics) + } +} + +// ----- targets subcommand ----- + +func runTargets(args []string) { + if len(args) == 0 { + die(exitUsage, "targets subcommand required (list|describe)") + } + switch args[0] { + case "list": + runTargetsList(args[1:]) + case "describe": + runTargetsDescribe(args[1:]) + default: + die(exitUsage, "unknown targets subcommand: %s", args[0]) + } +} + +func runTargetsList(args []string) { + fs := flag.NewFlagSet("targets list", flag.ExitOnError) + asJSON := fs.Bool("json", false, "emit JSON") + _ = fs.Parse(args) + ts, err := listTargets() + if err != nil { + die(exitInfra, "load targets: %v", err) + } + if *asJSON { + type row struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Arch string `json:"arch"` + RAMMB int `json:"ramMB"` + Accel string `json:"accel"` + } + out := make([]row, 0, len(ts)) + for _, t := range ts { + out = append(out, row{t.ID, t.DisplayName, t.Arch, t.RAM.TotalMB, t.Accel.Type}) + } + writeJSON(os.Stdout, out) + return + } + for _, t := range ts { + fmt.Printf("%-26s %-8s %5d MB accel=%-6s %s\n", t.ID, t.Arch, t.RAM.TotalMB, t.Accel.Type, t.DisplayName) + } +} + +func runTargetsDescribe(args []string) { + fs := flag.NewFlagSet("targets describe", flag.ExitOnError) + _ = fs.Bool("json", true, "emit JSON (only format in v1)") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent targets describe ") + } + t, err := getTarget(pos[0]) + if err != nil { + die(exitDiagnostics, "%v", err) + } + writeJSON(os.Stdout, t) +} + +// ----- capabilities subcommand ----- + +func runCapabilities(args []string) { + fs := flag.NewFlagSet("capabilities", flag.ExitOnError) + _ = fs.Bool("json", true, "emit JSON") + _ = fs.Parse(args) + caps := struct { + BusTypes []string `json:"busTypes"` + DeviceKinds []string `json:"deviceKinds"` + LLMCapabilities []string `json:"llmCapabilities"` + ContractVersion int `json:"contractWorkflowSchemaVersion"` + Notes string `json:"notes"` + }{ + BusTypes: []string{"mqtt", "gpio", "serial"}, + DeviceKinds: []string{"sensor", "actuator", "controller"}, + LLMCapabilities: []string{"chat", "reasoning", "classification", "embedding", "function_call", "vision", "code"}, + ContractVersion: 1, + Notes: "v1 bus types map to engine channel types MQTT, GPIO (digital pin), UART (serial). I2C/SPI/HTTP/CtrlX-Data-Layer planned for later versions.", + } + writeJSON(os.Stdout, caps) +} + +// ----- models subcommand ----- + +func runModels(args []string) { + if len(args) == 0 { + die(exitUsage, "models subcommand required (suggest)") + } + switch args[0] { + case "suggest": + runModelsSuggest(args[1:]) + default: + die(exitUsage, "unknown models subcommand: %s", args[0]) + } +} + +func runModelsSuggest(args []string) { + fs := flag.NewFlagSet("models suggest", flag.ExitOnError) + targetID := fs.String("target", "", "hardware target id") + cap := fs.String("capability", "", "required capability (chat|reasoning|embedding|...)") + maxRAM := fs.Int("max-ram-mb", 0, "optional hard RAM cap; 0 = use target available") + _ = fs.Bool("json", true, "emit JSON") + _ = fs.Parse(args) + if *targetID == "" || *cap == "" { + die(exitUsage, "usage: fh-agent models suggest --target --capability ") + } + t, err := getTarget(*targetID) + if err != nil { + die(exitDiagnostics, "%v", err) + } + out := suggestModels(t, *cap, *maxRAM) + writeJSON(os.Stdout, out) +} + +// parseMixed parses args allowing flags and positional args in any order. +// stdlib flag stops at the first non-flag arg; we loop, collect positionals, +// and re-parse the remainder until exhausted. Returns positional args. +func parseMixed(fs *flag.FlagSet, args []string) []string { + var positional []string + for len(args) > 0 { + _ = fs.Parse(args) + rem := fs.Args() + if len(rem) == 0 { + break + } + positional = append(positional, rem[0]) + args = rem[1:] + } + return positional +} + +// writeJSON emits indented, stable JSON to w. All Maps in inputs must have +// string keys (Go's json encoder sorts those alphabetically — gives us +// deterministic output for free). +func writeJSON(w *os.File, v any) { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + die(exitInfra, "encode JSON: %v", err) + } +} diff --git a/go/cmd/fh-agent/plan.go b/go/cmd/fh-agent/plan.go new file mode 100644 index 0000000..da2221a --- /dev/null +++ b/go/cmd/fh-agent/plan.go @@ -0,0 +1,707 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// runPlan is the heart of fh-agent: site.spec.yaml × target → build/ with +// five artifacts: agent.workflow.json, site.mapping.json, site.resources.yaml, +// device.manifest.json, local-models.yaml. +// +// MUST be deterministic — same spec + target → byte-identical output. +// Otherwise an iterating agent sees diff noise across re-plans. +func runPlan(args []string) { + fs := flag.NewFlagSet("plan", flag.ExitOnError) + targetID := fs.String("target", "", "hardware target id (overrides spec.target)") + out := fs.String("out", "build", "output directory") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent plan [--target id] [--out build/]") + } + specPath := pos[0] + + spec, err := LoadSpec(specPath) + if err != nil { + emitDiags(os.Stderr, []Diagnostic{{Severity: "error", Category: "spec", Message: err.Error()}}) + os.Exit(exitDiagnostics) + } + if diags := ValidateSpec(spec); hasError(diags) { + emitDiags(os.Stderr, diags) + os.Exit(exitDiagnostics) + } + if *targetID != "" { + spec.Target = *targetID + } + target, err := getTarget(spec.Target) + if err != nil { + emitDiags(os.Stderr, []Diagnostic{{Severity: "error", Category: "plan", Message: err.Error(), Location: "$.target"}}) + os.Exit(exitDiagnostics) + } + + plan, diags := compile(spec, target) + if hasError(diags) { + emitDiags(os.Stderr, diags) + os.Exit(exitDiagnostics) + } + + if err := os.MkdirAll(*out, 0o755); err != nil { + die(exitInfra, "mkdir %s: %v", *out, err) + } + if err := writePlanArtifacts(*out, plan); err != nil { + die(exitInfra, "write artifacts: %v", err) + } + if len(diags) > 0 { + emitDiags(os.Stderr, diags) + } + fmt.Fprintf(os.Stderr, "planned %s → %s (target=%s, %d devices, %d channels)\n", + specPath, *out, target.ID, len(spec.Devices), len(plan.Workflow.Channels)) +} + +// planResult is the in-memory bundle the compile pass produces. +type planResult struct { + Workflow Workflow + Mapping DeploymentMapping + Resources ExternalResources + DeviceManifest DeviceManifest + LocalModels LocalModelsConfig + BundleMetadata BundleMetadata +} + +// BundleMetadata is fh-agent–specific (not consumed by the engine). Kept in +// the build dir so 'fh-agent build' can render the compose stack without +// re-reading the spec. +type BundleMetadata struct { + SchemaVersion int `json:"schemaVersion" yaml:"schemaVersion"` + SpecName string `json:"specName" yaml:"specName"` + TargetID string `json:"targetId" yaml:"targetId"` + TargetArch string `json:"targetArch" yaml:"targetArch"` + SLMRuntime TargetSLMRT `json:"slmRuntime" yaml:"slmRuntime"` + ChosenModel ChosenModel `json:"chosenModel" yaml:"chosenModel"` +} + +type ChosenModel struct { + ID string `json:"id" yaml:"id"` + RAMMB int `json:"ramMB" yaml:"ramMB"` + GPURequest string `json:"gpuRequest,omitempty" yaml:"gpuRequest,omitempty"` +} + +// ---------- engine-side artifact shapes (subset of contract) ---------- + +// Workflow is the subset of the engine workflow contract we emit. We treat +// node arguments as raw JSON objects so we can hand-craft per node type +// without modelling every field statically. The downstream +// `fh-workflow validate` is the authoritative schema check. +type Workflow struct { + SchemaVersion int `json:"schemaVersion"` + Nodes []map[string]any `json:"nodes"` + Edges []map[string]any `json:"edges"` + Functions []any `json:"functions"` + DeclaredVariables []any `json:"declaredVariables"` + Channels []map[string]any `json:"channels"` + Memory []any `json:"memory"` + Models []map[string]any `json:"models"` +} + +// DeploymentMapping per contract/engine.yaml — keyed by workflow resource id. +type DeploymentMapping map[string]ResourceBinding +type ResourceBinding struct { + Ref string `json:"ref"` + Index *int `json:"index,omitempty"` +} + +// ExternalResources per contract/engine.yaml — keyed by platform resource id. +type ExternalResources map[string]map[string]any + +// DeviceManifest per contract/engine.yaml — keyed by driver instance id. +type DeviceManifest struct { + GPIOs map[string]map[string]any `json:"gpios,omitempty"` + Serials map[string]map[string]any `json:"serials,omitempty"` + PWMs map[string]map[string]any `json:"pwms,omitempty"` + ADCs map[string]map[string]any `json:"adcs,omitempty"` + DACs map[string]map[string]any `json:"dacs,omitempty"` +} + +// LocalModelsConfig is the format the engine's Local LLM provider expects +// (see go/llmproxy README). One endpoint per process, models declared with +// capabilities. +type LocalModelsConfig struct { + Endpoints []LocalEndpoint `yaml:"endpoints"` +} +type LocalEndpoint struct { + URL string `yaml:"url"` + Models []LocalModel `yaml:"models"` +} +type LocalModel struct { + ID string `yaml:"id"` + Capabilities []string `yaml:"capabilities"` + Dimension int `yaml:"dimension,omitempty"` +} + +// ---------- compiler ---------- + +// compile is the deterministic pass spec × target → plan artifacts. +func compile(spec *Spec, target *Target) (planResult, []Diagnostic) { + var diags []Diagnostic + + chosen, suggDiags := pickReasoningModel(spec, target) + diags = append(diags, suggDiags...) + + workflow := Workflow{ + SchemaVersion: 1, + Nodes: []map[string]any{}, + Edges: []map[string]any{}, + Functions: []any{}, + DeclaredVariables: []any{}, + Channels: []map[string]any{}, + Memory: []any{}, + Models: []map[string]any{}, + } + mapping := DeploymentMapping{} + resources := ExternalResources{} + manifest := DeviceManifest{ + GPIOs: map[string]map[string]any{}, + Serials: map[string]map[string]any{}, + PWMs: map[string]map[string]any{}, + ADCs: map[string]map[string]any{}, + DACs: map[string]map[string]any{}, + } + + // Index spec.buses for fast lookup. + busByID := map[string]Bus{} + for _, b := range spec.Buses { + busByID[b.ID] = b + } + + // 1. Model declaration in workflow. Local SLM is referenced by id; the + // Local provider routes via the embedded local-models.yaml. + if chosen.ID != "" { + workflow.Models = append(workflow.Models, map[string]any{ + "type": "LLMModel", + "id": chosen.ID, + "label": chosen.ID, + "capabilities": []string{spec.Agent.Reasoning.Capability}, + }) + } + + // 2. Devices → channels (+ matching mapping/resources/manifest entries). + // Channels are sorted by stable id for reproducible output. + sortedDevices := append([]Device(nil), spec.Devices...) + sort.Slice(sortedDevices, func(i, j int) bool { return sortedDevices[i].ID < sortedDevices[j].ID }) + + for _, d := range sortedDevices { + bus := busByID[d.Bus.Ref] + switch bus.Type { + case "mqtt": + chID := "ch-mqtt-" + sanitize(d.ID) + workflow.Channels = append(workflow.Channels, map[string]any{ + "type": "MQTT", + "id": chID, + "label": d.ID, + "topic": d.Bus.Topic, + }) + // MQTT channels resolve against ExternalResources via mapping.ref. + resID := "mqtt-" + sanitize(bus.ID) + mapping[chID] = ResourceBinding{Ref: resID} + resources[resID] = mqttResource(bus) + + case "gpio": + direction := "GPIOIN" + if d.Bus.Writable || d.Kind == "actuator" { + direction = "GPIOOUT" + } + chID := "ch-gpio-" + sanitize(d.ID) + ch := map[string]any{ + "type": direction, + "id": chID, + "label": d.ID, + } + if direction == "GPIOIN" { + ch["bias"] = "pulldown" + ch["debounceMs"] = 20 + } + workflow.Channels = append(workflow.Channels, ch) + gpioID := pickGPIOChip(target, bus) + line := 0 + if d.Bus.Line != nil { + line = *d.Bus.Line + } + mapping[chID] = ResourceBinding{Ref: gpioID, Index: &line} + manifest.GPIOs[gpioID] = map[string]any{"chip": gpioID} + + case "serial": + chID := "ch-uart-" + sanitize(d.ID) + workflow.Channels = append(workflow.Channels, map[string]any{ + "type": "UART", + "id": chID, + "label": d.ID, + }) + serID := pickSerial(target, bus) + mapping[chID] = ResourceBinding{Ref: serID} + serCfg := map[string]any{"device": serialDevice(target, bus)} + if bus.Baud > 0 { + serCfg["baud"] = bus.Baud + } + manifest.Serials[serID] = serCfg + } + } + + // 3. Nodes — minimal but engine-loadable. Per device-class one node; + // a single Agent driven by Ticker. Layout positions are computed + // from index for stable visual output in fh-builder. + startupID := nodeID(spec.Name, "OnStartup", "boot") + tickerID := nodeID(spec.Name, "Ticker", "agent") + agentID := nodeID(spec.Name, "Agent", "reasoner") + + workflow.Nodes = append(workflow.Nodes, + map[string]any{ + "id": startupID, + "type": "OnStartup", + "position": map[string]any{"x": 0, "y": 0}, + "arguments": map[string]any{}, + }, + map[string]any{ + "id": tickerID, + "type": "Ticker", + "position": map[string]any{"x": 0, "y": 120}, + "arguments": map[string]any{ + "intervalMs": parseEvery(spec.Agent.EvaluationEvery), + }, + }, + map[string]any{ + "id": agentID, + "type": "Agent", + "position": map[string]any{"x": 300, "y": 60}, + "arguments": map[string]any{ + "name": "site-agent", + "model": chosen.ID, + "instructions": buildAgentInstructions(spec, target), + "maxTurns": 6, + "answer": map[string]any{"active": false, "mode": "emit", "name": "answer"}, + "outputDeclarations": []any{}, + "memoryRefs": []any{}, + }, + }, + ) + + // Boot edge: OnStartup → Agent (one-shot warmup). + workflow.Edges = append(workflow.Edges, controlEdge(startupID, agentID)) + // Periodic edge: Ticker → Agent. + workflow.Edges = append(workflow.Edges, controlEdge(tickerID, agentID)) + + // 4. Per-device IO nodes. + for i, d := range sortedDevices { + bus := busByID[d.Bus.Ref] + y := 240 + i*80 + switch bus.Type { + case "mqtt": + chID := "ch-mqtt-" + sanitize(d.ID) + if d.Kind == "sensor" { + id := nodeID(spec.Name, "OnMqttMessage", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "OnMqttMessage", + "position": map[string]any{"x": 0, "y": y}, + "arguments": map[string]any{ + "channelReference": chID, + "dataType": inferDataType(d), + "output": map[string]any{"active": true, "mode": "emit", "name": d.ID}, + }, + }) + workflow.Edges = append(workflow.Edges, controlEdge(id, agentID)) + } else if d.Kind == "actuator" { + id := nodeID(spec.Name, "MqttPublish", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "MqttPublish", + "position": map[string]any{"x": 600, "y": y}, + "arguments": map[string]any{ + "channelReference": chID, + "dataType": inferDataType(d), + "value": map[string]any{"kind": "literal", "value": ""}, + "qos": 0, + "retain": false, + "toolDescription": fmt.Sprintf("Write to actuator %q (%s in zone %s)", d.ID, d.Controls, d.Zone), + }, + }) + workflow.Edges = append(workflow.Edges, agentTaskEdge(agentID, id)) + } + case "gpio": + chID := "ch-gpio-" + sanitize(d.ID) + if d.Kind == "sensor" { + id := nodeID(spec.Name, "OnPinEdge", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "OnPinEdge", + "position": map[string]any{"x": 0, "y": y}, + "arguments": map[string]any{ + "channelReference": chID, + "edge": "both", + "output": map[string]any{"active": true, "mode": "emit", "name": d.ID}, + }, + }) + workflow.Edges = append(workflow.Edges, controlEdge(id, agentID)) + } else if d.Kind == "actuator" { + id := nodeID(spec.Name, "WritePin", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "WritePin", + "position": map[string]any{"x": 600, "y": y}, + "arguments": map[string]any{ + "channelReference": chID, + "signalType": "digital", + "value": map[string]any{"kind": "literal", "value": 0}, + "toolDescription": fmt.Sprintf("Drive GPIO actuator %q (%s)", d.ID, d.Controls), + }, + }) + workflow.Edges = append(workflow.Edges, agentTaskEdge(agentID, id)) + } + case "serial": + chID := "ch-uart-" + sanitize(d.ID) + if d.Kind == "sensor" { + id := nodeID(spec.Name, "OnSerialReceive", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "OnSerialReceive", + "position": map[string]any{"x": 0, "y": y}, + "arguments": map[string]any{ + "portReference": chID, + "output": map[string]any{"active": true, "mode": "emit", "name": d.ID}, + }, + }) + workflow.Edges = append(workflow.Edges, controlEdge(id, agentID)) + } else if d.Kind == "actuator" { + id := nodeID(spec.Name, "SerialWrite", d.ID) + workflow.Nodes = append(workflow.Nodes, map[string]any{ + "id": id, + "type": "SerialWrite", + "position": map[string]any{"x": 600, "y": y}, + "arguments": map[string]any{ + "portReference": chID, + "value": map[string]any{"kind": "literal", "value": ""}, + }, + }) + workflow.Edges = append(workflow.Edges, agentTaskEdge(agentID, id)) + } + } + } + + // 5. Local-models.yaml — one endpoint, one model entry, capability list + // drawn from the target catalog. + local := LocalModelsConfig{ + Endpoints: []LocalEndpoint{ + { + URL: fmt.Sprintf("http://localhost:%d", target.SLMRuntime.ServePort), + Models: []LocalModel{ + { + ID: chosen.ID, + Capabilities: modelCapabilities(target, chosen.ID), + Dimension: modelDimension(target, chosen.ID), + }, + }, + }, + }, + } + + return planResult{ + Workflow: workflow, + Mapping: mapping, + Resources: resources, + DeviceManifest: manifest, + LocalModels: local, + BundleMetadata: BundleMetadata{ + SchemaVersion: 1, + SpecName: spec.Name, + TargetID: target.ID, + TargetArch: target.Arch, + SLMRuntime: target.SLMRuntime, + ChosenModel: ChosenModel{ + ID: chosen.ID, + RAMMB: chosen.RAMMB, + GPURequest: target.SLMRuntime.GPURequest, + }, + }, + }, diags +} + +// pickReasoningModel returns the best-fit SLM for the requested capability +// or an error diag if none fit on the target. +func pickReasoningModel(spec *Spec, target *Target) (TargetSLM, []Diagnostic) { + cands := suggestModels(target, spec.Agent.Reasoning.Capability, 0) + if len(cands) == 0 { + return TargetSLM{}, []Diagnostic{{ + Severity: "error", + Category: "plan", + Message: fmt.Sprintf("no SLM on target %q satisfies capability %q within %d MB available RAM", + target.ID, spec.Agent.Reasoning.Capability, target.RAM.availableMB()), + Location: "$.agent.reasoning.capability", + }} + } + // Prefer the largest model that still fits — better quality than the + // smallest. suggestModels returns smallest-first, so reverse-pick. + return cands[len(cands)-1], nil +} + +func modelCapabilities(t *Target, id string) []string { + for _, m := range t.SLMs { + if m.ID == id { + return append([]string(nil), m.Capabilities...) + } + } + return nil +} + +func modelDimension(t *Target, id string) int { + for _, m := range t.SLMs { + if m.ID == id { + return m.Dimension + } + } + return 0 +} + +// pickGPIOChip returns the chip id either from the bus override or the +// target's first gpiochip. Errors are handled later in validate. +func pickGPIOChip(t *Target, b Bus) string { + if b.Chip != "" { + return b.Chip + } + if len(t.Hardware.GPIOs) > 0 { + return t.Hardware.GPIOs[0].ID + } + return "gpiochip0" +} + +func pickSerial(t *Target, b Bus) string { + if b.Device != "" { + return "uart-" + sanitize(b.ID) + } + if len(t.Hardware.Serials) > 0 { + return t.Hardware.Serials[0].ID + } + return "uart-default" +} + +func serialDevice(t *Target, b Bus) string { + if b.Device != "" { + return b.Device + } + if len(t.Hardware.Serials) > 0 { + return t.Hardware.Serials[0].Device + } + return "/dev/ttyUSB0" +} + +func mqttResource(b Bus) map[string]any { + out := map[string]any{ + "type": "mqtt", + "brokerUrl": b.BrokerURL, + } + if b.Username != "" { + out["username"] = b.Username + } + if b.Password != "" { + out["password"] = b.Password + } + return out +} + +func controlEdge(from, to string) map[string]any { + return map[string]any{ + "id": "e-ctrl-" + shortHash(from+"->"+to), + "type": "control", + "from": map[string]any{"nodeId": from, "port": "ctrl"}, + "to": map[string]any{"nodeId": to, "port": "ctrl"}, + } +} + +func agentTaskEdge(from, to string) map[string]any { + return map[string]any{ + "id": "e-tool-" + shortHash(from+"->"+to), + "type": "agentTask", + "from": map[string]any{"nodeId": from, "port": "tool"}, + "to": map[string]any{"nodeId": to, "port": "ctrl"}, + } +} + +// nodeID derives a stable id from spec name + node type + discriminator. +// Same input → same id; agents can diff plan outputs across runs cleanly. +func nodeID(specName, nodeType, disc string) string { + return nodeType + "_" + shortHash(specName+"|"+nodeType+"|"+disc) +} + +func shortHash(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:6]) +} + +// sanitize maps an arbitrary id into [a-z0-9-] for use in channel/resource +// identifiers. +func sanitize(s string) string { + out := make([]byte, 0, len(s)) + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + out = append(out, byte(r)) + case r == '-', r == '_', r == ' ', r == '.': + out = append(out, '-') + } + } + return string(out) +} + +func parseEvery(s string) int { + if s == "" { + return 60_000 + } + mult := 1000 + body := s + switch { + case strings.HasSuffix(s, "ms"): + mult, body = 1, strings.TrimSuffix(s, "ms") + case strings.HasSuffix(s, "s"): + mult, body = 1000, strings.TrimSuffix(s, "s") + case strings.HasSuffix(s, "m"): + mult, body = 60_000, strings.TrimSuffix(s, "m") + case strings.HasSuffix(s, "h"): + mult, body = 3_600_000, strings.TrimSuffix(s, "h") + } + n := 0 + for _, c := range body { + if c < '0' || c > '9' { + return 60_000 + } + n = n*10 + int(c-'0') + } + if n == 0 { + return 60_000 + } + return n * mult +} + +// inferDataType maps a device's unit/measurement to a workflow DataType. +func inferDataType(d Device) string { + if d.Unit != "" { + return "float" + } + switch d.Measures { + case "temperature", "humidity", "co2", "pressure", "voltage", "current": + return "float" + case "presence", "open", "closed": + return "bool" + case "text", "command": + return "string" + } + switch d.Controls { + case "light", "valve": + return "bool" + } + return "string" +} + +// buildAgentInstructions composes the reasoning agent's system prompt from +// the spec's goals, constraints, devices, and user hint. Deterministic order. +func buildAgentInstructions(spec *Spec, target *Target) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("You are the on-device agent for the site %q running on a %s.\n", spec.Name, target.DisplayName)) + if spec.Description != "" { + sb.WriteString(spec.Description + "\n") + } + if len(spec.Goals) > 0 { + sb.WriteString("\nGoals:\n") + for _, g := range spec.Goals { + sb.WriteString(fmt.Sprintf("- (%s) %s\n", g.ID, g.Description)) + } + } + if len(spec.Constraints) > 0 { + sb.WriteString("\nHard constraints (do not violate):\n") + for _, c := range spec.Constraints { + sb.WriteString(fmt.Sprintf("- (%s) %s\n", c.ID, c.Description)) + } + } + sortedDevs := append([]Device(nil), spec.Devices...) + sort.Slice(sortedDevs, func(i, j int) bool { return sortedDevs[i].ID < sortedDevs[j].ID }) + if len(sortedDevs) > 0 { + sb.WriteString("\nDevices available:\n") + for _, d := range sortedDevs { + role := d.Measures + if d.Kind == "actuator" { + role = d.Controls + } + zone := d.Zone + if zone == "" { + zone = "—" + } + sb.WriteString(fmt.Sprintf("- %s [%s, %s, zone=%s]\n", d.ID, d.Kind, role, zone)) + } + } + if hint := strings.TrimSpace(spec.Agent.Reasoning.PromptHint); hint != "" { + sb.WriteString("\nAdditional guidance:\n") + sb.WriteString(hint) + sb.WriteString("\n") + } + return sb.String() +} + +// ---------- artifact writers (deterministic) ---------- + +func writePlanArtifacts(dir string, p planResult) error { + if err := writeStableJSON(filepath.Join(dir, "agent.workflow.json"), p.Workflow); err != nil { + return err + } + if err := writeStableJSON(filepath.Join(dir, "site.mapping.json"), p.Mapping); err != nil { + return err + } + if err := writeStableJSON(filepath.Join(dir, "device.manifest.json"), p.DeviceManifest); err != nil { + return err + } + if err := writeStableYAML(filepath.Join(dir, "site.resources.yaml"), p.Resources); err != nil { + return err + } + if err := writeStableYAML(filepath.Join(dir, "local-models.yaml"), p.LocalModels); err != nil { + return err + } + if err := writeStableJSON(filepath.Join(dir, "bundle.meta.json"), p.BundleMetadata); err != nil { + return err + } + return nil +} + +// writeStableJSON writes JSON with sorted map keys and stable indentation. +// json.Marshal already sorts map keys; for nested maps we round-trip through +// json.RawMessage to normalize key order recursively. +func writeStableJSON(path string, v any) error { + buf, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("marshal %s: %w", path, err) + } + // Round-trip via decode-into-interface{} → re-marshal, which sorts + // nested map keys (encoding/json sorts only top-level map keys, but + // not deeply-nested maps inside slices of any). + var generic any + if err := json.Unmarshal(buf, &generic); err != nil { + return fmt.Errorf("renormalize %s: %w", path, err) + } + sorted, err := json.MarshalIndent(generic, "", " ") + if err != nil { + return fmt.Errorf("re-marshal %s: %w", path, err) + } + return os.WriteFile(path, append(sorted, '\n'), 0o644) +} + +func writeStableYAML(path string, v any) error { + buf, err := yaml.Marshal(v) + if err != nil { + return fmt.Errorf("marshal %s: %w", path, err) + } + return os.WriteFile(path, buf, 0o644) +} diff --git a/go/cmd/fh-agent/plan_test.go b/go/cmd/fh-agent/plan_test.go new file mode 100644 index 0000000..1909679 --- /dev/null +++ b/go/cmd/fh-agent/plan_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// TestCompileIsDeterministic plans the example spec twice and ensures the +// generated artifacts are byte-identical. Iterating agents rely on this — +// any non-deterministic field (timestamps, random ids, map iteration order) +// would break the workflow. +func TestCompileIsDeterministic(t *testing.T) { + exampleSpec := "examples/building-automation.site.yaml" + for _, targetID := range []string{"rpi5-8gb", "stm32mp25-1gb", "jetson-orin-nano-8gb", "x86-nuc-16gb", "ctrlx-core-arm64"} { + t.Run(targetID, func(t *testing.T) { + a := planTo(t, exampleSpec, targetID) + b := planTo(t, exampleSpec, targetID) + if len(a) != len(b) { + t.Fatalf("artifact count differs across runs: %d vs %d", len(a), len(b)) + } + for name, contentA := range a { + contentB, ok := b[name] + if !ok { + t.Errorf("%s: missing in second run", name) + continue + } + if !bytes.Equal(contentA, contentB) { + t.Errorf("%s: bytes differ across runs (non-deterministic)", name) + } + } + }) + } +} + +// TestCompileFitsTarget plans the example on every embedded target and checks +// the chosen model's RAM fits within the target's available budget. +func TestCompileFitsTarget(t *testing.T) { + targets, err := loadAllTargets() + if err != nil { + t.Fatalf("load targets: %v", err) + } + spec, err := LoadSpec("examples/building-automation.site.yaml") + if err != nil { + t.Fatalf("load spec: %v", err) + } + for id, target := range targets { + t.Run(id, func(t *testing.T) { + plan, diags := compile(spec, target) + if hasError(diags) { + t.Fatalf("compile produced errors: %+v", diags) + } + if plan.BundleMetadata.ChosenModel.RAMMB > target.RAM.availableMB() { + t.Errorf("chosen model %q needs %d MB, target has %d MB available", + plan.BundleMetadata.ChosenModel.ID, + plan.BundleMetadata.ChosenModel.RAMMB, + target.RAM.availableMB()) + } + }) + } +} + +// TestValidateCatchesDanglingMapping mangles a planned bundle by deleting an +// entry from site.resources.yaml — validate must flag it. +func TestValidateCatchesDanglingMapping(t *testing.T) { + exampleSpec := "examples/building-automation.site.yaml" + dir := t.TempDir() + spec, err := LoadSpec(exampleSpec) + if err != nil { + t.Fatalf("load spec: %v", err) + } + target, err := getTarget("rpi5-8gb") + if err != nil { + t.Fatalf("get target: %v", err) + } + plan, diags := compile(spec, target) + if hasError(diags) { + t.Fatalf("compile: %+v", diags) + } + if err := writePlanArtifacts(dir, plan); err != nil { + t.Fatalf("write: %v", err) + } + + // Corrupt: blank out resources file. + if err := os.WriteFile(filepath.Join(dir, "site.resources.yaml"), []byte("{}\n"), 0o644); err != nil { + t.Fatalf("corrupt: %v", err) + } + + // Reload & cross-check manually (we can't call runValidate without + // triggering os.Exit). Same logic, no exit. + wf, _ := loadWorkflow(filepath.Join(dir, "agent.workflow.json")) + mapping, _ := loadMapping(filepath.Join(dir, "site.mapping.json")) + resources, _ := loadResources(filepath.Join(dir, "site.resources.yaml")) + + found := false + for chID, b := range mapping { + var typ string + for _, ch := range wf.Channels { + if ch["id"] == chID { + typ, _ = ch["type"].(string) + break + } + } + if typ == "MQTT" { + if _, ok := resources[b.Ref]; !ok { + found = true + } + } + } + if !found { + t.Error("expected validate logic to detect missing mqtt resource binding, but didn't") + } +} + +// planTo runs compile + writePlanArtifacts and returns the artifacts as +// name→bytes for byte-level comparison. +func planTo(t *testing.T, specPath, targetID string) map[string][]byte { + t.Helper() + spec, err := LoadSpec(specPath) + if err != nil { + t.Fatalf("load spec: %v", err) + } + target, err := getTarget(targetID) + if err != nil { + t.Fatalf("get target: %v", err) + } + plan, diags := compile(spec, target) + if hasError(diags) { + t.Fatalf("compile: %+v", diags) + } + dir := t.TempDir() + if err := writePlanArtifacts(dir, plan); err != nil { + t.Fatalf("write: %v", err) + } + out := map[string][]byte{} + for _, f := range []string{ + "agent.workflow.json", "site.mapping.json", "site.resources.yaml", + "device.manifest.json", "local-models.yaml", "bundle.meta.json", + } { + raw, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + // Round-trip JSON through json.RawMessage to normalize whitespace — + // catches accidental non-determinism in the writer itself. + if filepath.Ext(f) == ".json" { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("parse %s: %v", f, err) + } + } + out[f] = raw + } + return out +} + +// TestSpecSchemaIsValidJSON sanity-checks the hand-written schema parses. +func TestSpecSchemaIsValidJSON(t *testing.T) { + var v any + if err := json.Unmarshal(SpecSchema(), &v); err != nil { + t.Fatalf("SpecSchema is not valid JSON: %v", err) + } +} diff --git a/go/cmd/fh-agent/spec.go b/go/cmd/fh-agent/spec.go new file mode 100644 index 0000000..2b03bdd --- /dev/null +++ b/go/cmd/fh-agent/spec.go @@ -0,0 +1,405 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Spec is the user-facing site description. It is the single durable artifact +// the user maintains; every other build artifact is regenerated from it. +type Spec struct { + SchemaVersion int `yaml:"schemaVersion" json:"schemaVersion"` + Name string `yaml:"name" json:"name"` + Target string `yaml:"target" json:"target"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Goals []Goal `yaml:"goals,omitempty" json:"goals,omitempty"` + Constraints []Constraint `yaml:"constraints,omitempty" json:"constraints,omitempty"` + Zones []Zone `yaml:"zones,omitempty" json:"zones,omitempty"` + Buses []Bus `yaml:"buses" json:"buses"` + Devices []Device `yaml:"devices" json:"devices"` + Agent Agent `yaml:"agent" json:"agent"` +} + +type Goal struct { + ID string `yaml:"id" json:"id"` + Description string `yaml:"description" json:"description"` +} + +type Constraint struct { + ID string `yaml:"id" json:"id"` + Description string `yaml:"description" json:"description"` +} + +type Zone struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name" json:"name"` +} + +// Bus describes a transport reachable from the site (MQTT broker, GPIO chip, +// serial port). Devices reference a bus by id. +type Bus struct { + ID string `yaml:"id" json:"id"` + Type string `yaml:"type" json:"type"` // mqtt | gpio | serial + BrokerURL string `yaml:"brokerUrl,omitempty" json:"brokerUrl,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + Chip string `yaml:"chip,omitempty" json:"chip,omitempty"` // gpio: overrides target default + Device string `yaml:"device,omitempty" json:"device,omitempty"` // serial: overrides target default + Baud int `yaml:"baud,omitempty" json:"baud,omitempty"` +} + +type Device struct { + ID string `yaml:"id" json:"id"` + Zone string `yaml:"zone,omitempty" json:"zone,omitempty"` + Kind string `yaml:"kind" json:"kind"` // sensor | actuator | controller + Measures string `yaml:"measures,omitempty" json:"measures,omitempty"` // sensors: temperature, humidity, co2, presence, ... + Controls string `yaml:"controls,omitempty" json:"controls,omitempty"` // actuators: heating, cooling, light, valve, ... + Unit string `yaml:"unit,omitempty" json:"unit,omitempty"` + Bus DeviceBus `yaml:"bus" json:"bus"` +} + +// DeviceBus binds a device to one of the spec's buses with the per-device +// addressing (mqtt topic, gpio line number, etc). +type DeviceBus struct { + Ref string `yaml:"ref" json:"ref"` // buses[].id + Topic string `yaml:"topic,omitempty" json:"topic,omitempty"` + Line *int `yaml:"line,omitempty" json:"line,omitempty"` // gpio line / pwm channel + Writable bool `yaml:"writable,omitempty" json:"writable,omitempty"` +} + +type Agent struct { + Reasoning AgentReasoning `yaml:"reasoning" json:"reasoning"` + EvaluationEvery string `yaml:"evaluationEvery,omitempty" json:"evaluationEvery,omitempty"` // simple "60s" / "5m" +} + +type AgentReasoning struct { + Capability string `yaml:"capability" json:"capability"` // chat | reasoning | classification | embedding + PromptHint string `yaml:"promptHint,omitempty" json:"promptHint,omitempty"` +} + +// LoadSpec reads + parses a site.spec.yaml in strict mode (unknown fields +// fail loudly — agents would otherwise emit silently-ignored typos). +func LoadSpec(path string) (*Spec, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read spec: %w", err) + } + dec := yaml.NewDecoder(bytes.NewReader(raw)) + dec.KnownFields(true) + var s Spec + if err := dec.Decode(&s); err != nil { + return nil, fmt.Errorf("parse spec: %w", err) + } + return &s, nil +} + +// SpecSchema returns a hand-written JSON Schema (draft 2020-12). Kept in code +// rather than generated so the schema and the Go types are co-located and +// drift between them shows up in PR review. +func SpecSchema() json.RawMessage { + return json.RawMessage(specSchemaJSON) +} + +// ValidateSpec returns structured diagnostics describing missing required +// fields, dangling refs, and bus/device inconsistencies. Returns nil if the +// spec is well-formed (validation against the target catalog happens later +// in the plan pass). +func ValidateSpec(s *Spec) []Diagnostic { + var diags []Diagnostic + add := func(sev, msg, loc string) { + diags = append(diags, Diagnostic{Severity: sev, Category: "spec", Message: msg, Location: loc}) + } + + if s.SchemaVersion != 1 { + add("error", "schemaVersion must be 1", "$.schemaVersion") + } + if s.Name == "" { + add("error", "name is required", "$.name") + } + if s.Target == "" { + add("error", "target is required", "$.target") + } + if len(s.Buses) == 0 { + add("error", "at least one bus is required", "$.buses") + } + if len(s.Devices) == 0 { + add("error", "at least one device is required", "$.devices") + } + if s.Agent.Reasoning.Capability == "" { + add("error", "agent.reasoning.capability is required (chat|reasoning|classification|embedding)", "$.agent.reasoning.capability") + } + + busByID := map[string]Bus{} + for i, b := range s.Buses { + loc := fmt.Sprintf("$.buses[%d]", i) + if b.ID == "" { + add("error", "bus id is required", loc+".id") + continue + } + if _, dup := busByID[b.ID]; dup { + add("error", fmt.Sprintf("duplicate bus id %q", b.ID), loc+".id") + } + busByID[b.ID] = b + switch b.Type { + case "mqtt": + if b.BrokerURL == "" { + add("error", "mqtt bus requires brokerUrl", loc+".brokerUrl") + } + case "gpio": + // chip defaults to target's first gpiochip; nothing required + case "serial": + // device defaults to target's first serial; nothing required + case "": + add("error", "bus type is required (mqtt|gpio|serial)", loc+".type") + default: + add("error", fmt.Sprintf("unknown bus type %q (mqtt|gpio|serial)", b.Type), loc+".type") + } + } + + zoneByID := map[string]bool{} + for _, z := range s.Zones { + zoneByID[z.ID] = true + } + + devByID := map[string]bool{} + for i, d := range s.Devices { + loc := fmt.Sprintf("$.devices[%d]", i) + if d.ID == "" { + add("error", "device id is required", loc+".id") + continue + } + if devByID[d.ID] { + add("error", fmt.Sprintf("duplicate device id %q", d.ID), loc+".id") + } + devByID[d.ID] = true + if d.Zone != "" && !zoneByID[d.Zone] { + add("warn", fmt.Sprintf("device %q references unknown zone %q", d.ID, d.Zone), loc+".zone") + } + switch d.Kind { + case "sensor": + if d.Measures == "" { + add("warn", fmt.Sprintf("sensor %q has no 'measures' set", d.ID), loc+".measures") + } + case "actuator": + if d.Controls == "" { + add("warn", fmt.Sprintf("actuator %q has no 'controls' set", d.ID), loc+".controls") + } + case "controller": + // nothing required + case "": + add("error", "device kind is required (sensor|actuator|controller)", loc+".kind") + default: + add("error", fmt.Sprintf("unknown device kind %q", d.Kind), loc+".kind") + } + bus, ok := busByID[d.Bus.Ref] + if !ok { + add("error", fmt.Sprintf("device %q references unknown bus %q", d.ID, d.Bus.Ref), loc+".bus.ref") + continue + } + switch bus.Type { + case "mqtt": + if d.Bus.Topic == "" { + add("error", fmt.Sprintf("device %q on mqtt bus requires bus.topic", d.ID), loc+".bus.topic") + } + case "gpio": + if d.Bus.Line == nil { + add("error", fmt.Sprintf("device %q on gpio bus requires bus.line", d.ID), loc+".bus.line") + } + } + if d.Kind == "actuator" && !d.Bus.Writable { + add("warn", fmt.Sprintf("actuator %q on bus %q is not marked writable", d.ID, d.Bus.Ref), loc+".bus.writable") + } + } + + return diags +} + +var errSpecInvalid = errors.New("spec validation failed") + +// specInitTemplate is the YAML emitted by `fh-agent spec init`. Kept compact +// and heavily commented so a human (or an LLM) can fill it in without +// reading external docs first. +const specInitTemplate = `schemaVersion: 1 + +# Human-readable site name (used as the bundle directory name). +name: my-site + +# Hardware target. Run 'fh-agent targets list' for available ids. +target: rpi5-8gb + +description: "" + +# What the agent is trying to achieve. Each goal becomes part of the +# reasoning prompt. Keep them short and behavioral. +goals: [] +# - id: comfort +# description: Keep occupied rooms comfortable. + +# Hard rules the agent must respect. Free-text in v1 — enforced via prompt, +# not via structural constraints. Tighten in a later version. +constraints: [] +# - id: heating-min +# description: Heating setpoint must never go below 5 °C. + +# Optional zone (room) labels. Devices reference a zone for spatial context. +zones: [] +# - id: livingroom +# name: Wohnzimmer + +# Buses are transports reachable from this site. Each device binds to one. +buses: [] +# - id: home-mqtt +# type: mqtt +# brokerUrl: tcp://localhost:1883 +# - id: gpio +# type: gpio +# - id: serial +# type: serial + +# Physical devices the agent reads or controls. +devices: [] +# - id: temp-livingroom +# zone: livingroom +# kind: sensor +# measures: temperature +# unit: C +# bus: +# ref: home-mqtt +# topic: home/living/temperature +# - id: heating-livingroom +# zone: livingroom +# kind: actuator +# controls: heating +# unit: C +# bus: +# ref: home-mqtt +# topic: home/living/heating/setpoint +# writable: true + +agent: + reasoning: + capability: chat # chat | reasoning | classification | embedding + promptHint: "" + evaluationEvery: 60s +` + +// Hand-written JSON Schema for the spec. Edit alongside the Go types above. +const specSchemaJSON = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "site.spec.yaml", + "type": "object", + "required": ["schemaVersion", "name", "target", "buses", "devices", "agent"], + "additionalProperties": false, + "properties": { + "schemaVersion": {"type": "integer", "const": 1}, + "name": {"type": "string", "minLength": 1}, + "target": {"type": "string", "minLength": 1, "description": "Hardware target id from 'fh-agent targets list'"}, + "description": {"type": "string"}, + "goals": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "description"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "description": {"type": "string", "minLength": 1} + } + } + }, + "constraints": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "description"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "description": {"type": "string", "minLength": 1} + } + } + }, + "zones": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "name"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "name": {"type": "string", "minLength": 1} + } + } + }, + "buses": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "type"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"type": "string", "enum": ["mqtt", "gpio", "serial"]}, + "brokerUrl": {"type": "string"}, + "username": {"type": "string"}, + "password": {"type": "string"}, + "chip": {"type": "string"}, + "device": {"type": "string"}, + "baud": {"type": "integer", "minimum": 0} + } + } + }, + "devices": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["id", "kind", "bus"], + "additionalProperties": false, + "properties": { + "id": {"type": "string", "minLength": 1}, + "zone": {"type": "string"}, + "kind": {"type": "string", "enum": ["sensor", "actuator", "controller"]}, + "measures": {"type": "string"}, + "controls": {"type": "string"}, + "unit": {"type": "string"}, + "bus": { + "type": "object", + "required": ["ref"], + "additionalProperties": false, + "properties": { + "ref": {"type": "string", "minLength": 1}, + "topic": {"type": "string"}, + "line": {"type": "integer", "minimum": 0}, + "writable": {"type": "boolean"} + } + } + } + } + }, + "agent": { + "type": "object", + "required": ["reasoning"], + "additionalProperties": false, + "properties": { + "reasoning": { + "type": "object", + "required": ["capability"], + "additionalProperties": false, + "properties": { + "capability": {"type": "string", "enum": ["chat", "reasoning", "classification", "embedding"]}, + "promptHint": {"type": "string"} + } + }, + "evaluationEvery": {"type": "string", "pattern": "^[0-9]+(s|m|h)$"} + } + } + } +} +` diff --git a/go/cmd/fh-agent/targets.go b/go/cmd/fh-agent/targets.go new file mode 100644 index 0000000..3940049 --- /dev/null +++ b/go/cmd/fh-agent/targets.go @@ -0,0 +1,211 @@ +package main + +import ( + "bytes" + "embed" + "fmt" + "io/fs" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +//go:embed targets/*.yaml +var targetsFS embed.FS + +// Target is the embedded hardware-profile shape. Each YAML in targets/ +// unmarshals into one of these. +type Target struct { + ID string `yaml:"id" json:"id"` + DisplayName string `yaml:"displayName" json:"displayName"` + Arch string `yaml:"arch" json:"arch"` // arm64 | amd64 + OS string `yaml:"os" json:"os"` + Runtime string `yaml:"runtime" json:"runtime"` // docker | podman | snap + CPU TargetCPU `yaml:"cpu" json:"cpu"` + RAM TargetRAM `yaml:"ram" json:"ram"` + Accel TargetAccel `yaml:"accel" json:"accel"` + Hardware TargetHardware `yaml:"hardware" json:"hardware"` + SLMs []TargetSLM `yaml:"slms" json:"slms"` + SLMRuntime TargetSLMRT `yaml:"slmRuntime" json:"slmRuntime"` + Notes string `yaml:"notes,omitempty" json:"notes,omitempty"` + NotesIO string `yaml:"notes_io,omitempty" json:"notes_io,omitempty"` + IndustrialBuses []string `yaml:"industrialBuses,omitempty" json:"industrialBuses,omitempty"` +} + +type TargetCPU struct { + Model string `yaml:"model" json:"model"` + Cores int `yaml:"cores" json:"cores"` + FreqMHz int `yaml:"freqMHz" json:"freqMHz"` +} + +type TargetRAM struct { + TotalMB int `yaml:"totalMB" json:"totalMB"` + ReservedMB int `yaml:"reservedMB" json:"reservedMB"` +} + +func (r TargetRAM) availableMB() int { + return r.TotalMB - r.ReservedMB +} + +type TargetAccel struct { + Type string `yaml:"type" json:"type"` // none | cuda | npu | igpu + Device string `yaml:"device,omitempty" json:"device,omitempty"` + TOPS float64 `yaml:"tops,omitempty" json:"tops,omitempty"` + Notes string `yaml:"notes,omitempty" json:"notes,omitempty"` +} + +type TargetHardware struct { + GPIOs []TargetGPIO `yaml:"gpios" json:"gpios"` + Serials []TargetSerial `yaml:"serials" json:"serials"` + PWMs []TargetPWM `yaml:"pwms" json:"pwms"` + ADCs []TargetADC `yaml:"adcs" json:"adcs"` + DACs []TargetDAC `yaml:"dacs" json:"dacs"` + I2C []string `yaml:"i2c,omitempty" json:"i2c,omitempty"` + SPI []string `yaml:"spi,omitempty" json:"spi,omitempty"` +} + +type TargetGPIO struct { + ID string `yaml:"id" json:"id"` + Chip string `yaml:"chip" json:"chip"` + Lines int `yaml:"lines" json:"lines"` +} +type TargetSerial struct { + ID string `yaml:"id" json:"id"` + Device string `yaml:"device" json:"device"` + DefaultBaud int `yaml:"defaultBaud" json:"defaultBaud"` +} +type TargetPWM struct { + ID string `yaml:"id" json:"id"` + Chip string `yaml:"chip" json:"chip"` +} +type TargetADC struct { + ID string `yaml:"id" json:"id"` + Device string `yaml:"device" json:"device"` +} +type TargetDAC struct { + ID string `yaml:"id" json:"id"` + Device string `yaml:"device" json:"device"` +} + +type TargetSLM struct { + ID string `yaml:"id" json:"id"` + Capabilities []string `yaml:"capabilities" json:"capabilities"` + RAMMB int `yaml:"ramMB" json:"ramMB"` + EstTokPerSec float64 `yaml:"estTokPerSec,omitempty" json:"estTokPerSec,omitempty"` + Dimension int `yaml:"dimension,omitempty" json:"dimension,omitempty"` + Runtime string `yaml:"runtime,omitempty" json:"runtime,omitempty"` + Notes string `yaml:"notes,omitempty" json:"notes,omitempty"` +} + +type TargetSLMRT struct { + Image string `yaml:"image" json:"image"` + ServePort int `yaml:"servePort" json:"servePort"` + GPURequest string `yaml:"gpuRequest,omitempty" json:"gpuRequest,omitempty"` +} + +// loadAllTargets reads every embedded *.yaml under targets/ exactly once. +// Strict mode (KnownFields) catches typos in profile files at compile-time +// of the binary, not at runtime in a customer environment. +func loadAllTargets() (map[string]*Target, error) { + out := map[string]*Target{} + err := fs.WalkDir(targetsFS, "targets", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if !strings.HasSuffix(path, ".yaml") { + return nil + } + raw, rerr := targetsFS.ReadFile(path) + if rerr != nil { + return fmt.Errorf("read %s: %w", path, rerr) + } + dec := yaml.NewDecoder(bytes.NewReader(raw)) + dec.KnownFields(true) + var t Target + if derr := dec.Decode(&t); derr != nil { + return fmt.Errorf("parse %s: %w", path, derr) + } + if t.ID == "" { + return fmt.Errorf("%s: missing id", path) + } + if _, dup := out[t.ID]; dup { + return fmt.Errorf("%s: duplicate target id %q", path, t.ID) + } + out[t.ID] = &t + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// listTargets returns target ids sorted alphabetically for deterministic +// output. +func listTargets() ([]*Target, error) { + all, err := loadAllTargets() + if err != nil { + return nil, err + } + out := make([]*Target, 0, len(all)) + for _, t := range all { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func getTarget(id string) (*Target, error) { + all, err := loadAllTargets() + if err != nil { + return nil, err + } + t, ok := all[id] + if !ok { + ids := make([]string, 0, len(all)) + for k := range all { + ids = append(ids, k) + } + sort.Strings(ids) + return nil, fmt.Errorf("unknown target %q; available: %s", id, strings.Join(ids, ", ")) + } + return t, nil +} + +// suggestModels picks SLMs from the target whose capability set covers the +// requested capability and whose RAM footprint fits the available budget. +// Results are sorted by best-fit: smaller RAM first (leaves headroom), +// then higher estimated throughput. +func suggestModels(t *Target, capability string, maxRAMMB int) []TargetSLM { + avail := t.RAM.availableMB() + if maxRAMMB > 0 && maxRAMMB < avail { + avail = maxRAMMB + } + var out []TargetSLM + for _, m := range t.SLMs { + if !contains(m.Capabilities, capability) { + continue + } + if m.RAMMB > avail { + continue + } + out = append(out, m) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].RAMMB != out[j].RAMMB { + return out[i].RAMMB < out[j].RAMMB + } + return out[i].EstTokPerSec > out[j].EstTokPerSec + }) + return out +} + +func contains(xs []string, x string) bool { + for _, v := range xs { + if v == x { + return true + } + } + return false +} diff --git a/go/cmd/fh-agent/targets/ctrlx-core-arm64.yaml b/go/cmd/fh-agent/targets/ctrlx-core-arm64.yaml new file mode 100644 index 0000000..ae5dfaf --- /dev/null +++ b/go/cmd/fh-agent/targets/ctrlx-core-arm64.yaml @@ -0,0 +1,68 @@ +id: ctrlx-core-arm64 +displayName: Bosch Rexroth ctrlX CORE (ARM64, ctrlX OS) +arch: arm64 +os: linux +runtime: docker +cpu: + model: NXP i.MX 8M Plus (Cortex-A53 quad-core) + cores: 4 + freqMHz: 1600 +ram: + totalMB: 2048 + reservedMB: 512 +accel: + type: npu + device: imx8mp-vpu + tops: 2.3 + notes: | + NXP Neural Processing Unit on i.MX 8M Plus. Used via ONNX Runtime / NNAPI; + not OpenAI-API compatible. Best for classifier/vision side-tasks. +hardware: + gpios: + - id: gpiochip0 + chip: gpiochip0 + lines: 24 + serials: + - id: rs232 + device: /dev/ttymxc0 + defaultBaud: 115200 + - id: rs485 + device: /dev/ttymxc1 + defaultBaud: 115200 + pwms: [] + adcs: [] + dacs: [] + i2c: + - /dev/i2c-1 + spi: + - /dev/spidev0.0 +industrialBuses: + - ethercat + - profinet + - opc-ua + - modbus-tcp +slms: + - id: llama-3.2-1b-instruct-q4 + capabilities: [chat, function_call] + ramMB: 1200 + estTokPerSec: 7 + - id: tinyllama-1.1b-chat-q4 + capabilities: [chat] + ramMB: 720 + estTokPerSec: 9 + - id: distil-bert-cls-q8 + capabilities: [classification] + ramMB: 95 + - id: nomic-embed-text-v1.5-q4 + capabilities: [embedding] + dimension: 768 + ramMB: 220 +slmRuntime: + image: ghcr.io/ggerganov/llama.cpp:server + servePort: 8080 +notes: | + Industrial controller running ctrlX OS (Ubuntu-Core based). Apps deploy as + Snap or Docker (Docker engine available via 'docker-cli' snap). Fieldbus + IO (EtherCAT, PROFINET, OPC-UA, Modbus-TCP) is reached via the ctrlX OS + Data Layer rather than direct GPIO — wire those to MQTT or HTTP channels + in the workflow and let a ctrlX Data-Layer bridge translate. diff --git a/go/cmd/fh-agent/targets/jetson-orin-nano-8gb.yaml b/go/cmd/fh-agent/targets/jetson-orin-nano-8gb.yaml new file mode 100644 index 0000000..f6ff3a4 --- /dev/null +++ b/go/cmd/fh-agent/targets/jetson-orin-nano-8gb.yaml @@ -0,0 +1,67 @@ +id: jetson-orin-nano-8gb +displayName: NVIDIA Jetson Orin Nano (8 GB) +arch: arm64 +os: linux +runtime: docker +cpu: + model: Cortex-A78AE 6-core + cores: 6 + freqMHz: 1500 +ram: + totalMB: 8192 + reservedMB: 1536 +accel: + type: cuda + device: orin + tops: 40 + notes: 1024-core Ampere GPU + 32 Tensor Cores. INT8 sparse 40 TOPS. CUDA, TensorRT, vLLM supported. +hardware: + gpios: + - id: gpiochip0 + chip: gpiochip0 + lines: 40 + serials: + - id: uart-header + device: /dev/ttyTHS1 + defaultBaud: 115200 + pwms: + - id: pwmchip0 + chip: /sys/class/pwm/pwmchip0 + adcs: [] + dacs: [] + i2c: + - /dev/i2c-1 + - /dev/i2c-7 + spi: + - /dev/spidev0.0 +slms: + - id: llama-3.2-3b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 2800 + estTokPerSec: 60 + runtime: llama.cpp + - id: llama-3.1-8b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 5800 + estTokPerSec: 22 + runtime: llama.cpp + - id: qwen2.5-7b-instruct-awq + capabilities: [chat, function_call, reasoning, code] + ramMB: 6200 + estTokPerSec: 35 + runtime: vllm + - id: nomic-embed-text-v1.5 + capabilities: [embedding] + dimension: 768 + ramMB: 350 + - id: distil-bert-cls + capabilities: [classification] + ramMB: 95 +slmRuntime: + image: dustynv/llama_cpp:r36.4.0 + servePort: 8080 + gpuRequest: all +notes: | + Top edge-AI option for industrial / robotics / vision-language scenarios. + CUDA acceleration enables 7B-class models. Use TensorRT-LLM or vLLM for + production. JetPack 6 / Ubuntu 22.04 base. diff --git a/go/cmd/fh-agent/targets/rpi5-8gb.yaml b/go/cmd/fh-agent/targets/rpi5-8gb.yaml new file mode 100644 index 0000000..b274701 --- /dev/null +++ b/go/cmd/fh-agent/targets/rpi5-8gb.yaml @@ -0,0 +1,64 @@ +id: rpi5-8gb +displayName: Raspberry Pi 5 (8 GB) +arch: arm64 +os: linux +runtime: docker +cpu: + model: BCM2712 (Cortex-A76 quad-core) + cores: 4 + freqMHz: 2400 +ram: + totalMB: 8192 + reservedMB: 1024 +accel: + type: none + notes: VideoCore VII GPU present but not used for LLM inference (no mature CUDA/Metal path). +hardware: + gpios: + - id: gpiochip0 + chip: gpiochip0 + lines: 28 + serials: + - id: uart-onboard + device: /dev/ttyAMA0 + defaultBaud: 115200 + - id: uart-usb + device: /dev/ttyUSB0 + defaultBaud: 115200 + pwms: + - id: pwmchip0 + chip: /sys/class/pwm/pwmchip0 + adcs: [] + dacs: [] + i2c: + - /dev/i2c-1 + spi: + - /dev/spidev0.0 + - /dev/spidev0.1 +slms: + - id: llama-3.2-1b-instruct-q4 + capabilities: [chat, function_call] + ramMB: 1200 + estTokPerSec: 28 + - id: llama-3.2-3b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 2800 + estTokPerSec: 11 + - id: qwen2.5-3b-instruct-q5 + capabilities: [chat, function_call, code] + ramMB: 3400 + estTokPerSec: 9 + - id: nomic-embed-text-v1.5-q8 + capabilities: [embedding] + dimension: 768 + ramMB: 280 + - id: distil-bert-cls-q8 + capabilities: [classification] + ramMB: 95 +slmRuntime: + image: ghcr.io/ggerganov/llama.cpp:server + servePort: 8080 +notes: | + General-purpose ARM SBC. Workhorse for home automation and light edge AI. + No GPU/NPU acceleration for LLMs — CPU-only via llama.cpp. + GPIO via libgpiod (gpiochip0). On-board ADC requires HAT. diff --git a/go/cmd/fh-agent/targets/stm32mp25-1gb.yaml b/go/cmd/fh-agent/targets/stm32mp25-1gb.yaml new file mode 100644 index 0000000..32686fa --- /dev/null +++ b/go/cmd/fh-agent/targets/stm32mp25-1gb.yaml @@ -0,0 +1,63 @@ +id: stm32mp25-1gb +displayName: ST STM32MP25 (1 GB, Neural-ART NPU) +arch: arm64 +os: linux +runtime: docker +cpu: + model: Cortex-A35 dual-core + Cortex-M33 (Linux runs on A35) + cores: 2 + freqMHz: 1500 +ram: + totalMB: 1024 + reservedMB: 256 +accel: + type: npu + device: neural-art + tops: 1.35 + notes: | + ST Neural-ART NPU. Operates on quantized models exported via STM32Cube.AI. + Not OpenAI-API compatible — used for classifier/vision nodes, not chat LLMs. +hardware: + gpios: + - id: gpiochip0 + chip: gpiochip0 + lines: 32 + serials: + - id: uart4 + device: /dev/ttySTM0 + defaultBaud: 115200 + pwms: + - id: pwmchip0 + chip: /sys/class/pwm/pwmchip0 + adcs: + - id: adc1 + device: /sys/bus/iio/devices/iio:device0 + dacs: + - id: dac1 + device: /sys/bus/iio/devices/iio:device1 + i2c: + - /dev/i2c-1 + spi: + - /dev/spidev0.0 +slms: + - id: tinyllama-1.1b-chat-q4 + capabilities: [chat] + ramMB: 720 + estTokPerSec: 4 + notes: Tight fit; chat is slow but functional for simple intent extraction. + - id: distil-bert-cls-q8 + capabilities: [classification] + ramMB: 95 + estTokPerSec: 0 + - id: nomic-embed-text-v1.5-q4 + capabilities: [embedding] + dimension: 768 + ramMB: 220 +slmRuntime: + image: ghcr.io/ggerganov/llama.cpp:server + servePort: 8080 +notes: | + Microprocessor-class device (Linux on A35). Native ADC/DAC + on-board NPU. + Strong fit for sensor-fusion + classification edge tasks. + Chat-class LLMs only marginally usable — prefer classification / embedding + workflows here, route reasoning to cloud or to a higher-RAM peer. diff --git a/go/cmd/fh-agent/targets/x86-nuc-16gb.yaml b/go/cmd/fh-agent/targets/x86-nuc-16gb.yaml new file mode 100644 index 0000000..9273d32 --- /dev/null +++ b/go/cmd/fh-agent/targets/x86-nuc-16gb.yaml @@ -0,0 +1,59 @@ +id: x86-nuc-16gb +displayName: Intel NUC / x86 mini-PC (16 GB) +arch: amd64 +os: linux +runtime: docker +cpu: + model: Intel Core i5-1340P class (12-core, P+E) + cores: 12 + freqMHz: 4600 +ram: + totalMB: 16384 + reservedMB: 2048 +accel: + type: igpu + device: intel-iris-xe + notes: Iris Xe iGPU. ipex-llm enables INT4 LLM inference; not NPU-class but capable. +hardware: + gpios: [] + serials: + - id: uart-usb-a + device: /dev/ttyUSB0 + defaultBaud: 115200 + - id: uart-usb-b + device: /dev/ttyUSB1 + defaultBaud: 115200 + pwms: [] + adcs: [] + dacs: [] + i2c: [] + spi: [] +notes_io: | + No on-board GPIO/I2C/SPI/ADC. Industrial IO via USB-CAN, USB-RS485, + Ethernet/Modbus, or USB-GPIO adapters (FT232H, MCP2221). +slms: + - id: llama-3.2-3b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 2800 + estTokPerSec: 25 + - id: llama-3.1-8b-instruct-q4 + capabilities: [chat, function_call, reasoning] + ramMB: 5800 + estTokPerSec: 12 + - id: qwen2.5-7b-instruct-q5 + capabilities: [chat, function_call, reasoning, code] + ramMB: 6500 + estTokPerSec: 10 + - id: nomic-embed-text-v1.5 + capabilities: [embedding] + dimension: 768 + ramMB: 350 + - id: bge-reranker-base + capabilities: [classification, reasoning] + ramMB: 600 +slmRuntime: + image: ghcr.io/ggerganov/llama.cpp:server + servePort: 8080 +notes: | + Office-grade gateway. Most RAM-comfortable v1 target. + Best for content-heavy agents (RAG, document QA, vision-language). diff --git a/go/cmd/fh-agent/templates/compose.yml.tmpl b/go/cmd/fh-agent/templates/compose.yml.tmpl new file mode 100644 index 0000000..3a86f44 --- /dev/null +++ b/go/cmd/fh-agent/templates/compose.yml.tmpl @@ -0,0 +1,83 @@ +# Generated by fh-agent. Do not edit by hand — re-run `fh-agent build`. +# +# Stack layout: +# engine — official ForestHub edge-agent engine, configs mounted from ./ +# llm — local SLM server (llama.cpp / Ollama / vLLM image per target) +# mosquitto — optional local MQTT broker; remove if you connect to an +# external broker (then update site.resources.yaml accordingly). + +services: + engine: + image: ghcr.io/foresthubai/edge-agents/engine:latest + platform: linux/{{ .Arch }} + container_name: {{ .Name }}-engine + restart: unless-stopped + environment: + ENGINE_STANDALONE: "true" + ENGINE_ADDR: ":8081" + ENGINE_CONFIG_FILE: /etc/forest/agent.workflow.json + ENGINE_DEPLOYMENT_MAPPING_FILE: /etc/forest/site.mapping.json + ENGINE_EXTERNAL_RESOURCES_FILE: /etc/forest/site.resources.yaml + ENGINE_DEVICE_MANIFEST_FILE: /etc/forest/device.manifest.json + ENGINE_LOG_LEVEL: info + env_file: + - .env + volumes: + - ./agent.workflow.json:/etc/forest/agent.workflow.json:ro + - ./site.mapping.json:/etc/forest/site.mapping.json:ro + - ./site.resources.yaml:/etc/forest/site.resources.yaml:ro + - ./device.manifest.json:/etc/forest/device.manifest.json:ro + - engine-memory:/var/forest/memory +{{- if .NeedsGPIO }} + devices: + - /dev/gpiochip0:/dev/gpiochip0 +{{- end }} +{{- if .NeedsSerial }} + - /dev/ttyAMA0:/dev/ttyAMA0 +{{- end }} + ports: + - "8081:8081" + depends_on: + - llm +{{- if .NeedsMQTT }} + - mosquitto +{{- end }} + + llm: + image: {{ .SLMImage }} + platform: linux/{{ .Arch }} + container_name: {{ .Name }}-llm + restart: unless-stopped + command: ["--host", "0.0.0.0", "--port", "{{ .SLMPort }}", "--model", "/models/{{ .ModelID }}.gguf"] + volumes: + - ./models:/models:ro + ports: + - "{{ .SLMPort }}:{{ .SLMPort }}" +{{- if .GPURequest }} + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: {{ .GPURequest }} + capabilities: [gpu] +{{- end }} + +{{- if .NeedsMQTT }} + + mosquitto: + image: eclipse-mosquitto:2 + container_name: {{ .Name }}-mqtt + restart: unless-stopped + ports: + - "1883:1883" + volumes: + - ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + - mosquitto-data:/mosquitto/data +{{- end }} + +volumes: + engine-memory: +{{- if .NeedsMQTT }} + mosquitto-data: +{{- end }} diff --git a/go/cmd/fh-agent/templates/env.example.tmpl b/go/cmd/fh-agent/templates/env.example.tmpl new file mode 100644 index 0000000..6f8c3e5 --- /dev/null +++ b/go/cmd/fh-agent/templates/env.example.tmpl @@ -0,0 +1,12 @@ +# Generated by fh-agent. Copy to .env and fill in. +# +# Cloud LLM provider keys — only needed if your workflow uses them in +# addition to the local SLM. Leave blank to disable. +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GEMINI_API_KEY= +MISTRAL_API_KEY= + +# Web-search node provider (optional). +ENGINE_WEB_SEARCH_PROVIDER=brave +ENGINE_WEB_SEARCH_API_KEY= diff --git a/go/cmd/fh-agent/templates/mosquitto.conf.tmpl b/go/cmd/fh-agent/templates/mosquitto.conf.tmpl new file mode 100644 index 0000000..039a5cf --- /dev/null +++ b/go/cmd/fh-agent/templates/mosquitto.conf.tmpl @@ -0,0 +1,4 @@ +listener 1883 +allow_anonymous true +persistence true +persistence_location /mosquitto/data/ diff --git a/go/cmd/fh-agent/templates/readme.md.tmpl b/go/cmd/fh-agent/templates/readme.md.tmpl new file mode 100644 index 0000000..f871ca8 --- /dev/null +++ b/go/cmd/fh-agent/templates/readme.md.tmpl @@ -0,0 +1,60 @@ +# {{ .Name }} + +Edge-AI agent bundle, generated by `fh-agent` for target **{{ .TargetID }}** ({{ .TargetArch }}). + +## What's in this bundle + +| File | Purpose | +| --- | --- | +| `compose.yml` | Docker Compose stack: engine + local LLM (+ optional MQTT broker) | +| `agent.workflow.json` | The workflow graph (binding-free) | +| `site.mapping.json` | Binds workflow channels to platform resources | +| `site.resources.yaml` | MQTT broker URLs and other deploy-time configs | +| `device.manifest.json` | Hardware resources this device exposes | +| `local-models.yaml` | Local SLM registry (capabilities, endpoints) | +| `bundle.meta.json` | Metadata fh-agent uses; safe to inspect | +| `.env.example` | Secrets template — copy to `.env` and fill in | + +## Run it + +1. Copy this whole directory to the target device (`scp -r` is fine). +2. Place model weights under `./models/{{ .ModelID }}.gguf`. + {{- if .ModelDownloadHint }} + Download hint: {{ .ModelDownloadHint }} + {{- end }} +3. Copy `.env.example` to `.env` and fill in any secrets. +4. Bring the stack up: + ```sh + docker compose up -d + ``` +5. Engine HTTP API is on `:8081`. Health check: + ```sh + curl http://localhost:8081/healthz + ``` + +## Architecture chosen + +- **Reasoning model:** `{{ .ModelID }}` ({{ .ModelRAMMB }} MB RAM) +- **SLM runtime image:** `{{ .SLMImage }}` on port {{ .SLMPort }} +- **Accelerator:** {{ .AccelType }}{{ if .AccelDevice }} ({{ .AccelDevice }}){{ end }} + +## Re-generating + +If you change `site.spec.yaml`, re-run the pipeline: + +```sh +fh-agent plan site.spec.yaml --target {{ .TargetID }} --out build/ +fh-agent validate build/ +fh-agent build build/ --name {{ .Name }} --out dist/ +``` + +Every file in this bundle is regenerated from `site.spec.yaml`. Edits made +to the generated files will be overwritten on the next build. + +## Known limits (v1) + +- Workflow JSON is structurally valid per fh-agent's checks but is **not** + validated against the OpenAPI contract here. Run `fh-workflow validate + agent.workflow.json` (from the edge-agents TypeScript CLI) separately to + catch any contract drift. +- Provisioning, OTA updates, and HIL test runs are scoped for later versions. diff --git a/go/cmd/fh-agent/validate.go b/go/cmd/fh-agent/validate.go new file mode 100644 index 0000000..fd91175 --- /dev/null +++ b/go/cmd/fh-agent/validate.go @@ -0,0 +1,202 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// runValidate cross-checks a build/ directory produced by `fh-agent plan`. +// +// What this DOES check: +// - every workflow channel id has a mapping entry +// - every mapping ref resolves to either an external resource or a manifest entry +// - chosen model exists in local-models.yaml and fits target RAM +// - bundle metadata schema version is current +// +// What this does NOT check: +// - workflow JSON against the OpenAPI contract — that is the job of the +// TypeScript `fh-workflow validate` CLI (or the engine on /deploy). +// Run it separately for v1. +func runValidate(args []string) { + fs := flag.NewFlagSet("validate", flag.ExitOnError) + _ = fs.Bool("json", true, "emit JSON diagnostics") + pos := parseMixed(fs, args) + if len(pos) < 1 { + die(exitUsage, "usage: fh-agent validate ") + } + dir := pos[0] + + wf, diags := loadWorkflow(filepath.Join(dir, "agent.workflow.json")) + mapping, mdiags := loadMapping(filepath.Join(dir, "site.mapping.json")) + resources, rdiags := loadResources(filepath.Join(dir, "site.resources.yaml")) + manifest, manDiags := loadManifest(filepath.Join(dir, "device.manifest.json")) + meta, metaDiags := loadMetadata(filepath.Join(dir, "bundle.meta.json")) + diags = append(diags, mdiags...) + diags = append(diags, rdiags...) + diags = append(diags, manDiags...) + diags = append(diags, metaDiags...) + + if hasError(diags) { + emitDiags(os.Stderr, diags) + os.Exit(exitDiagnostics) + } + + // 1. Every channel id has a mapping entry. + channelTypes := map[string]string{} + for _, ch := range wf.Channels { + id, _ := ch["id"].(string) + typ, _ := ch["type"].(string) + if id == "" { + continue + } + channelTypes[id] = typ + if _, ok := mapping[id]; !ok { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("workflow channel %q has no mapping entry", id), + Location: "site.mapping.json", + }) + } + } + + // 2. Every mapping ref resolves. + for chID, b := range mapping { + typ := channelTypes[chID] + switch typ { + case "MQTT": + if _, ok := resources[b.Ref]; !ok { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("mapping for %q points at resource %q not present in site.resources.yaml", chID, b.Ref), + Location: "site.resources.yaml", + }) + } + case "GPIOIN", "GPIOOUT": + if _, ok := manifest.GPIOs[b.Ref]; !ok { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("mapping for %q points at gpio %q not present in device.manifest.json", chID, b.Ref), + Location: "device.manifest.json", + }) + } + case "UART": + if _, ok := manifest.Serials[b.Ref]; !ok { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("mapping for %q points at serial %q not present in device.manifest.json", chID, b.Ref), + Location: "device.manifest.json", + }) + } + } + } + + // 3. Chosen model fits target. + target, err := getTarget(meta.TargetID) + if err != nil { + diags = append(diags, Diagnostic{Severity: "error", Category: "validate", Message: err.Error()}) + } else { + if meta.ChosenModel.RAMMB > target.RAM.availableMB() { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("chosen model %q needs %d MB RAM, target %q has only %d MB available", + meta.ChosenModel.ID, meta.ChosenModel.RAMMB, target.ID, target.RAM.availableMB()), + }) + } + if !modelOnTarget(target, meta.ChosenModel.ID) { + diags = append(diags, Diagnostic{ + Severity: "warn", Category: "validate", + Message: fmt.Sprintf("chosen model %q is not in target %q's catalog — may not be available locally", + meta.ChosenModel.ID, target.ID), + }) + } + } + + // 4. Bundle metadata schema. + if meta.SchemaVersion != 1 { + diags = append(diags, Diagnostic{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("bundle.meta.json schemaVersion %d; expected 1", meta.SchemaVersion), + }) + } + + emitDiags(os.Stderr, diags) + if hasError(diags) { + os.Exit(exitDiagnostics) + } +} + +func modelOnTarget(t *Target, id string) bool { + for _, m := range t.SLMs { + if m.ID == id { + return true + } + } + return false +} + +// ----- minimal loaders for the build/ artifacts ----- + +func loadWorkflow(path string) (Workflow, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return Workflow{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "read agent.workflow.json: " + err.Error()}} + } + var wf Workflow + if err := json.Unmarshal(raw, &wf); err != nil { + return Workflow{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse agent.workflow.json: " + err.Error()}} + } + return wf, nil +} + +func loadMapping(path string) (DeploymentMapping, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, []Diagnostic{{Severity: "error", Category: "validate", Message: "read site.mapping.json: " + err.Error()}} + } + out := DeploymentMapping{} + if err := json.Unmarshal(raw, &out); err != nil { + return nil, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse site.mapping.json: " + err.Error()}} + } + return out, nil +} + +func loadResources(path string) (ExternalResources, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, []Diagnostic{{Severity: "error", Category: "validate", Message: "read site.resources.yaml: " + err.Error()}} + } + out := ExternalResources{} + if err := yaml.Unmarshal(raw, &out); err != nil { + return nil, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse site.resources.yaml: " + err.Error()}} + } + return out, nil +} + +func loadManifest(path string) (DeviceManifest, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return DeviceManifest{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "read device.manifest.json: " + err.Error()}} + } + var out DeviceManifest + if err := json.Unmarshal(raw, &out); err != nil { + return DeviceManifest{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse device.manifest.json: " + err.Error()}} + } + return out, nil +} + +func loadMetadata(path string) (BundleMetadata, []Diagnostic) { + raw, err := os.ReadFile(path) + if err != nil { + return BundleMetadata{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "read bundle.meta.json: " + err.Error()}} + } + var out BundleMetadata + if err := json.Unmarshal(raw, &out); err != nil { + return BundleMetadata{}, []Diagnostic{{Severity: "error", Category: "validate", Message: "parse bundle.meta.json: " + err.Error()}} + } + return out, nil +} From 0fb4ef99c8c8130abc5c6192e2eaf72fe2cd151e Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 00:27:26 +0200 Subject: [PATCH 02/28] feat(fh-agent): contract-schema validation via fh-builder subprocess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fh-agent validate now shells out to `fh-builder validate --json` to catch contract violations the Go-side cross-checks can't see (missing required params, wrong arg names, expression shape). Diagnostics merge into fh-agent's existing JSON array — same shape, same exit codes. Resilient: if fh-builder is not in PATH (e.g. on a customer device without Node), validate emits one warn diagnostic and continues. `--skip-workflow-check` silences it explicitly. Required TS-side change: - ts/app/cli/validate.ts: new `--json` flag emits a flat diagnostics array on stdout (was human text only). Same field names as fh-agent's Diagnostic. Plan fixes uncovered by the new check: - Ticker: emit intervalValue + intervalUnit, not legacy intervalMs - ReadPin/WritePin/OnPinEdge: use `pinReference`, not `channelReference` - agentTask edges: now carry the required `prompt` Expression - Expression literals: non-empty default per dataType ("0"/"false"/"") Example bundle (muellers-haus) now passes both Go cross-checks AND fh-builder contract validation end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- go/cmd/fh-agent/README.md | 23 +++++++-- go/cmd/fh-agent/plan.go | 100 +++++++++++++++++++++++++++--------- go/cmd/fh-agent/validate.go | 77 +++++++++++++++++++++++++-- ts/app/cli/fh-builder.mjs | 0 ts/app/cli/index.ts | 16 +++--- ts/app/cli/validate.ts | 71 +++++++++++++++++++++---- 6 files changed, 239 insertions(+), 48 deletions(-) mode change 100644 => 100755 ts/app/cli/fh-builder.mjs diff --git a/go/cmd/fh-agent/README.md b/go/cmd/fh-agent/README.md index a73eb24..b08d97a 100644 --- a/go/cmd/fh-agent/README.md +++ b/go/cmd/fh-agent/README.md @@ -122,11 +122,28 @@ $ fh-agent build build/ --name muellers-haus --tar - **Stable output**: same spec + target ⇒ byte-identical artifacts. Map keys sorted, node ids hashed from inputs, no timestamps. +## Contract-schema validation + +`fh-agent validate` shells out to `fh-builder validate --json` (the +TypeScript CLI in `ts/app/`) to check the generated workflow against +`contract/workflow.yaml`. The diagnostics merge into the same JSON array +fh-agent's own cross-checks emit. + +To enable, build workflow-core and link fh-builder once: + +```sh +cd ts +npm ci +npm run -w @foresthubai/workflow-core build +cd app && npm link +``` + +If `fh-builder` is not in PATH, `fh-agent validate` emits a single warn +diagnostic and continues — the tool stays usable offline. Pass +`--skip-workflow-check` to silence it. + ## v1 known limits (documented; not bugs) -- **Workflow schema** is structurally compiled but **not** validated - against `contract/workflow.yaml`. Run `fh-workflow validate` (from the - TypeScript CLI) for that. Plan is to call it as a subprocess in v1.1. - **Bus types** in spec: `mqtt`, `gpio`, `serial`. I2C, SPI, HTTP, and ctrlX Data-Layer adapters are deferred. - **No provisioning, no OTA, no HIL**. v1 stops at the bundle. v2 adds diff --git a/go/cmd/fh-agent/plan.go b/go/cmd/fh-agent/plan.go index da2221a..caf4c7a 100644 --- a/go/cmd/fh-agent/plan.go +++ b/go/cmd/fh-agent/plan.go @@ -273,9 +273,7 @@ func compile(spec *Spec, target *Target) (planResult, []Diagnostic) { "id": tickerID, "type": "Ticker", "position": map[string]any{"x": 0, "y": 120}, - "arguments": map[string]any{ - "intervalMs": parseEvery(spec.Agent.EvaluationEvery), - }, + "arguments": tickerArgs(spec.Agent.EvaluationEvery), }, map[string]any{ "id": agentID, @@ -307,33 +305,34 @@ func compile(spec *Spec, target *Target) (planResult, []Diagnostic) { chID := "ch-mqtt-" + sanitize(d.ID) if d.Kind == "sensor" { id := nodeID(spec.Name, "OnMqttMessage", d.ID) + dt := inferDataType(d) workflow.Nodes = append(workflow.Nodes, map[string]any{ "id": id, "type": "OnMqttMessage", "position": map[string]any{"x": 0, "y": y}, "arguments": map[string]any{ "channelReference": chID, - "dataType": inferDataType(d), + "dataType": dt, "output": map[string]any{"active": true, "mode": "emit", "name": d.ID}, }, }) workflow.Edges = append(workflow.Edges, controlEdge(id, agentID)) } else if d.Kind == "actuator" { id := nodeID(spec.Name, "MqttPublish", d.ID) + dt := inferDataType(d) workflow.Nodes = append(workflow.Nodes, map[string]any{ "id": id, "type": "MqttPublish", "position": map[string]any{"x": 600, "y": y}, "arguments": map[string]any{ "channelReference": chID, - "dataType": inferDataType(d), - "value": map[string]any{"kind": "literal", "value": ""}, + "dataType": dt, + "value": literalExpr("", dt), "qos": 0, "retain": false, - "toolDescription": fmt.Sprintf("Write to actuator %q (%s in zone %s)", d.ID, d.Controls, d.Zone), }, }) - workflow.Edges = append(workflow.Edges, agentTaskEdge(agentID, id)) + workflow.Edges = append(workflow.Edges, agentToolEdge(agentID, id, fmt.Sprintf("Write to actuator %q (%s in zone %s)", d.ID, d.Controls, d.Zone))) } case "gpio": chID := "ch-gpio-" + sanitize(d.ID) @@ -344,9 +343,8 @@ func compile(spec *Spec, target *Target) (planResult, []Diagnostic) { "type": "OnPinEdge", "position": map[string]any{"x": 0, "y": y}, "arguments": map[string]any{ - "channelReference": chID, - "edge": "both", - "output": map[string]any{"active": true, "mode": "emit", "name": d.ID}, + "pinReference": chID, + "edge": "both", }, }) workflow.Edges = append(workflow.Edges, controlEdge(id, agentID)) @@ -357,13 +355,12 @@ func compile(spec *Spec, target *Target) (planResult, []Diagnostic) { "type": "WritePin", "position": map[string]any{"x": 600, "y": y}, "arguments": map[string]any{ - "channelReference": chID, - "signalType": "digital", - "value": map[string]any{"kind": "literal", "value": 0}, - "toolDescription": fmt.Sprintf("Drive GPIO actuator %q (%s)", d.ID, d.Controls), + "pinReference": chID, + "signalType": "digital", + "value": literalExpr(false, "bool"), }, }) - workflow.Edges = append(workflow.Edges, agentTaskEdge(agentID, id)) + workflow.Edges = append(workflow.Edges, agentToolEdge(agentID, id, fmt.Sprintf("Drive GPIO actuator %q (%s)", d.ID, d.Controls))) } case "serial": chID := "ch-uart-" + sanitize(d.ID) @@ -387,10 +384,10 @@ func compile(spec *Spec, target *Target) (planResult, []Diagnostic) { "position": map[string]any{"x": 600, "y": y}, "arguments": map[string]any{ "portReference": chID, - "value": map[string]any{"kind": "literal", "value": ""}, + "value": literalExpr("", "string"), }, }) - workflow.Edges = append(workflow.Edges, agentTaskEdge(agentID, id)) + workflow.Edges = append(workflow.Edges, agentToolEdge(agentID, id, fmt.Sprintf("Write to serial actuator %q", d.ID))) } } } @@ -524,13 +521,70 @@ func controlEdge(from, to string) map[string]any { } } -func agentTaskEdge(from, to string) map[string]any { +// agentToolEdge wires a tool node onto the agent. The contract requires +// every agentTask edge to carry a `prompt` Expression — that is the +// LLM-readable description of what the tool does. +func agentToolEdge(from, to, description string) map[string]any { return map[string]any{ - "id": "e-tool-" + shortHash(from+"->"+to), - "type": "agentTask", - "from": map[string]any{"nodeId": from, "port": "tool"}, - "to": map[string]any{"nodeId": to, "port": "ctrl"}, + "id": "e-tool-" + shortHash(from+"->"+to), + "type": "agentTask", + "from": map[string]any{"nodeId": from, "port": "tool"}, + "to": map[string]any{"nodeId": to, "port": "ctrl"}, + "prompt": literalExpr(description, "string"), + } +} + +// literalExpr builds a workflow.Expression with no variable references — +// a constant value the agent fills in at tool-call time (or that the engine +// uses as a default). +func literalExpr(value any, dataType string) map[string]any { + expr := fmt.Sprintf("%v", value) + if expr == "" { + switch dataType { + case "float", "int": + expr = "0" + case "bool": + expr = "false" + default: + expr = `""` + } + } + return map[string]any{ + "expression": expr, + "references": []any{}, + "dataType": dataType, + } +} + +// tickerArgs splits "60s" / "5m" / "100ms" / "1h" into intervalValue + +// intervalUnit as the contract requires. Falls back to 60 seconds. +func tickerArgs(s string) map[string]any { + if s == "" { + return map[string]any{"intervalValue": 60, "intervalUnit": "seconds"} + } + unit := "seconds" + body := s + switch { + case strings.HasSuffix(s, "ms"): + unit, body = "milliseconds", strings.TrimSuffix(s, "ms") + case strings.HasSuffix(s, "s"): + unit, body = "seconds", strings.TrimSuffix(s, "s") + case strings.HasSuffix(s, "m"): + unit, body = "minutes", strings.TrimSuffix(s, "m") + case strings.HasSuffix(s, "h"): + unit, body = "hours", strings.TrimSuffix(s, "h") + } + n := 0 + for _, c := range body { + if c < '0' || c > '9' { + return map[string]any{"intervalValue": 60, "intervalUnit": "seconds"} + } + n = n*10 + int(c-'0') + } + if n == 0 { + n = 60 } + return map[string]any{"intervalValue": n, "intervalUnit": unit} } // nodeID derives a stable id from spec name + node type + discriminator. diff --git a/go/cmd/fh-agent/validate.go b/go/cmd/fh-agent/validate.go index fd91175..07b1ccd 100644 --- a/go/cmd/fh-agent/validate.go +++ b/go/cmd/fh-agent/validate.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "os" + "os/exec" "path/filepath" "gopkg.in/yaml.v3" @@ -17,13 +18,11 @@ import ( // - every mapping ref resolves to either an external resource or a manifest entry // - chosen model exists in local-models.yaml and fits target RAM // - bundle metadata schema version is current -// -// What this does NOT check: -// - workflow JSON against the OpenAPI contract — that is the job of the -// TypeScript `fh-workflow validate` CLI (or the engine on /deploy). -// Run it separately for v1. +// - contract-schema validation via `fh-builder validate --json` +// (subprocess; warn-skipped if fh-builder is not installed) func runValidate(args []string) { fs := flag.NewFlagSet("validate", flag.ExitOnError) + skipWorkflowCheck := fs.Bool("skip-workflow-check", false, "skip fh-builder contract-schema validation") _ = fs.Bool("json", true, "emit JSON diagnostics") pos := parseMixed(fs, args) if len(pos) < 1 { @@ -124,12 +123,80 @@ func runValidate(args []string) { }) } + // 5. Contract-schema validation via fh-builder. Subprocess; warn-skipped + // if fh-builder is not installed so the tool stays usable offline. + if !*skipWorkflowCheck { + diags = append(diags, runWorkflowSchemaCheck(filepath.Join(dir, "agent.workflow.json"))...) + } + emitDiags(os.Stderr, diags) if hasError(diags) { os.Exit(exitDiagnostics) } } +// runWorkflowSchemaCheck shells out to `fh-builder validate --json` +// (the TS-side contract validator). Returns: +// - the validator's diagnostics on success +// - one warn-diagnostic if fh-builder is not in PATH (so the tool keeps +// working in environments without Node) +// - an error-diagnostic if subprocess failed for an unexpected reason +func runWorkflowSchemaCheck(workflowPath string) []Diagnostic { + bin, err := exec.LookPath("fh-builder") + if err != nil { + return []Diagnostic{{ + Severity: "warn", Category: "validate", + Message: "fh-builder not in PATH — skipping contract-schema validation; install via `npm i -g @foresthubai/app` or pass --skip-workflow-check", + Location: workflowPath, + }} + } + cmd := exec.Command(bin, "validate", workflowPath, "--json") + stdout, err := cmd.Output() + if err != nil { + // Exit 1 means the validator found errors; output is still valid JSON. + if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { + return parseSchemaDiags(stdout, workflowPath) + } + return []Diagnostic{{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("fh-builder validate failed unexpectedly: %v", err), + }} + } + return parseSchemaDiags(stdout, workflowPath) +} + +func parseSchemaDiags(stdout []byte, workflowPath string) []Diagnostic { + if len(stdout) == 0 { + return nil + } + var raw []map[string]any + if err := json.Unmarshal(stdout, &raw); err != nil { + return []Diagnostic{{ + Severity: "error", Category: "validate", + Message: fmt.Sprintf("fh-builder JSON parse error: %v", err), + }} + } + out := make([]Diagnostic, 0, len(raw)) + for _, r := range raw { + d := Diagnostic{ + Severity: strOf(r["severity"]), + Category: strOf(r["category"]), + Message: strOf(r["message"]), + Location: workflowPath + "#" + strOf(r["location"]), + NodeID: strOf(r["nodeId"]), + } + out = append(out, d) + } + return out +} + +func strOf(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + func modelOnTarget(t *Target, id string) bool { for _, m := range t.SLMs { if m.ID == id { diff --git a/ts/app/cli/fh-builder.mjs b/ts/app/cli/fh-builder.mjs old mode 100644 new mode 100755 diff --git a/ts/app/cli/index.ts b/ts/app/cli/index.ts index fbb5022..65c6a4b 100644 --- a/ts/app/cli/index.ts +++ b/ts/app/cli/index.ts @@ -3,23 +3,27 @@ import { updateCommand } from "./update"; import { validateCommand } from "./validate"; const USAGE = `Usage: - fh-builder open [file.json] Open the workflow builder; optionally pre-load a workflow. - fh-builder validate Validate a workflow JSON file. Exits non-zero on errors. - fh-builder update [out] Migrate a workflow to the current schema version. + fh-builder open [file.json] Open the workflow builder; optionally pre-load a workflow. + fh-builder validate [--json] Validate a workflow JSON file. --json emits a machine-readable diagnostics array. Exits non-zero on errors. + fh-builder update [out] Migrate a workflow to the current schema version. `; const [, , command, ...args] = process.argv; +// Strip recognised flags so positional args keep their indexes. +const jsonFlag = args.includes("--json"); +const positional = args.filter((a) => a !== "--json"); + try { switch (command) { case "open": - await openCommand(args[0]); + await openCommand(positional[0]); break; case "validate": - await validateCommand(args[0]); + await validateCommand(positional[0], jsonFlag); break; case "update": - await updateCommand(args[0], args[1]); + await updateCommand(positional[0], positional[1]); break; case undefined: case "--help": diff --git a/ts/app/cli/validate.ts b/ts/app/cli/validate.ts index f4e4232..cba77fe 100644 --- a/ts/app/cli/validate.ts +++ b/ts/app/cli/validate.ts @@ -5,15 +5,23 @@ import type { ValidationResult, Diagnostic } from "@foresthubai/workflow-core/di import type { ApiWorkflow } from "@foresthubai/workflow-core/workflow"; /** - * `fh-builder validate ` + * `fh-builder validate [--json]` * * Reads a workflow snapshot, deserializes it to the in-memory shape, runs * the headless validator, and prints a report. Exits with code 1 if any * errors were found, 0 otherwise. + * + * `--json` emits a flat diagnostics array on stdout for machine consumption + * (used by `fh-agent validate` to merge contract-schema findings into its + * own report). */ -export async function validateCommand(filePath?: string): Promise { +export async function validateCommand(filePath?: string, jsonOutput = false): Promise { if (!filePath) { - process.stderr.write("Usage: fh-builder validate \n"); + if (jsonOutput) { + process.stdout.write(JSON.stringify([{ severity: "error", category: "usage", message: "missing " }])); + } else { + process.stderr.write("Usage: fh-builder validate [--json]\n"); + } process.exit(1); } @@ -24,8 +32,7 @@ export async function validateCommand(filePath?: string): Promise { } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { - process.stderr.write(`File not found: ${abs}\n`); - process.exit(1); + emitFatal(jsonOutput, `File not found: ${abs}`); } throw err; } @@ -34,25 +41,67 @@ export async function validateCommand(filePath?: string): Promise { try { parsed = JSON.parse(raw); } catch (err) { - process.stderr.write(`Invalid JSON: ${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); + emitFatal(jsonOutput, `Invalid JSON: ${err instanceof Error ? err.message : String(err)}`); } let workflow: ApiWorkflow; try { workflow = migrate(parsed); } catch (err) { - process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); - process.exit(1); + emitFatal(jsonOutput, err instanceof Error ? err.message : String(err)); } - const result = validateWorkflow(workflow); + const result = validateWorkflow(workflow!); - printReport(abs, result); + if (jsonOutput) { + process.stdout.write(JSON.stringify(flattenDiagnostics(result), null, 2) + "\n"); + } else { + printReport(abs, result); + } if (result.totalErrors > 0) process.exit(1); } +/** + * Flattens the nested ValidationResult into a single array of diagnostics — + * the shape `fh-agent validate` expects, identical to its own diagnostic + * type. Canvas/channel/memory scope is folded into the `location` string. + */ +function flattenDiagnostics(result: ValidationResult): Array> { + const out: Array> = []; + for (const canvas of result.canvases) { + for (const d of canvas.diagnostics) out.push(toFlat(d, `canvas[${canvas.canvasId}]`)); + } + for (const d of result.channelDiagnostics) out.push(toFlat(d, "channels")); + for (const d of result.memoryDiagnostics) out.push(toFlat(d, "memory")); + return out; +} + +function toFlat(d: Diagnostic, scope: string): Record { + const where: string[] = []; + if (d.nodeId) where.push(`node:${d.nodeId}`); + if (d.edgeId) where.push(`edge:${d.edgeId}`); + if (d.channelId) where.push(`channel:${d.channelId}`); + if (d.paramId) where.push(`param:${d.paramId}`); + if (d.outputId) where.push(`output:${d.outputId}`); + return { + severity: d.severity, + category: `workflow:${d.category}`, + message: d.message, + location: where.length > 0 ? `${scope}/${where.join(",")}` : scope, + ...(d.nodeId ? { nodeId: d.nodeId } : {}), + }; +} + +function emitFatal(jsonOutput: boolean, msg: string): never { + if (jsonOutput) { + process.stdout.write(JSON.stringify([{ severity: "error", category: "workflow:io", message: msg }]) + "\n"); + } else { + process.stderr.write(msg + "\n"); + } + process.exit(1); +} + function printReport(file: string, result: ValidationResult): void { const out = process.stdout; From f29f5d25560bf07075ecaeb3d46f95aa5895be64 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 00:57:08 +0200 Subject: [PATCH 03/28] docs(fh-agent): add ROADMAP.md alongside the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped roadmap for go/cmd/fh-agent/ — principles, shipped versions (v1.0 + v1.1), v1.x hardening backlog ordered by value/effort, v2 phase candidates, and explicit out-of-scope items. Co-Authored-By: Claude Opus 4.7 (1M context) --- go/cmd/fh-agent/ROADMAP.md | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 go/cmd/fh-agent/ROADMAP.md diff --git a/go/cmd/fh-agent/ROADMAP.md b/go/cmd/fh-agent/ROADMAP.md new file mode 100644 index 0000000..10a2086 --- /dev/null +++ b/go/cmd/fh-agent/ROADMAP.md @@ -0,0 +1,76 @@ +# fh-agent roadmap + +The plan for the CLI that turns a `site.spec.yaml` into a deployable +edge-agent bundle. Owned by this directory; updated when scope changes. + +## Principles + +- **Compiler model, not orchestrator.** Source = spec + target; output = + bundle. The CLI does not run the agent or hold state. +- **Deterministic.** Same inputs ⇒ byte-identical outputs. Agents diff + outputs across runs to detect progress. +- **JSON-first.** Every diagnostic and listing is machine-readable. +- **No interactive prompts.** Agents drive this thing. +- **Resilient.** Optional tools (fh-builder, docker) degrade to a warn — + the binary stays usable on a customer device with nothing but glibc. + +## Shipped + +### v1.0 — `site.spec.yaml` → deployable bundle (commit `b4a3150`) + +- Four-phase pipeline: `spec` / `plan` / `validate` / `build` +- 5 embedded hardware target profiles + (`rpi5-8gb`, `jetson-orin-nano-8gb`, `x86-nuc-16gb`, `stm32mp25-1gb`, + `ctrlx-core-arm64`) +- Spec schema + JSON-Schema export for agent self-priming +- Deterministic plan compile (hashed node ids, sorted keys) +- Model auto-selection (capability ∩ RAM fit) per target +- Compose-stack bundle: official engine image + llama-server sidecar + + optional mosquitto + configs + auto-README + `.env.example` + + optional `.tar.gz` +- Cross-validation: channels ↔ mapping ↔ resources ↔ manifest +- Go-side tests: determinism on all 5 targets, RAM-fit, mangle-catch + +### v1.1 — Contract-schema validation (commit `0fb4ef9`) + +- `fh-builder validate --json` (TS) emits machine-readable diagnostics +- Subprocess integration in `fh-agent validate`; warn-fallback if + `fh-builder` is missing +- Plan fixes uncovered by the new check (Ticker units, pinReference, + agentTask edge `prompt`, expression literal defaults) + +## Open — v1.x (hardening, small surface) + +Ordered by ratio of (value / effort). Pick from the top. + +| | Why it matters | Sketch | +| --- | --- | --- | +| **Real-device smoke test on a Pi5** | Every v1 guarantee is theoretical until one bundle has actually booted on real hardware. | Take the muellers-haus example, scp, `docker compose up`, attach a fake MQTT publisher, watch the agent loop. | +| **CI: `go test ./cmd/fh-agent/...`** | Determinism + RAM-fit + validate-catch already exist as tests; just wire them into `.github/workflows/`. | Add a step to the existing CI workflow file. | +| **Claude skill wrapper in `skills/agent-build/`** | Makes the CLI discoverable to Claude Code (mirrors how `workflow-validate` wraps `fh-workflow validate`). | One `SKILL.md`, points at `fh-agent plan / validate / build`. | +| **`fh-agent doctor`** | Pre-flight check on the bundle host: engine reachable, llm endpoint up, GPIO permissions, model file present. Saves support roundtrips. | New subcommand; reuses `loadMetadata` + a few `http.Get` / `os.Stat`. | +| **Model-download helper** | Today `models/` ships empty and the README points at Hugging Face manually. Either an init-container in the compose stack, or `fh-agent build --pull-model`. | Curated GGUF URL table per target catalog; SHA verification. | +| **Spec-schema JSON publishable** | The schema export is the primary primer for an external Claude agent. Ship it as a versioned URL (e.g. `https://schemas.foresthub.ai/site.spec.v1.json`) so prompts can reference it stably. | Wire `fh-agent spec schema` into a build artifact, host on cloudflare. | + +## Open — v2 (new phases, bigger scope) + +| Phase | What it unlocks | +| --- | --- | +| **`fh-agent deploy --to pi@host`** | Closes the manual gap between bundle and running agent. SSH/scp or Balena, then `docker compose up` remotely. Bring-your-own-key auth. | +| **HIL test harness** | `scenarios.test.yaml` + a runner that publishes synthetic MQTT events, waits, asserts on actuator topics. Safety-critical for buildings (heating must not switch off). The skeleton file already ships in v1 bundles. | +| **Bus-type expansion: I2C / SPI / HTTP / ctrlX Data-Layer** | Unlocks STM32MP25 ADC/DAC and full ctrlX CORE use-cases. Requires matching channel types in `contract/workflow.yaml`. | +| **Constraint enforcement** | Today, `constraints[]` flow into the agent prompt as text. v2 generates structural guards (`If` nodes with min/max) in front of each `WritePin` / `MqttPublish` so the agent *can't* violate them. | +| **Diff-deploy / OTA** | Spec edit ⇒ only the delta is pushed to the engine. Needs `fh-backend` and engine versioning protocol. | +| **Vision + function-call capabilities** | Real tool schemas (not just descriptions), vision-language models for camera-fed sensors. New target capability flags. | +| **More targets** | Variscite VAR-SOM, Toradex Verdin, Siemens IPC, BeagleY-AI. Each is a single YAML in `targets/`. | + +## Out of scope + +Intentionally not in this CLI — belongs elsewhere: + +- **Workflow editing UI** — that is `fh-builder open`. +- **Multi-tenant control plane** — `fh-backend`, closed source. +- **Agent runtime tracing** — engine emits structured logs; consume them + from `fh-backend` or a Grafana dashboard, not from this CLI. +- **Natural-language → spec generation** — that is the agent's job. The + CLI compiles what the agent produced. From d3134cc3cf236d716fc8228e3c5ca9f8088c4637 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:24:32 +0200 Subject: [PATCH 04/28] chore(llmproxy): remove commented-out resilience.go stub The entire file was commented out with only the package declaration active. No code referenced RetryWithResilienceContext anywhere in the tree. Drop the dead stub. Co-Authored-By: Claude Opus 4.7 (1M context) --- go/llmproxy/resilience.go | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 go/llmproxy/resilience.go diff --git a/go/llmproxy/resilience.go b/go/llmproxy/resilience.go deleted file mode 100644 index 2197c05..0000000 --- a/go/llmproxy/resilience.go +++ /dev/null @@ -1,23 +0,0 @@ -package llmproxy - -// RetryWithResilienceContext retries the given operation according to the provided resilience configuration. -// func RetryWithResilienceContext(ctx context.Context, cfg config.ResilienceConfig, operation func(ctx context.Context) error) error { -// var err error -// delay := cfg.InitialDelay - -// for i := 0; i < cfg.MaxRetries; i++ { -// err = operation(ctx) -// if err == nil { -// return nil -// } - -// if cfg.Jitter { -// delay = time.Duration(float64(delay) * (1 + rand.Float64()*0.5)) -// } - -// time.Sleep(delay) -// delay = min(time.Duration(float64(delay)*cfg.Multiplier), cfg.MaxDelay) -// } - -// return err -// } From a00e79523d6fcd689fac4a4f7a6171f5bf3e9062 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:24:45 +0200 Subject: [PATCH 05/28] chore(deps): add dependabot config for go, npm, actions Configure weekly Dependabot updates for the Go module under /go, the npm workspace under /ts, and GitHub Actions workflows. Minor and patch updates are grouped per ecosystem to reduce PR noise. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/dependabot.yml | 70 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c935f11 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,70 @@ +version: 2 + +updates: + # Go module (root of Go code is /go) + - package-ecosystem: "gomod" + directory: "/go" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + groups: + go-minor-and-patch: + applies-to: version-updates + update-types: + - "minor" + - "patch" + go-security: + applies-to: security-updates + update-types: + - "minor" + - "patch" + + # npm workspace root (handles workflow-core, workflow-builder, app) + - package-ecosystem: "npm" + directory: "/ts" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore" + prefix-development: "chore" + include: "scope" + groups: + npm-minor-and-patch: + applies-to: version-updates + update-types: + - "minor" + - "patch" + npm-security: + applies-to: security-updates + update-types: + - "minor" + - "patch" + + # GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore" + include: "scope" + groups: + actions-minor-and-patch: + applies-to: version-updates + update-types: + - "minor" + - "patch" + actions-security: + applies-to: security-updates + update-types: + - "minor" + - "patch" From 29604af8bd223ca8d8d2a589984693219f34fc7f Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:24:54 +0200 Subject: [PATCH 06/28] chore: add FUNDING.yml Enable GitHub Sponsors button for the ForestHubAI org. Other funding platforms are listed commented-out as a reference for later. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/FUNDING.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..cd7fe7d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,18 @@ +# GitHub Sponsors and other funding platforms for this repository. +# Reference: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository + +github: ForestHubAI + +# Additional platforms (uncomment to enable later): +# patreon: # Replace with a single Patreon username +# open_collective: # Replace with a single Open Collective username +# ko_fi: # Replace with a single Ko-fi username +# tidelift: # Replace with a single Tidelift platform-name/package-name +# community_bridge: # Replace with a single Community Bridge project-name +# liberapay: # Replace with a single Liberapay username +# issuehunt: # Replace with a single IssueHunt username +# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name +# polar: # Replace with a single Polar username +# buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +# thanks_dev: # Replace with a single thanks.dev username +# custom: # Replace with up to 4 custom sponsorship URLs From da1bd3b8cdc8248171a7074e8e8579a2e0891fbc Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:25:00 +0200 Subject: [PATCH 07/28] chore: add CODEOWNERS for routing reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route reviews per subtree to @ForestHubAI/maintainers: - /go/, /ts/ — language subtrees - /contract/ — critical: drift between go/ts implementations - /.github/ — CI, workflows, repo meta - * — default fallback Note: the @ForestHubAI/maintainers team must be created in GitHub org settings (Org Settings → Teams) for the mentions to resolve. Until then GitHub will show "Unknown owner" warnings on the branch-protection UI but rules silently no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/CODEOWNERS | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..eed99f7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,31 @@ +# CODEOWNERS — Review routing for edge-agents +# +# Syntax: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# Order matters: the LAST matching pattern wins for a given file. +# +# NOTE: The @ForestHubAI/maintainers team must exist in the GitHub org +# (Org Settings → Teams) for these mentions to resolve. Without the team, +# GitHub will show "Unknown owner" warnings on the Branch protection / +# PR review UI but CODEOWNERS routing will silently no-op. + +# --------------------------------------------------------------------------- +# Default owner — applies to anything not covered by a more specific rule below +# --------------------------------------------------------------------------- +* @ForestHubAI/maintainers + +# --------------------------------------------------------------------------- +# Language subtrees +# --------------------------------------------------------------------------- +/go/ @ForestHubAI/maintainers +/ts/ @ForestHubAI/maintainers + +# --------------------------------------------------------------------------- +# Contract subtree — CRITICAL: drift between go/ and ts/ implementations +# of the protobuf/JSON-Schema contracts must always be reviewed. +# --------------------------------------------------------------------------- +/contract/ @ForestHubAI/maintainers + +# --------------------------------------------------------------------------- +# Repo meta: CI workflows, issue templates, CODEOWNERS itself +# --------------------------------------------------------------------------- +/.github/ @ForestHubAI/maintainers From fb9ad6664336d8f1b70be6efcf3db4a95f76e6db Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:25:09 +0200 Subject: [PATCH 08/28] chore: add .editorconfig Pin per-language indentation and whitespace defaults so editors without project-aware tooling agree with gofmt, Prettier, and YAML conventions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .editorconfig | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f543bcf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,36 @@ +# EditorConfig — https://editorconfig.org +# Top-level config; no parent lookups beyond this file. +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true + +# Go uses tabs (gofmt is non-negotiable). +[*.go] +indent_style = tab + +# TS / JS / JSON — 2-space (matches Prettier + tsconfig conventions). +[*.{ts,tsx,js,jsx,mjs,cjs,json,jsonc}] +indent_style = space +indent_size = 2 + +# YAML — 2-space (contract/*.yaml, GitHub Actions). +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# Markdown — preserve trailing spaces (two-space line-break trick). +[*.md] +trim_trailing_whitespace = false + +# Makefiles require literal tabs. +[Makefile] +indent_style = tab + +[**/Makefile] +indent_style = tab From 464bd650a7afd406b7c82de807324232fe5ecbc0 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:25:35 +0200 Subject: [PATCH 09/28] fix(engine): use constant-time compare for bearer token The auth middleware compared the configured shared secret against the incoming Authorization header with `!=`, which short-circuits on the first differing byte. A network-adjacent attacker could in principle use response-time differences to learn the secret prefix-by-prefix. Switch to `crypto/subtle.ConstantTimeCompare` after a length check (length is not secret information, so an early length-mismatch return does not weaken the guarantee). Add table-style tests covering the empty-secret, missing-header, wrong-token and correct-token cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- go/cmd/engine/server.go | 11 ++++- go/cmd/engine/server_test.go | 81 ++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 go/cmd/engine/server_test.go diff --git a/go/cmd/engine/server.go b/go/cmd/engine/server.go index c42f5f2..e6e800a 100644 --- a/go/cmd/engine/server.go +++ b/go/cmd/engine/server.go @@ -2,6 +2,7 @@ package main import ( "context" + "crypto/subtle" "net/http" "github.com/ForestHubAI/edge-agents/go/api/engineapi" @@ -50,11 +51,17 @@ func (s *strictServer) Stop(_ context.Context, _ engineapi.StopRequestObject) (e // AuthMiddleware is a strict-handler middleware enforcing the shared agent // secret as a bearer token on every operation. An empty configured secret -// rejects all requests. +// rejects all requests. The token comparison uses crypto/subtle to avoid +// leaking the secret through response-time side channels. func AuthMiddleware(secret string) engineapi.StrictMiddlewareFunc { + want := []byte("Bearer " + secret) return func(f engineapi.StrictHandlerFunc, _ string) engineapi.StrictHandlerFunc { return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - if secret == "" || r.Header.Get("Authorization") != "Bearer "+secret { + got := []byte(r.Header.Get("Authorization")) + // Length differs => unauthorized. Length is not secret, so an + // early return here does not weaken the constant-time check; + // ConstantTimeCompare itself rejects unequal-length inputs. + if secret == "" || len(got) != len(want) || subtle.ConstantTimeCompare(got, want) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return nil, nil } diff --git a/go/cmd/engine/server_test.go b/go/cmd/engine/server_test.go new file mode 100644 index 0000000..8c51aee --- /dev/null +++ b/go/cmd/engine/server_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ForestHubAI/edge-agents/go/api/engineapi" + "github.com/stretchr/testify/assert" +) + +// okHandler is a no-op StrictHandlerFunc used to detect whether the +// AuthMiddleware passed the request through (handler called) or rejected it +// (handler not called, 401 written by the middleware). +func okHandler(called *bool) engineapi.StrictHandlerFunc { + return func(_ context.Context, w http.ResponseWriter, _ *http.Request, _ interface{}) (interface{}, error) { + *called = true + w.WriteHeader(http.StatusOK) + return nil, nil + } +} + +func TestAuthMiddleware_RejectsBadBearer(t *testing.T) { + mw := AuthMiddleware("s3cret") + called := false + handler := mw(okHandler(&called), "Deploy") + + req := httptest.NewRequest(http.MethodPost, "/deploy", nil) + req.Header.Set("Authorization", "Bearer wrong") + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, called, "downstream handler must not run on bad bearer") +} + +func TestAuthMiddleware_RejectsMissingHeader(t *testing.T) { + mw := AuthMiddleware("s3cret") + called := false + handler := mw(okHandler(&called), "Deploy") + + req := httptest.NewRequest(http.MethodPost, "/deploy", nil) + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, called) +} + +func TestAuthMiddleware_RejectsEmptyConfiguredSecret(t *testing.T) { + mw := AuthMiddleware("") + called := false + handler := mw(okHandler(&called), "Deploy") + + req := httptest.NewRequest(http.MethodPost, "/deploy", nil) + req.Header.Set("Authorization", "Bearer ") + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.False(t, called) +} + +func TestAuthMiddleware_AcceptsCorrectBearer(t *testing.T) { + mw := AuthMiddleware("s3cret") + called := false + handler := mw(okHandler(&called), "Deploy") + + req := httptest.NewRequest(http.MethodPost, "/deploy", nil) + req.Header.Set("Authorization", "Bearer s3cret") + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} From dc24a2b11a5b8858609d5a04758c34da70183aa2 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:25:40 +0200 Subject: [PATCH 10/28] docs: add initial CHANGELOG.md with v1.0.1 backfill Keep-a-Changelog 1.1.0 format. Backfills go/v1.0.1 and ts/0.1.1 release lines from git history; opens an Unreleased section covering the fh-agent CLI, dependabot, CODEOWNERS, and README rewrite work landed since the tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..37394a8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,98 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This repository hosts multiple release lines (Go binaries under `go/`, npm +packages under `ts/`) and each line follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +independently. + +## [Unreleased] + +### Added +- `fh-agent` CLI: compile `site.spec.yaml` into deployable edge-agent bundles. +- `fh-agent` CLI: contract-schema validation via `fh-builder` subprocess. +- `fh-agent`: dedicated `ROADMAP.md` alongside the CLI. +- Dependabot configuration for Go modules, npm packages, and GitHub Actions. +- Repository governance: `CODEOWNERS` for review routing, `FUNDING.yml`. + +### Changed +- README rewritten as a category-defining, SEO/GEO-optimized engineering entry + point; SLM/Local-provider story surfaced; hero tagline, Features section, + ASCII diagram, and branded footer restored. + +### Removed +- Commented-out `resilience.go` stub from `llmproxy`. + +## [go/v1.0.1] - 2026-05-29 + +First tagged Go release after the `fh-core` → `edge-agents` repository rename. + +### Added +- AGPL license header, `NOTICE`, and third-party notices across the tree. +- Initial README and community health files (issue templates, contributing). +- `cmd-engine`: standalone mode (operates without backend) and multi-arch + Dockerfile. +- `cmd-engine`: CI workflow with image-tarball gitignore. +- Backend-routed LLM provider in `engine-backend`. +- `engine`: Lifecycle port (replaces `ControlPlane`); typed ports for LLM / + Memory / Retriever with contract regeneration. +- MQTT channel: topic support; `line`/`channel` promoted to binding fields. +- Go `cmd` package wired back into the builder UI flow. +- Working CLI for the engine. +- Tests: `llmproxy-provider` now reads Vertex AI config from env; coverage + expanded. + +### Changed +- Repository renamed from `fh-core` to `edge-agents`; package layout + flattened. +- `engine`: ticker and JSON-type helpers moved into the `mapping` package; + logging extracted into its own package (`Activity` helper dropped). +- `engine`: memory tests isolated from the backend adapter. +- Workflow YAML: deployment mapping removed. +- Mapping promoted to a dedicated package; `engineapi` dependency removed + from `engine`; mapping folded into `llmproxy`. + +### Fixed +- Lockstep / CI contract-drift check. +- npm lockfile regeneration with optional deps (works around npm bug). + +## [ts/0.1.1] - 2026-05-29 + +First documented release of the TypeScript workspace +(`@foresthub/workflow-core`, `@foresthub/workflow-builder`). + +### Added +- Visual workflow builder SPA (`workflow-builder`) with drag-and-drop canvas, + canvas tabs toolbar, and overhauled scrollbars. +- `workflow-core`: workflow schema migration infrastructure; builder + auto-migrates workflows on load. +- Node library: model declared as a resource alongside the catalog; unified + RAG and memory abstractions. +- Function configuration and diagnostics enhancements; function definition + scoped at editor level rather than per-canvas. +- Parameter system overhaul and serialization streamlining across nodes and + channels. +- Localization for toast messages and validation feedback. +- ESLint + Prettier setup across the TS workspace. +- TS release system (publish scripts under `ts/scripts/`). + +### Changed +- `workflow-core` refactored; mapping promoted to its own package. +- Unified UI colors and node-library design; unified name de-duplication and + selection model. +- `isDirty` tracking now driven by `mutationCount` in history. + +### Fixed +- `Parameter.ts` casing corrected to match imports. +- Leaked React dependency in `workflow-core` removed; assorted code-smell + cleanup, type errors, and tests referring to old nodes. + +## [0.0.0] - 2026-05-19 + +Initial commit. Internal prototype prior to the first tagged release. + +[Unreleased]: https://github.com/ForestHubAI/edge-agents/compare/go/v1.0.1...HEAD +[go/v1.0.1]: https://github.com/ForestHubAI/edge-agents/releases/tag/go/v1.0.1 +[ts/0.1.1]: https://github.com/ForestHubAI/edge-agents/tree/main/ts +[0.0.0]: https://github.com/ForestHubAI/edge-agents/commit/d9e6f9d From 13ba61958ce6da9c3f98db68747babd42f30f173 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:25:51 +0200 Subject: [PATCH 11/28] ci: add Spectral OpenAPI lint for contract/ Lints contract/*.yaml on PR + push to main with stoplightio/spectral-action. Baseline ruleset extends spectral:oas; first run surfaces 12 errors (mostly no-$ref-siblings + missing paths on llmproxy/workflow) and 31 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/spectral.yml | 31 +++++++++++++++++++++++++++++++ .spectral.yaml | 1 + 2 files changed, 32 insertions(+) create mode 100644 .github/workflows/spectral.yml create mode 100644 .spectral.yaml diff --git a/.github/workflows/spectral.yml b/.github/workflows/spectral.yml new file mode 100644 index 0000000..ef0ff8f --- /dev/null +++ b/.github/workflows/spectral.yml @@ -0,0 +1,31 @@ +name: Spectral + +on: + pull_request: + branches: [main] + paths: + - "contract/**" + - ".spectral.yaml" + - ".github/workflows/spectral.yml" + push: + branches: [main] + paths: + - "contract/**" + - ".spectral.yaml" + - ".github/workflows/spectral.yml" + +permissions: + contents: read + pull-requests: write + checks: write + +jobs: + spectral: + name: Lint OpenAPI contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: stoplightio/spectral-action@latest + with: + file_glob: "contract/*.yaml" + spectral_ruleset: .spectral.yaml diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..d47c47d --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1 @@ +extends: ["spectral:oas"] From 690718bf5fb59a78f957b9b9370e7921f84f2adc Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:26:20 +0200 Subject: [PATCH 12/28] fix(engine): expose /healthz without auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared-secret bearer middleware ran on every operation including Healthz, breaking the standard container-orchestrator pattern where a kubelet, Docker Compose healthcheck or ECS agent probes /healthz unauthenticated. The probe would 401 and the orchestrator would mark the engine unhealthy or refuse to route traffic to it. Short-circuit the middleware when the strict-handler operationID is "Healthz" so the probe reaches the handler without credentials. The response only reports "is the runner attached" — no workflow content or node state — so the exemption is safe to disclose. Other operations (Deploy, Stop) continue to require the bearer. Tests cover Healthz bypass with both a configured and an empty secret. Co-Authored-By: Claude Opus 4.7 (1M context) --- go/cmd/engine/server.go | 10 +++++++++- go/cmd/engine/server_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/go/cmd/engine/server.go b/go/cmd/engine/server.go index e6e800a..03e4cb0 100644 --- a/go/cmd/engine/server.go +++ b/go/cmd/engine/server.go @@ -53,9 +53,17 @@ func (s *strictServer) Stop(_ context.Context, _ engineapi.StopRequestObject) (e // secret as a bearer token on every operation. An empty configured secret // rejects all requests. The token comparison uses crypto/subtle to avoid // leaking the secret through response-time side channels. +// +// The Healthz operation is exempted so that container-orchestrator +// readiness/liveness probes (k8s, compose, ECS) can hit /healthz without +// being issued the shared secret. The handler discloses only "is the +// runner attached" — no workflow content, no node state. func AuthMiddleware(secret string) engineapi.StrictMiddlewareFunc { want := []byte("Bearer " + secret) - return func(f engineapi.StrictHandlerFunc, _ string) engineapi.StrictHandlerFunc { + return func(f engineapi.StrictHandlerFunc, operationID string) engineapi.StrictHandlerFunc { + if operationID == "Healthz" { + return f + } return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { got := []byte(r.Header.Get("Authorization")) // Length differs => unauthorized. Length is not secret, so an diff --git a/go/cmd/engine/server_test.go b/go/cmd/engine/server_test.go index 8c51aee..9ada035 100644 --- a/go/cmd/engine/server_test.go +++ b/go/cmd/engine/server_test.go @@ -65,6 +65,40 @@ func TestAuthMiddleware_RejectsEmptyConfiguredSecret(t *testing.T) { assert.False(t, called) } +func TestAuthMiddleware_HealthzBypassesAuth(t *testing.T) { + mw := AuthMiddleware("s3cret") + called := false + // Pass operationID="Healthz" — the middleware must let this through + // without checking the Authorization header so that container + // orchestrators can probe readiness without the shared secret. + handler := mw(okHandler(&called), "Healthz") + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + // Deliberately no Authorization header. + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called, "Healthz must reach the downstream handler unauthenticated") +} + +func TestAuthMiddleware_HealthzBypassesEvenWithEmptySecret(t *testing.T) { + // Empty secret normally rejects every request. Healthz must still be + // reachable so a misconfigured engine can be diagnosed via the probe. + mw := AuthMiddleware("") + called := false + handler := mw(okHandler(&called), "Healthz") + + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + rec := httptest.NewRecorder() + + _, err := handler(req.Context(), rec, req, nil) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.True(t, called) +} + func TestAuthMiddleware_AcceptsCorrectBearer(t *testing.T) { mw := AuthMiddleware("s3cret") called := false From 85bad59e9a7e16f8eb512cc0681f69796b17ad55 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:26:21 +0200 Subject: [PATCH 13/28] fix(httpclient): set default 30s timeout to avoid backend hangs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vendored backend httpclient used &http.Client{} with no timeout. Per-call context.WithTimeout is used on the boot/heartbeat paths (BootCallbackTimeout=10s, HeartbeatTimeout=5s, ProviderLoadTimeout=10s), but Engine.Deploy → memory.Restore → Snapshot inherits an uncapped ctx from context.WithCancel(Background) and would hang on an unreachable backend. RAG/LLM-chat call sites from runner nodes are likewise uncapped. A 30s defaultTimeout on the http.Client gives defense-in-depth without interfering with the tighter per-call timeouts. Co-Authored-By: Claude Opus 4.7 (1M context) --- go/engine/backend/internal/httpclient/httpclient.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/go/engine/backend/internal/httpclient/httpclient.go b/go/engine/backend/internal/httpclient/httpclient.go index 37081ea..4a48fff 100644 --- a/go/engine/backend/internal/httpclient/httpclient.go +++ b/go/engine/backend/internal/httpclient/httpclient.go @@ -13,8 +13,19 @@ import ( "net/http" "net/url" "strings" + "time" ) +// defaultTimeout is the per-request ceiling for the underlying http.Client. +// Callers are expected to pass their own context.WithTimeout for tighter +// per-call deadlines (e.g. BootCallbackTimeout=10s, HeartbeatTimeout=5s), +// but this default protects call sites that forget — most notably the deploy +// path (engine.Engine.Deploy → memory.Restore → Snapshot), which inherits an +// uncapped context and would otherwise hang the engine on an unreachable +// backend. 30s is loose enough not to interfere with the tighter per-call +// timeouts and long enough for a slow first-byte on a healthy backend. +const defaultTimeout = 30 * time.Second + // Client is a JSON-over-HTTP client that attaches a fixed auth header to // every request. type Client struct { @@ -31,7 +42,7 @@ func NewClient(baseURL, authHeader, authValue string) *Client { baseURL: strings.TrimRight(baseURL, "/"), authHeader: authHeader, authValue: authValue, - http: &http.Client{}, + http: &http.Client{Timeout: defaultTimeout}, } } From 4c71a9d542ae5ad32aec029badec39b1fe37f8e0 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:26:32 +0200 Subject: [PATCH 14/28] ci: add lint workflow (golangci-lint + eslint) Adds .github/workflows/lint.yml with two parallel jobs: - go-lint: golangci/golangci-lint-action@v6 against go/, Go version from go.mod - ts-lint: npm ci && npm run lint in ts/ (eslint flat config already present) Separate from ci.yml to keep lint-failures non-blocking for test runs and to isolate concerns. permissions: contents: read only. Also adds go/.golangci.yml with sensible defaults: - enabled: govet, errcheck, staticcheck, ineffassign, unused, gosimple, gosec - gosec noisy rules excluded (G104, G304, G404) - govet: enable-all minus fieldalignment + shadow - test files exempt from gosec/errcheck - generated go/api/ tree excluded Local verification: - ts lint runs clean (0 findings) via `npm run lint` - golangci-lint not installed locally; YAML syntax verified for both files - Go lint findings: unknown until first CI run (follow-up: fix or relax) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/lint.yml | 43 ++++++++++++++++++++++++++++++++++ go/.golangci.yml | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 .github/workflows/lint.yml create mode 100644 go/.golangci.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3303fc6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,43 @@ +name: Lint + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + go-lint: + name: Go (golangci-lint) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + cache-dependency-path: go/go.sum + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + working-directory: go + args: --timeout=5m + + ts-lint: + name: TS (eslint) + runs-on: ubuntu-latest + defaults: + run: + working-directory: ts + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: ts/package-lock.json + - run: npm ci + - run: npm run lint diff --git a/go/.golangci.yml b/go/.golangci.yml new file mode 100644 index 0000000..93d3df3 --- /dev/null +++ b/go/.golangci.yml @@ -0,0 +1,47 @@ +# golangci-lint config — sensible defaults for the edge-agents Go module. +# See https://golangci-lint.run/usage/configuration/ + +run: + timeout: 5m + # Generated files are skipped by linters that respect generation markers, + # but be explicit for the oapi-codegen output. + build-tags: [] + +issues: + exclude-dirs: + - api # generated oapi-codegen output (types.gen.go, server.gen.go) + exclude-rules: + # Test files often have legitimate uses of patterns gosec flags. + - path: _test\.go + linters: + - gosec + - errcheck + max-issues-per-linter: 0 + max-same-issues: 0 + +linters: + disable-all: true + enable: + - govet + - errcheck + - staticcheck + - ineffassign + - unused + - gosimple + - gosec + +linters-settings: + gosec: + excludes: + # Noisy / low-signal for this codebase: + - G104 # errors unhandled (errcheck covers this with better context) + - G304 # file path provided as taint input (legitimate config loading) + - G404 # weak random (we don't do crypto in engine paths) + errcheck: + check-type-assertions: false + check-blank: false + govet: + enable-all: true + disable: + - fieldalignment # too noisy for general code + - shadow # too noisy with err re-use From a6155993e760d697e1f6f4c61ad5376c3168fb07 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 02:27:44 +0200 Subject: [PATCH 15/28] chore: add issue-template chooser config Disables blank issues and routes off-topic intents (questions, security reports, commercial license inquiries) to Discussions, private security advisories, and mailto:root@foresthub.ai respectively. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/ISSUE_TEMPLATE/config.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..34fbc71 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,31 @@ +# Issue-template chooser config. +# Reference: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository +# +# Forces contributors to pick a structured template (bug_report / feature_request) +# instead of opening blank issues. The contact_links route off-topic intents +# (questions, security reports, commercial inquiries) to the right channel so +# the issue tracker stays focused on actionable engineering work. + +blank_issues_enabled: false + +contact_links: + - name: Question / Discussion + url: https://github.com/ForestHubAI/edge-agents/discussions + about: | + Have a question, an idea, or want to discuss usage? Please open a + GitHub Discussion instead of an issue. (If Discussions is not yet + enabled on this repo, this link will 404 until the maintainer turns + it on in Settings → Features.) + + - name: Security Vulnerability + url: https://github.com/ForestHubAI/edge-agents/security/advisories/new + about: | + Please do NOT open a public issue for security vulnerabilities. Use + GitHub's private vulnerability reporting, or email root@foresthub.ai. + See .github/SECURITY.md for scope and process. + + - name: Commercial License Inquiry + url: mailto:root@foresthub.ai + about: | + edge-agents is dual-licensed (AGPL-3.0-only + commercial). For use + cases that are incompatible with AGPL, contact root@foresthub.ai. From 72f61445e18d6f9ad660f133c26ca2682fffa1c4 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 09:43:24 +0200 Subject: [PATCH 16/28] chore(ts): switch workflow-core publish target to npmjs.org public registry Removes the GitHub Packages registry pin (which forced consumers to set up an auth token even for read access) and points the package at the public npm registry instead. Adds publishConfig.access=public so npm treats the scoped @foresthubai/* package as openly published on first publish. Tightens the files whitelist to dist + README + LICENSE; the previous list included src, which double-shipped TypeScript sources alongside the compiled dist/ output (npm pack now reports ~87 kB / 265 files for the tarball). Follow-up: no release workflow exists today, but when one is added it must not pin registry-url to https://npm.pkg.github.com. Co-Authored-By: Claude Opus 4.7 (1M context) --- ts/workflow-core/package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ts/workflow-core/package.json b/ts/workflow-core/package.json index f62ac76..3e5ef5e 100644 --- a/ts/workflow-core/package.json +++ b/ts/workflow-core/package.json @@ -78,10 +78,12 @@ }, "files": [ "dist", - "src" + "README.md", + "LICENSE" ], "publishConfig": { - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org", + "access": "public" }, "scripts": { "generate": "openapi-typescript ../../contract/workflow.yaml -o src/api/workflow.ts", From 0a378fabaffadd410fb54c6c2513272964907656 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 09:43:54 +0200 Subject: [PATCH 17/28] chore(ts): switch workflow-builder publish target to npmjs.org public registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the workflow-core switch: drops the GitHub Packages registry pin so consumers can install without an auth token, and sets access=public so the scoped package is published openly. Tightens the files whitelist: dist + tailwind-preset.ts + README + LICENSE plus src/styles/ (load-bearing — the exports map points "./styles/index.css" at "./src/styles/index.css", so consumers' CSS import would break if the whole src/ tree were excluded). The rest of src/ (TS sources) no longer ships. Tarball is now ~212 kB / 394 files. Co-Authored-By: Claude Opus 4.7 (1M context) --- ts/workflow-builder/package.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ts/workflow-builder/package.json b/ts/workflow-builder/package.json index 2202270..288d252 100644 --- a/ts/workflow-builder/package.json +++ b/ts/workflow-builder/package.json @@ -20,11 +20,14 @@ }, "files": [ "dist", - "src", - "tailwind-preset.ts" + "tailwind-preset.ts", + "src/styles", + "README.md", + "LICENSE" ], "publishConfig": { - "registry": "https://npm.pkg.github.com" + "registry": "https://registry.npmjs.org", + "access": "public" }, "scripts": { "build": "tsc -b", From 39f33f76d833d1f76a61c672ce531121bd92c033 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 09:44:03 +0200 Subject: [PATCH 18/28] docs(readme): rewrite hero with verified container size and real I/O nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hero now leads with a concrete, verified number (~32 MB compressed image, measured by cross-compiling cmd/engine for linux/arm64 and linux/amd64 with CGO_ENABLED=0 and -ldflags='-s -w' on top of distroless/static). Replaces the previous ~15 MB figure which was not reproducible from a fresh build. Drops OPC-UA from the pitch — it appears only as a ctrlX-OS pass-through note in a target manifest, not as an engine node (go/engine/driver and go/engine/transport implement GPIO/ADC/DAC/PWM, UART/serial, and MQTT). Pi Zero 2W is replaced by generic "Raspberry Pi" wording since no Pi Zero target is in cmd/fh-agent/targets. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1c129c7..80492f7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # edge-agents -**The open-source runtime for embedded and edge AI agents.** +**A ~32 MB AI agent runtime for edge devices.** Runs offline. GPIO, MQTT, and UART as first-class nodes. From a Raspberry Pi to industrial gateways. [![CI](https://github.com/ForestHubAI/edge-agents/actions/workflows/ci.yml/badge.svg)](https://github.com/ForestHubAI/edge-agents/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/ForestHubAI/edge-agents/go.svg)](https://pkg.go.dev/github.com/ForestHubAI/edge-agents/go) @@ -36,7 +36,7 @@ not in this repository. - **Contract-typed wire format** — every API generated from `contract/*.yaml` in both Go and TypeScript; CI fails on drift. - **Distroless multi-arch container** — `linux/amd64` and `linux/arm64`, - nonroot, ~15 MB. + nonroot, ~32 MB. ## Contents From 49ac9a54445b2ed7dc75237128715bf22c97eebc Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 09:44:26 +0200 Subject: [PATCH 19/28] ci: add multi-arch container release workflow with cosign signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes ghcr.io/foresthubai/edge-agents/engine on every `go/vX.Y.Z` tag — the same tag that releases the Go module — so the README's `docker run ...` quickstart actually resolves once the next tag is pushed. - linux/amd64 + linux/arm64 via buildx + QEMU (cross-compile in builder stage, only the final COPY into distroless is emulated) - docker/metadata-action strips the `go/` prefix so the image gets clean semver tags (1.2.3, 1.2, 1) plus `latest` - keyless cosign signing via GitHub OIDC, signs each tag-by-digest - SLSA build provenance + SBOM attestations pushed to the registry - workflow_dispatch with optional tag override for one-off rebuilds Triggered exclusively on `go/v*` tag-push and manual dispatch; no rolling build on main, to keep `:latest` aligned with a real release tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 116 ++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ff03631 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,116 @@ +name: Release container + +# Builds and publishes the multi-arch engine container to GHCR on every +# `go/vX.Y.Z` tag — the same tag that releases the Go module (see RELEASING.md). +# Image: ghcr.io/foresthubai/edge-agents/engine +# +# Triggered manually via workflow_dispatch for one-off rebuilds, but the +# canonical path is `git tag go/vX.Y.Z && git push origin go/vX.Y.Z`. + +on: + push: + tags: ['go/v*'] + workflow_dispatch: + inputs: + tag: + description: 'Override image tag (default: derived from ref / sha)' + required: false + type: string + +permissions: + contents: read + packages: write + id-token: write # cosign keyless OIDC + provenance attestations + attestations: write # actions/attest-* writes to the attestation store + +env: + REGISTRY: ghcr.io + IMAGE_NAME: foresthubai/edge-agents/engine + +jobs: + build-and-push: + name: Build, push, sign (linux/amd64,arm64) + runs-on: ubuntu-latest + outputs: + digest: ${{ steps.build.outputs.digest }} + tags: ${{ steps.meta.outputs.tags }} + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Strips the `go/` prefix from `refs/tags/go/v1.2.3` so the image gets + # `1.2.3`, `1.2`, `1`, and `latest` rather than literally `go-v1.2.3`. + - name: Compute image tags + labels + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=match,pattern=go/(v.*),group=1 + type=match,pattern=go/v(\d+\.\d+\.\d+),group=1 + type=match,pattern=go/v(\d+\.\d+)\.\d+,group=1 + type=match,pattern=go/v(\d+)\.\d+\.\d+,group=1 + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/go/v') }} + type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} + labels: | + org.opencontainers.image.title=ForestHub edge-agents engine + org.opencontainers.image.description=Workflow runtime for embedded and edge AI agents. + org.opencontainers.image.vendor=ForestHub + org.opencontainers.image.licenses=AGPL-3.0-only + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.url=https://github.com/${{ github.repository }} + org.opencontainers.image.documentation=https://github.com/${{ github.repository }}#readme + + - name: Build and push + id: build + uses: docker/build-push-action@v6 + with: + context: go + file: go/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true + + - name: Install cosign + uses: sigstore/cosign-installer@v3 + + # Keyless signing — uses the GitHub OIDC token, no long-lived secret. + # Signs by digest so every tag pointing at this build is covered. + - name: Sign image + env: + DIGEST: ${{ steps.build.outputs.digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + for tag in $TAGS; do + ref="${tag%:*}@${DIGEST}" + echo "cosign sign $ref" + cosign sign --yes "$ref" + done + + - name: Attest build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true From e87f4c4ac63b89d10332c0d0a9d46efaa1a4976a Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 09:44:27 +0200 Subject: [PATCH 20/28] docs(readme): add go-version, stars, last-commit, and issues badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four shields.io badges that work today: Go version (resolved from go/go.mod, currently 1.25), GitHub stars, last commit, and open issues. Each URL was probed (HTTP 200, real SVG content) before being added. Skipped: release badge (no GitHub releases published yet — only the go/v1.0.1 git tag exists), Docker Pulls (no public image), Discord (no server), Codecov (not wired). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 80492f7..110d062 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,11 @@ [![CI](https://github.com/ForestHubAI/edge-agents/actions/workflows/ci.yml/badge.svg)](https://github.com/ForestHubAI/edge-agents/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/ForestHubAI/edge-agents/go.svg)](https://pkg.go.dev/github.com/ForestHubAI/edge-agents/go) +[![Go Version](https://img.shields.io/github/go-mod/go-version/ForestHubAI/edge-agents?filename=go%2Fgo.mod)](go/go.mod) [![License: AGPL v3](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/ForestHubAI/edge-agents?style=flat)](https://github.com/ForestHubAI/edge-agents/stargazers) +[![GitHub last commit](https://img.shields.io/github/last-commit/ForestHubAI/edge-agents)](https://github.com/ForestHubAI/edge-agents/commits) +[![GitHub issues](https://img.shields.io/github/issues/ForestHubAI/edge-agents)](https://github.com/ForestHubAI/edge-agents/issues) `edge-agents` is the open-source core of the [ForestHub](https://foresthub.ai) platform. It contains the Go runtime engine, a multi-provider LLM proxy with From bcc49abc225147d63b8ac87f288161703218c292 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 09:44:27 +0200 Subject: [PATCH 21/28] docs(releasing): update npm publish instructions for npmjs.org Mirrors the publishConfig switch on both TS packages: replaces the GitHub Packages walkthrough (consumer .npmrc + FH_PACKAGES_TOKEN) with the npmjs.org flow (npm login on the publish machine, no consumer setup required). Drops the "going public" footnote since that is now the steady state. Co-Authored-By: Claude Opus 4.7 (1M context) --- RELEASING.md | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 3eeb47f..7fd5bcb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,8 +7,8 @@ never collide. | Artifact | Ecosystem | Version source | Consumer pins with | | ----------------------------------- | --------------------- | ------------------------- | ------------------------------------------- | | `github.com/ForestHubAI/edge-agents/go` | Go modules | **git tag** `go/vX.Y.Z` | `go get ...@vX.Y.Z` | -| `@foresthubai/workflow-core` | npm (GitHub Packages) | `version` in package.json | `npm i @foresthubai/workflow-core@X.Y.Z` | -| `@foresthubai/workflow-builder` | npm (GitHub Packages) | `version` in package.json | `npm i @foresthubai/workflow-builder@X.Y.Z` | +| `@foresthubai/workflow-core` | npm (npmjs.org) | `version` in package.json | `npm i @foresthubai/workflow-core@X.Y.Z` | +| `@foresthubai/workflow-builder` | npm (npmjs.org) | `version` in package.json | `npm i @foresthubai/workflow-builder@X.Y.Z` | Go and TS version on **independent cadences**. The two TS packages, however, are **locked to each other** (see below) — they always ship one shared version. @@ -39,33 +39,37 @@ npm run release -- 0.2.0 Each package's `prepublishOnly` rebuilds `dist/` first, so you never publish stale output. -### Registry: GitHub Packages +### Registry: npmjs.org (public) -Both packages carry `publishConfig.registry = https://npm.pkg.github.com`. +Both packages carry `publishConfig.registry = https://registry.npmjs.org` and +`publishConfig.access = public`, so the first `npm publish` ships the scoped +`@foresthubai/*` packages as openly installable on npmjs.org. No token or +`.npmrc` is required on the consumer side — `npm i @foresthubai/workflow-builder@X.Y.Z` +just works. -**To publish**, npm needs a GitHub token with `write:packages`, supplied via `~/.npmrc` -(never commit it): +**To publish**, log in once on the machine that runs the release: -``` -//npm.pkg.github.com/:_authToken=$[Token with write access] +```sh +npm login # interactive — uses the npmjs.org @foresthubai org account +npm whoami # sanity check ``` -**To consume** (e.g. the private FE repo), add `FH_PACKAGES_TOKEN` as env variable and add an `.npmrc` in consumers' root folder: +Then from `ts/`: +```sh +npm run release -- X.Y.Z ``` -@foresthubai:registry=https://npm.pkg.github.com -//npm.pkg.github.com/:_authToken=${FH_PACKAGES_TOKEN} -``` -then `npm i @foresthubai/workflow-builder@X.Y.Z` as normal. See -[`ts/workflow-builder/README.md`](ts/workflow-builder/README.md) for the Tailwind/styles -wiring the consumer must also do. +The release script invokes `npm publish --workspaces`, which honours each +package's `publishConfig` and skips the private `@foresthubai/app`. Two-factor +auth on the npm account is recommended; if enabled, `npm publish` will prompt +for the OTP. + +See [`ts/workflow-builder/README.md`](ts/workflow-builder/README.md) for the +Tailwind/styles wiring the consumer must also do. > **No automated changelog.** Put a one-line "what changed" in the release commit so the > FE maintainer (often future-you) can see why a pinned version moved. -> -> **Going public** keeps the `@foresthubai` scope — you'd own that org on npmjs.com and -> drop the GitHub Packages registry line; the package name does not change. ## Go module — manual tag From 6609ba008fdb15e0edaaeafd021631fd17bfa231 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 09:44:49 +0200 Subject: [PATCH 22/28] ci: add CodeQL security scan for Go and TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the GitHub Advanced Security CodeQL analyzer on push to main, on every PR to main, and on a weekly Sunday schedule (catches CVEs disclosed against already-merged code). Matrix covers go (autobuild via go/go.mod) and javascript-typescript (the modern combined identifier; supersedes the split javascript + typescript languages). security-and-quality query pack runs the security suite plus the quality rules — slightly noisier than security-only but worth it for a public repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/codeql.yml | 51 ++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3b4bac0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,51 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly on Sundays at 04:17 UTC. Off-peak, off the hour. + - cron: "17 4 * * 0" + +permissions: + contents: read + actions: read + security-events: write + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # CodeQL's modern "javascript-typescript" identifier covers ts/. + # "go" covers go/ — autobuild finds the module via go/go.mod. + language: [go, javascript-typescript] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + if: matrix.language == 'go' + uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + cache-dependency-path: go/go.sum + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" From b8e9bf6a92972e0efd323e26f4d619b630ee37a4 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 09:45:04 +0200 Subject: [PATCH 23/28] docs(readme): add Why edge-agents comparison section before Quickstart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New section frames edge-agents against three projects users frequently evaluate alongside it (LangGraph, n8n, Dify). Comparison axes are container size, offline mode, hardware I/O, MQTT, visual builder, local SLMs, and license. Container sizes are sourced: n8n ~368 MB and Dify ~900 MB from current Docker Hub tags; edge-agents ~32 MB from the same cross-compile + distroless/static measurement used in the hero. OpenClaw was considered but dropped — no authoritative size data and the project is not comparable in scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 110d062..91ac59e 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,28 @@ not in this repository. Go and TypeScript are independently buildable. Only `contract/` edits touch both sides. +## Why edge-agents + +Most AI agent frameworks assume datacenter GPUs and always-on cloud APIs. +`edge-agents` is built for the other end of the spectrum — embedded Linux on +industrial gateways, SBCs, and edge nodes. Here is how it compares to projects +people often evaluate alongside it. + +| | edge-agents | LangGraph | n8n | Dify | +|---|---|---|---|---| +| Container image (compressed) | **~32 MB** | n/a (Python library) | ~368 MB | ~900 MB | +| Runs offline / air-gapped | first-class (`ENGINE_STANDALONE=true`) | depends on host app | possible, manual telemetry-disable | not designed for it | +| Hardware I/O (GPIO, ADC/DAC/PWM, UART) | first-class engine nodes | no | only via shell-exec workarounds | no | +| MQTT transport | first-class | no | community node | no | +| Visual builder | yes (React Flow, embeddable) | no | yes | yes | +| Local SLMs (`llama.cpp`, `vLLM`, `Ollama`, …) | typed registry, capability routing | via custom code | community nodes | yes | +| License | AGPL-3.0 + commercial | MIT | Sustainable Use License | Apache-2.0 | + +Container sizes were measured against the latest public images on Docker Hub +in May 2026 (n8n: `n8nio/n8n:latest`, Dify: `langgenius/dify-api:main`); the +`edge-agents` figure is the linux/arm64 binary cross-compiled with +`CGO_ENABLED=0 -ldflags='-s -w'` on top of `gcr.io/distroless/static-debian12`. + ## Quickstart ### Engine (Docker) From 2098a527f318293f954a0a725f30cc307701f06f Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 09:45:07 +0200 Subject: [PATCH 24/28] ci: add gitleaks secret scan workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs gitleaks-action on every push and PR, with fetch-depth: 0 so the scan covers full git history (not just the latest diff) — historic leaks block the merge just like fresh ones. No GITLEAKS_LICENSE configured: the paid tier is unnecessary for a public repo. If the default ruleset throws false positives on third-party notices, generated code, or vendored snippets we'll add a .gitleaks.toml allowlist in a follow-up commit — the first real CI run on this branch will surface what (if anything) needs ignoring. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/gitleaks.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/gitleaks.yml diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..fa30d1e --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,29 @@ +name: gitleaks + +on: + push: + pull_request: + +permissions: + contents: read + pull-requests: write + +jobs: + scan: + name: Scan for leaked secrets + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + # gitleaks needs the full history to scan past commits, not just + # the diff. Shallow clone would miss anything before the last fetch. + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # No GITLEAKS_LICENSE on purpose: it's a paid-tier key. Public repos + # get full functionality on the free tier without it. From f640d4523d9f4d21bd22defab9e3d773cad4c093 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 16:44:59 +0200 Subject: [PATCH 25/28] =?UTF-8?q?docs(changelog):=20fix=20npm=20scope=20@f?= =?UTF-8?q?oresthub=20=E2=86=92=20@foresthubai=20for=20ts=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37394a8..2302d71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ First tagged Go release after the `fh-core` → `edge-agents` repository rename. ## [ts/0.1.1] - 2026-05-29 First documented release of the TypeScript workspace -(`@foresthub/workflow-core`, `@foresthub/workflow-builder`). +(`@foresthubai/workflow-core`, `@foresthubai/workflow-builder`). ### Added - Visual workflow builder SPA (`workflow-builder`) with drag-and-drop canvas, From 31f0b1b9dabf6f6f6ac6c2a11d5e599a28714aa7 Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 16:45:32 +0200 Subject: [PATCH 26/28] ci: pin stoplightio/spectral-action version (drop @latest) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/spectral.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spectral.yml b/.github/workflows/spectral.yml index ef0ff8f..b662e70 100644 --- a/.github/workflows/spectral.yml +++ b/.github/workflows/spectral.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: stoplightio/spectral-action@latest + - uses: stoplightio/spectral-action@v0.8.13 with: file_glob: "contract/*.yaml" spectral_ruleset: .spectral.yaml From 495846afd07065fc946692c5e42ba4bfd87d353a Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 16:45:48 +0200 Subject: [PATCH 27/28] docs(codeowners): fix contract description (OpenAPI, not protobuf/JSON-Schema) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index eed99f7..47afa6c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -20,8 +20,8 @@ /ts/ @ForestHubAI/maintainers # --------------------------------------------------------------------------- -# Contract subtree — CRITICAL: drift between go/ and ts/ implementations -# of the protobuf/JSON-Schema contracts must always be reviewed. +# Contract subtree — CRITICAL: this is the source of truth. Drift between +# go/ and ts/ codegen from the OpenAPI 3.0.3 contracts must always be reviewed. # --------------------------------------------------------------------------- /contract/ @ForestHubAI/maintainers From 96b24a5199dd3839c78a33cb1c45e195737df35c Mon Sep 17 00:00:00 2001 From: MarcusBot Date: Sun, 31 May 2026 16:47:00 +0200 Subject: [PATCH 28/28] chore(llmproxy/config): remove leftover commented-out ResilienceConfig block Co-Authored-By: Claude Opus 4.7 (1M context) --- go/llmproxy/config/config.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/go/llmproxy/config/config.go b/go/llmproxy/config/config.go index 5a75696..1ac713e 100644 --- a/go/llmproxy/config/config.go +++ b/go/llmproxy/config/config.go @@ -33,17 +33,6 @@ type EmbeddingConfig struct { ExtractTimeout time.Duration `env:"EMBEDDING_EXTRACT_TIMEOUT" envDefault:"5m"` } -// ResilienceConfig holds the resilience configuration parameters -// type ResilienceConfig struct { -// MaxRetries int `json:"RESILIENCE_MAX_RETRIES" default:"3"` -// InitialDelay time.Duration `json:"RESILIENCE_INITIAL_DELAY" default:"1s"` -// MaxDelay time.Duration `json:"RESILIENCE_MAX_DELAY" default:"30s"` -// Multiplier float64 `json:"RESILIENCE_MULTIPLIER" default:"2.0"` -// Jitter bool `json:"RESILIENCE_JITTER" default:"true"` -// RequestTimeout time.Duration `json:"RESILIENCE_REQUEST_TIMEOUT" default:"30s"` -// ConnectTimeout time.Duration `json:"RESILIENCE_CONNECT_TIMEOUT" default:"10s"` -// } - // NewConfig creates a new configuration instance of the specified type // T is a generic type that allows creating configurations for different structs func NewConfig[T any]() *T {