ADR-108: Typed ToolResult with run-only artifacts
- Status
-
Accepted
- Date
-
2026-07-22
- Authors
-
Netresearch DTT GmbH
Context
Tool returned a plain string that
ToolLoopService fed straight back to the provider AND rendered
into the backend Tool Playground inspector. A tool that computes something
inherently structured — a set of records, a schema, a file listing — could only
flatten it to text, so the inspector had nothing richer to show than the same
line-based blob the model receives.
Adding structured output naively is a security problem: a tool result already egresses to an external LLM provider, so any structured payload bolted onto the return value would ride the wire too, widening the egress surface for attacker-influenceable tool bytes (the model is steerable by injected skill prose, see ADR-064).
ADR-094 introduced Tool as an opt-in
marker deliberately kept OFF Tool to avoid editing all 41
builtins at once. That precedent does not apply here: egress separation must BE
the return value (a structured channel that is unreachable from the wire), not
an annotation a tool may forget to add.
Decision
Replace execute with
execute — a final readonly value object
carrying exactly one provider-facing string $content, a bool $is,
and a list<Tool that is run-scoped: it flows only to
the trace, the inspector event stream and the persisted audit copy, and has NO
code path to the provider wire.
Egress separation is enforced by construction:
Toolhas noResult __and no accessor that merges an artifact into a wire string;to String () ->contentis the single path to a wire string.- The sole wire sink,
Chat, is only ever handedMessage:: tool Result () $result->content. Tool— the single seam every executed call passes through — UTF-8-coerces and byte-bounds BOTH channels before anyLoop Service:: invoke () Toolleaves the process.Result contentkeeps its existing 50 000-byte cap (cap);Result () artifactsget an independent 50 000-byte serialised budget (bound).Artifacts ()
The private constructor forces the Tool / Tool
factories; error carries no artifacts, so a failing tool can never leak a
half-built structure.
Artifact type model
Artifact is the smallest closed set whose every case has a v1 emitter
plus a fallback:
enum ArtifactType: string {
case TABLE = 'table'; // {columns: list<string>, rows: list<list<string>>}
case TEXT = 'text'; // {text: string} — fallback + "artifacts omitted" marker
}
This is a rendering shape, NOT a semantic taxonomy. TREE, LIST,
KEY_, LINK and CODE are additive follow-ups — each lands later
as one new enum case plus one JS branch, with no consumer or persisted-data
migration. No case ships without a committed producer: TREE (a page-tree
emitter) is intentionally deferred rather than shipped empty.
The sole v1 emitter is Read, which builds its TABLE rows from
the SAME already-redacted format cells its text lines use, in one
pass — the artifact can never drift from, or re-expose more than, the text
egress. Every other builtin ships text-parity via a mechanical
Tool wrap.
Fail-closed bounding
bound UTF-8-coerces every string leaf, then validates the whole
list with the EXACT flags the downstream sinks use
(JSON_) plus a depth-64 cap.
Anything that survives therefore cannot throw at
Tool (stream / respond) or
Agent — crash-safety by construction, not by a
lenient superset. On a Json (non-finite float, unencodable type,
over-depth) or an over-budget encode, the WHOLE list is replaced by a single
TEXT "Artifacts omitted" marker — never a mid-structure truncation.
Privacy
tool is added to Run's CONTENT_.
Consequences flow from the existing machinery:
- At the default METADATA/NONE level the artifact data is
unset; a summary (() tool+Artifacts Count tool) records shape and count but never bytes — mirroringArtifact Types tool.Result Length - At REDACTED the normalised
list<is masked by the existing recursive redactor with no new code (the{type,label,data}> typediscriminator andlabelare masked too; the JS renderer then falls to its unknown-shape fallback — fail-safe). - At FULL it is verbatim (deliberate).
As with tool (ADR-081), the LIVE NDJSON stream
renders unfiltered from memory (Run), so an admin's browser
sees full artifacts even at METADATA while the persisted copy is summarised.
This is intentional for the admin-only module, not a bypass.
Consequences
Toolis a breaking change across every builtin. That was 41 of them when this was written; the live count isInterface grep -, which answers 46 today. The magnitude is the point here, so it is anchored to the command rather than left as a number nothing re-derives. Pre-1.0 (ADR-090) this is acceptable and announced; third-party tools discovered via thel Tool Interface Classes/ Service/ Tool/ Builtin/*. php | wc - l nr_tag must return allm. tool Tool(theResult Toolfactory keeps the trivial case a one-line change).Result:: text () - The provider wire is unchanged:
Chatstill receives a string, so no provider adapter changes.Message:: tool Result () ToolandInvocation Rungain a typed artifact field (appended with a default, positions stable).Step Chat,Message ToolandLoop Result Suspendedare untouched — artifacts are audit/display state, never resumable functional state, so they must not ride the suspend payload.Run State - The inspector gains a conditional "Artifacts" tab rendering
TABLE/TEXTand an unknown-type JSON fallback, all viatext(neverContent inner) because artifacts are attacker-influenceable.HTML
See also ADR-010 (tool function-calling design), ADR-094 (tool data-class trust zones) and ADR-064 (event privacy).