No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Trung Nguyen d4980b4725
feat: aijson v0.1.0 — a forgiving JSON parser for LLM output
Drop-in replacement for encoding/json.Unmarshal that tolerates the
small set of shape mistakes LLMs repeat when emitting JSON for tool
calls.

The headline API is aijson.Unmarshal, which has the same signature as
encoding/json.Unmarshal:

    var args Args
    if err := aijson.Unmarshal(data, &args); err != nil {
        return err
    }

Unmarshal first tries a strict json.Unmarshal. On success it returns
immediately — valid input pays nothing. On failure it applies a
narrow, named set of shape repairs and retries the parse. If the
repair does not produce a parseable payload, the original strict
parse error is returned unchanged.

Four repairs:

  - Stringified array: "paths": "[\"a\",\"b\"]" → ["a","b"]
  - Bare scalar where array expected: "paths": "foo" → ["foo"]
  - Single-key object placeholder: "paths": {"path": "foo"} → ["foo"]
  - Null for primitive scalar (when custom UnmarshalJSON rejects null)

Optional OnRepair callback exposes the list of repair kinds that
fired, for telemetry.
2026-05-18 14:11:40 +02:00
.github feat: aijson v0.1.0 — a forgiving JSON parser for LLM output 2026-05-18 14:11:40 +02:00
aijson.go feat: aijson v0.1.0 — a forgiving JSON parser for LLM output 2026-05-18 14:11:40 +02:00
aijson_test.go feat: aijson v0.1.0 — a forgiving JSON parser for LLM output 2026-05-18 14:11:40 +02:00
go.mod feat: aijson v0.1.0 — a forgiving JSON parser for LLM output 2026-05-18 14:11:40 +02:00
go.sum feat: aijson v0.1.0 — a forgiving JSON parser for LLM output 2026-05-18 14:11:40 +02:00
LICENSE feat: aijson v0.1.0 — a forgiving JSON parser for LLM output 2026-05-18 14:11:40 +02:00
README.md feat: aijson v0.1.0 — a forgiving JSON parser for LLM output 2026-05-18 14:11:40 +02:00

aijson

encoding/json for JSON produced by LLMs.

One line to switch.

-import "encoding/json"
+import "github.com/docker/aijson"

-err := json.Unmarshal(data, &args)
+err := aijson.Unmarshal(data, &args)

That's the entire change. Wherever you parse JSON from a language model — tool calls today, structured outputs, JSON-mode responses, streaming partials next — drop in aijson.Unmarshal and stop losing turns to shape-only mistakes.

Why

A strict json.Unmarshal rejects paths: "foo" when the schema says paths: []string. The model retries with the same mistake — the error message it sees is unreadable JSON noise. A turn burns, the user waits.

aijson.Unmarshal does the obvious thing first: strict json.Unmarshal into your typed value. The 95% case pays nothing. Only when that fails does it apply a narrow, named set of shape repairs and try again. Repairs that don't fit return the original strict-parse error — no made-up complaints, no silent corruption.

The four repairs

Kind Before After
unwrap_string_array "paths": "[\"a\",\"b\"]" "paths": ["a","b"]
wrap_in_array "paths": "foo.txt" "paths": ["foo.txt"]
wrap_object_in_array "paths": {"path": "foo.txt"} "paths": ["foo.txt"]
drop_null "n": null (custom UnmarshalJSON) field removed

Each repair fires only when the destination type expects a specific shape the input doesn't have. The schema is the prior.

Install

go get github.com/docker/aijson

Telemetry

If you want to know when repairs fired (per-model, per-tool dashboards), pass an OnRepair callback:

err := aijson.Unmarshal(data, &args, aijson.OnRepair(func(kinds []aijson.Kind) {
    slog.Info("aijson_repaired", "kinds", kinds)
}))

The callback only fires when repairs actually fired. Valid input pays nothing — neither in CPU nor in callback noise.

Design

Validate-then-repair, not preprocess-then-validate

The naive approach is to walk every input and "fix" things that look broken before parsing. That silently corrupts: imagine a write_file tool whose content is a string that happens to look like "[1,2,3]". A generic preprocessor would turn it into an array and write garbage to disk.

aijson avoids this because repairs only run at field paths where the strict parse already failed. If content is typed as string, the parse succeeds and we never touch it. If paths is typed as []string, the parse fails with a type mismatch at paths, and only there do we try unwrapping.

There is a dedicated test (TestUnmarshal_SchemaIsThePrior) pinning this property.

Ordering is load-bearing

unwrap_string_array must run before wrap_in_array, otherwise a literal stringified array like "[\"a\",\"b\"]" would be wrapped as a single- element array and we'd silently corrupt the input. There is a dedicated test (TestUnmarshal_OrderingPreventsDoubleWrap) pinning this invariant.

API

func Unmarshal(data []byte, v any, opts ...Option) error
func OnRepair(fn func([]Kind)) Option

type Kind string
const (
    KindDropNull          Kind = "drop_null"
    KindUnwrapStringArray Kind = "unwrap_string_array"
    KindWrapObjectInArray Kind = "wrap_object_in_array"
    KindWrapInArray       Kind = "wrap_in_array"
)

Unmarshal's signature is intentionally identical to encoding/json.Unmarshal, extended only by variadic options.

Origin

Extracted from docker-agent (PR docker/docker-agent#2635), which generalised an edit_file-specific repair layer to every tool with an array-typed field.

License

Apache-2.0