Build

Described tool results

describedResult() — a tool returns rows, a series or relationships as typed data, with grain, freshness and coverage traveling with the numbers; a build gate refuses a triage tool that forgets its caveats, by name.

Your inventory tool reports jobs_local: 331 and jobs_replicated_in: 607. The model adds them and announces 938 protected jobs — a fleet nobody has, because those two numbers must never be summed. The tool's author knew that. The knowledge lived in a comment. describedResult() makes the caveats typed data that travel with the values, and check:semantics is the build gate that refuses a tool that forgot them.

The problem: honesty by culture

The conventions that make tool answers trustworthy — grain stated, counters marked, freshness declared, "not covered" named on every triage — get re-implemented by hand inside every tool and held in place by code review. That scales to one disciplined author. It does not scale to a hundred tools or to adapters written by other teams.

describedResult() turns the conventions into construction. Every value in the declaration comes from the data — here the export's own timestamp is measuredAt:

const backupStatus = defineTool({  name: 'vm_backup_status',  description: 'Backup posture for one VM across the Cohesity clusters',  resultClass: 'triage',  inputSchema: { type: 'object', properties: { vm: { type: 'string' } }, required: ['vm'] },  execute: ({ vm }: { vm: string }) => {    const { exportedAt, source, runs: all } = backupExport;    const runs = all.filter((r) => r.vm === vm); // the export lists runs oldest first    const latest = runs[runs.length - 1];    if (latest === undefined) {      return absent({ what: `backup runs for ${vm}`, checked: [`every backup job in the export of ${exportedAt}`] });    }    return describedResult({      facts: [{ entity: vm, backed_up: latest.ok === 1, copies: latest.copies, runs_seen: runs.length }],      series: runs.map((r) => ({ t: r.day, entity: vm, metric: 'backup_runs_ok', value: r.ok })),      // Each value counts ONE day's successful runs, so adding days gives the      // week's total: a per-day count is not a counter (isCounter: false). A      // cumulative total since the job was created would be isCounter: true.      grain: { interval: 'daily', aggregation: 'count', isCounter: false },      provenance: { measuredAt: exportedAt, source }, // the export's own timestamp      coverage: {        checked: [`every backup job in the export of ${exportedAt}`],        cannotCover: [{ what: 'PPDM', why: 'not collected on this install' }],      },      render: { default: 'table', columns: ['entity', 'backed_up', 'copies'], sort: 'entity' },    });  },});

The declaration is camelCase; the envelope the model and the record read is snake_case, and never changes:

fieldwhat it sayson the wire
seriesmeasured points — { t, entity, metric, value }series
factstyped rows about entities — every row names its entityfacts
edgestyped relationships — { from, to, kind }edges
grainwhat one value MEANS: interval, aggregation, isCounter, collapsedgrain, with is_counter
provenancemeasuredAt (when the WORLD was measured — not when the tool ran), source, ageSeconds, sourceExportDateprovenance, with measured_at, age_seconds, source_export_date
periodwhat time the READ behind the data covered: queried (the instants it asked for), held (what the store holds, or 'unknown'), readAt — ISO 8601 instants with a zone; the results layer files its verdict (The period a result covered)period, with read_at — served to the model as declared
coveragethe three-list vocabulary coverage() and absent() speak: checked, notChecked, cannotCovercoverage (not_checked, cannot_cover) and the not_covered lines composed from it
clarifythe ask-vs-answer decision as data: { question, candidates }, or null for "ambiguity considered, none found"clarify
renderhints for a UI — default, columns, sort, filterNote, chartHint; the model never sees themrender, with filter_note, chart_hint

isCounter means a cumulative reading a reader must never add up: a total since the job was created. One day's count of successful runs is not a counter — adding days gives the week's total — so the example states isCounter: false. Either way it must be stated when aggregation is counter-looking (sum, count, total, cumulative, counter, delta).

describedResult() refuses at the call site — the absent() law — anything the vocabulary cannot honor: series without grain, series or facts without provenance.measuredAt and provenance.source, a counter-looking aggregation with isCounter unstated. A missing provenance beside series or facts is also a compile error. A key the declaration — or its grain, provenance, coverage, clarify or render — does not have is refused too, never dropped, and a snake_case key names the camelCase one meant (provenance.measured_at → "did you mean measuredAt?"). Inside execute a refusal becomes the call's error result, which is what the model reads, so it starts refused: and names the field as you spelled it: refused: this result carries series/facts with no provenance — provenance.measuredAt and provenance.source are required….

Where it lives

  execute() returns describedResult({ … })
          │   nothing is added to the system prompt or the tool's schema
          │
          ├─▶ the tool message ─── what the MODEL reads: the data, grain,
          │                        provenance, one not_covered line per gap,
          │                        a non-null clarify, the static note
          │
          ├─▶ the run record ───── tools.semantics_declared: the WHOLE envelope
          │                        (checked list, render hints, marker) and the
          │                        coverage rows in coverageDeclared
          │
          └─▶ the final answer ─── only with .limitsTravelWithTheAnswer(): the
                                   coverage lists, appended — never provenance
                                   or grain
  • It is the tool's response. Nothing goes into the system prompt or the tool spec; the envelope is the value execute returns.
  • The model reads a projection, semanticsForModel: the data, grain, provenance, a declared period as declared, the not_covered lines composed from coverage (never hand-written, so the two cannot disagree), a non-null clarify, and a static note telling it how to read the caveats. It does NOT read the checked list, the render hints or the af_semantics marker. (absent() and coverage() do serve their checked lists — see what each helper serves.)
  • The record keeps everything, on the typed agentfootprint.tools.semantics_declared event — one per recognized envelope, stamped with the tool name, the tool call id and the iteration — and the coverage lands in sharedState.coverageDeclared beside every coverage() and absent() statement. The event is emitted before the tool's resultCeiling is measured, so an oversized result that is refused still leaves its grain and provenance on the record.
  • Only coverage reaches the final answer, and only with .limitsTravelWithTheAnswer(). Provenance and grain are for the model and the record.
  • A fact without a source never reaches the model. It is refused inside the tool; the model reads the refusal in its place, and the run continues.
  • An empty list is not "nothing matched". series, facts and edges are never empty, so a found branch cannot also be the not-found branch: describedResult({ facts: [] }) is refused with `facts` is empty — if nothing matched, return absent({ what, checked }) instead. Write both: rows.length ? describedResult({ … }) : absent({ … }). check:semantics says the same about a sample that exercises the empty branch.
  • measuredAt is never parsed. It is the tool's own words for a time — "last year, roughly" passes — and it rides to the model as written.
agent.on('agentfootprint.tools.semantics_declared', (e) => {
  e.payload.semantics; // the FULL envelope — render hints, coverage, all of it
});

It lands in recordings (recordRun / persistRecording), so a UI can draw the table the author hinted at months after the run.

Composition: the result envelopes stack. { content: describedResult({...}), effects: [...], status: 'partial' } is one result that carries typed data AND a tool effect AND an outcome status. absent() remains the answer for "I looked and found nothing"; never wrap a described result in coverage() — it carries its own.

Values from the data

measuredAt is when the world was measured, and the data knows when that was. Take it from the data; never type a date into a tool:

the resultmeasuredAt is
rows from an export or a nightly filethe export's own timestamp
a live readthe moment of the read — take new Date().toISOString() when you query
a seriesthe newest sample's time
a value computed over a window ("the last 24h")the END of the window

ageSeconds is how old that was when the tool answered. It must be a finite number ≥ 0, so compute it only from a time you can parse — for an ISO export time, Math.max(0, Math.round((Date.now() - Date.parse(exportedAt)) / 1000)) — and leave it out otherwise; measuredAt still says when.

A computed result is still data from its system of record — a count over the export's rows carries the export's provenance:

const failed = runs.filter((r) => r.ok === 0);
return describedResult({
  facts: [{ entity: vm, failed_runs: failed.length, runs_seen: runs.length }],
  provenance: { measuredAt: snapshot.exportedAt, source: snapshot.source },
});

A clarify-only result hands the question back instead of picking silently; it needs no provenance:

const matches = customers.filter((c) => c.name.startsWith(query));
if (matches.length > 1) {
  return describedResult({
    clarify: {
      question: `${matches.length} customers match "${query}" — which one?`,
      candidates: matches.map((c) => c.name),
    },
  });
}

clarify does not pause the run: the model reads the question and decides what to do with it — often, to ask the person in its answer. To stop the run until a person supplies a value, use requestInput().

check:semantics — the build gate

The gate judges sample results (what your mock tools return — a build must never run tools that touch live systems) against the envelope rules and the per-class rules declared on defineTool({ resultClass }):

ruleseverity
'triage' result with no coverage declaration (a described result's coverage, coverage(), or absent())error
'inventory' result with no coverage declarationerror
marker-bearing envelope with faults: series-without-grain, data-without-provenance, counter-aggregation-unstated, malformed-semanticserror
'inventory' with facts but no render hintwarning
a classed tool with zero sample resultswarning

Severity follows provability: only what the declarations prove wrong errors. Every finding names the tool and the field, and a missing coverage tells the author to return describedResult({ …, coverage }), coverage(result, …) or absent({ … }).

Wire it beside your other gates (the check:tools convention) — dump [{ name, resultClass, results: [...] }] to JSON and:

{
  "scripts": {
    "check:semantics": "agentfootprint-check-semantics semantics-catalog.json"
  }
}

Exit codes: 0 ok, 1 findings failed the gate (--strict gates warnings too), 2 input error. The same core is callable in-process:

import { checkSemantics, formatSemanticsReport } from 'agentfootprint/observe';

const report = checkSemantics([
  { name: 'vm_backup_status', resultClass: 'triage', results: [sampleResult] },
]);
if (!report.ok) throw new Error(formatSemanticsReport(report));

Zero-cost when unused

Recognition is strict: only a plain object whose af_semantics is exactly true and that passes the whole rule set is an envelope. Every other value any tool has ever returned — including a marker-bearing shape with faults, which is dev-warned and named by the gate but never half-applied — takes the exact path it always took, byte for byte.

Migration from the removed declaration door

The deprecated semantic() function and SemanticDeclaration type have been removed. Use describedResult() and DescribedResultDeclaration. In declarations, rename is_counter → isCounter, measured_at → measuredAt, age_seconds → ageSeconds, source_export_date → sourceExportDate, filter_note → filterNote and chart_hint → chartHint. Coverage declarations already use notChecked and cannotCover; keep those names. Series and facts require provenance in the declaration type as well as at runtime.

Do not rename keys in saved records or foreign-language wire envelopes: ToolSemantics, its snake_case fields, the marker, note and model projection are unchanged. readSemantics() continues to recognize valid saved envelopes.

Vocabulary reference

The authoring half ships on the main barrel (agentfootprint). describedResult is the single authoring door and mints the envelope from a DescribedResultDeclaration. The rendered value is a ToolSemantics carrying SemanticSeriesPoint rows, SemanticFact rows, SemanticEdge links, a SemanticGrain, a SemanticProvenance, a SemanticCoverage (whose derived not_covered lines are composed by composeNotCovered), a SemanticClarify question and SemanticRender hints. readSemantics is the strict recognizer the dispatch loop uses; explainSemantics names the faults — SemanticIssue rows, each with a SemanticIssueCode — on a marker-bearing value that was not recognized; semanticsForModel builds the model's compact projection; isCounterLookingAggregation and the COUNTER_AGGREGATION_WORDS list decide when is_counter must be stated. The marker key is SEMANTICS_MARKER (the string af_semantics), the static note text is SEMANTICS_NOTE, the closed class set is RESULT_CLASSES (typed as ToolResultClass), and assertResultClass is the definition-time refusal defineTool runs on a declared class.

SEMANTICS_NOTE and SEMANTICS_MARKER are also published as data, beside the coverage/absence notes, at node_modules/agentfootprint/canonical-notes.json — for a tool written in a language that cannot import them. See reading the canonical notes from another language.

The gate half ships on agentfootprint/observe. checkSemantics judges an array of SemanticsCatalogEntry rows and returns a SemanticsReport of SemanticsFinding rows, each carrying a SemanticsFindingCode, the tool and the field; formatSemanticsReport renders the report for a terminal; coerceSemanticsCatalog normalizes the accepted JSON shapes; and runCheckSemanticsCli is the unit-testable CLI core behind the agentfootprint-check-semantics bin, with its IO injected through SemanticsCliIO.

Next steps

  • Runnable example 66 — the two views, the coverage absorb, one execute with all three helpers, and the gate failing a broken tool by name
  • Tools — which helper to return, and exactly what the model reads from each
  • Unsupported claims — .claims() checks an answer field against a fact a described result settled

On this page