Agent-First Data v0.19: Cleaner Output, Safer Edits

by Agent-First Kit Contributors

v0.19 sharpens both ends of afdata. On the way out: a one-shot CLI puts its result on stdout and every diagnostic on stderr, --output-to overrides it, and finish() maps outcomes to broken-pipe-safe exit codes. On the way in: afdata now reads and edits a Markdown page's +++/--- frontmatter by dot-path, and reading a secret-bearing config no longer risks leaking the file into an error.

afdata sits between an agent and its data twice over: it hands results back out, and it reaches in to files to read and change them. v0.19 sharpens both ends — what comes out of a command, and what goes into a file.

It’s a breaking release — AFDATA is still pre-1.0 and carries no compatibility layer.

The result is the only thing on stdout

A tool that prints its answer and its complaint on the same stream pushes the problem to everyone downstream. x=$(cmd) captures whatever lands on stdout — the result on a good day, a JSON error envelope on a bad one — and now $x is a lie. An agent parsing stdout for its answer trips over a diagnostic that was never the answer.

v0.19 makes the stream an event lands on a function of what the event is. A one-shot command emits one terminal event and routes by its kind: the result goes to stdout, and an error, progress, or log goes to stderr. So the classic shell capture is safe by construction:

port=$(afdata value config.toml server.port)   # stdout: the scalar, or nothing

Ask for a key that isn’t there and stdout stays empty — the error is on stderr, where a shell already sends diagnostics — so $port is never a JSON error masquerading as a value. Pair it with --default when “missing or null → fall back” is the point, and the 2>/dev/null guard disappears too.

This is uniform now. value, paths, and keys already kept stdout clean on failure; the odd one out was get, which wrote its error onto stdout — the one place a captured value could silently become an error. Routing by kind closes that: a get that fails is a kind:"error" event, and error events go to stderr, full stop.

--output-to when you want it all in one place

The split is the default, not a mandate. One global flag overrides it:

afdata value config.toml server.port --output-to stdout   # result AND errors on stdout
afdata get config.toml --output-to stderr                 # everything on stderr

--output-to stdout is how you capture a complete session — result and diagnostics interleaved in order — or feed it to --stdout-file for a log. split (the default), stdout, and stderr are the three choices, and routing always follows the event’s kind, never the process exit code.

That distinction is the second mode in disguise. The split is right for a command that returns one answer; it is wrong for one whose output is a sequence — a long query that emits progress, then rows, then a status, where the interleaving order carries information. In the library it’s which constructor you reach for:

let mut out = CliEmitter::finite(format);                  // one-shot: split by kind
let mut out = CliEmitter::stream(io::stdout().lock(), format); // every event on one ordered stream

or CliEmitter::from_output_to(selector, format) when a --output-to flag decides it at runtime. Pick by consumption model, not reflex.

Exit codes that survive a broken pipe

Turning the last event into an exit code used to be hand-rolled at every call site — and the easy version gets a broken pipe wrong. Pipe a command into head and the reader hangs up early; the final write fails with EPIPE; a naive handler reports failure for a run that did exactly what was asked.

finish and finish_result fold that into one call:

return emitter.finish_result(value);   // success -> 0; broken pipe -> 0; other write error -> 4

let event = json_error("invalid_request", &msg).hint_if_some(hint).build()?;
return emitter.finish(event, 1);       // an error is just the json_error builder, finished

The builder is the error type — there’s no separate error-and-exit helper — and the library never calls process::exit itself. It hands the code back and lets main return it, so it composes instead of tearing the process down from inside a helper.

Editing a Markdown page’s frontmatter

Config isn’t always a config.toml. Half the config an agent touches lives in the +++ or --- block at the top of a Markdown page — a blog post’s date, a doc’s weight, a page’s draft flag. v0.19 lets afdata address that block directly:

afdata value post.md date --input-format yaml-frontmatter
afdata set post.md draft false --value-type bool --input-format toml-frontmatter

The same read and mutate verbs work inside the frontmatter, addressed by dot-path, and the Markdown body is left byte-for-byte untouched — the change lands in the header, not in a reflowed document. toml-frontmatter reads a +++ block, yaml-frontmatter a --- block.

Frontmatter is never auto-detected: extension detection only ever resolves to a whole-file format (.md is Markdown, not “the TOML inside the Markdown”), so you name the mode deliberately. That’s the point — reaching into a page’s header is a choice you make on purpose, not something a .md extension does behind your back.

Reading a secret config without leaking it

Reading a config is exactly where a credential leaks, and v0.19 hardens the library path around a real job — afpsql pulling a database secret out of a config file it doesn’t control. Three sharp edges, now handled by afdata instead of every caller:

let doc = DocumentFile::open_capped(path, None, MAX_BYTES)?; // size + regular-file guard, before reading
let dsn = doc.value_at("database.url_secret")?;             // one call to a dot-path

open_capped rejects an oversized or non-regular file before reading a byte — an unbounded read of an arbitrary path is a denial-of-service waiting to happen. value_at collapses open-parse-traverse into one call, the read counterpart to set. And when parsing fails, DocumentError::redacted_message() (and the raw location()) give you the position — failed to parse YAML at line 5 column 12 — with the offending source line dropped, because that line is exactly where a secret would be. A config reader’s error must not be the thing that spills the config.

The small stuff: accessors instead of match arms

The document types grew the accessors a consumer kept re-implementing: DocumentError::code() for the stable error code, Format::name() for a display label, Value::kind_name() for a type name. A caller branching on an error or naming a format no longer matches the public enum by hand — it asks the value what it is.

Upgrading

afdata always knew which of its outputs was the answer and which was commentary, and which byte of a file it meant to change. Now the streams say the first and the edits respect the second — so nothing reading afdata, and no file afdata touches, has to find out the hard way.